1use crate::progress::ProgressSender;
12use deps_core::ConcreteVersion;
13use deps_core::Deprecation;
14use deps_core::FetchFailure;
15use deps_core::PackageName;
16use deps_core::PackageVersions;
17use deps_core::Registry;
18use deps_core::RemovalStatus;
19use deps_core::Version;
20use deps_core::VersionReq;
21use std::collections::{HashMap, HashSet};
22use std::num::NonZeroUsize;
23use std::sync::Arc;
24use std::time::Duration;
25
26pub type DepSources = Vec<(PackageName, deps_core::parser::DependencySource)>;
29
30pub fn dedup_dependencies_by_source(
96 parse_result: &dyn deps_core::ParseResult,
97 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
98) -> (
99 HashMap<PackageName, deps_core::parser::DependencySource>,
100 HashSet<PackageName>,
101) {
102 use std::collections::hash_map::Entry;
103
104 let mut by_name: HashMap<PackageName, deps_core::parser::DependencySource> = HashMap::new();
105 let mut collided: HashSet<PackageName> = HashSet::new();
106
107 for dep in parse_result
108 .dependencies()
109 .into_iter()
110 .filter(|dep| formatter.can_resolve_source(&dep.source()))
111 {
112 let name = dep.name().clone();
113 let source = dep.source();
114 match by_name.entry(name.clone()) {
115 Entry::Vacant(entry) => {
116 entry.insert(source);
117 }
118 Entry::Occupied(entry) => {
119 if *entry.get() != source && collided.insert(name.clone()) {
120 tracing::warn!(
121 package = %name.for_tracing(),
122 source_a = ?entry.get(),
123 source_b = ?source,
124 "dependency declared against two different resolved registries; \
125 skipping version resolution for all occurrences"
126 );
127 }
128 }
129 }
130 }
131
132 for name in &collided {
133 by_name.remove(name);
134 }
135 (by_name, collided)
136}
137
138#[non_exhaustive]
149pub struct FetchPreparation {
150 pub dep_sources: DepSources,
155 pub in_use: HashMap<PackageName, Vec<ConcreteVersion>>,
157 pub selection_context: deps_core::SelectionContext,
160 pub collided_names: HashSet<PackageName>,
163}
164
165pub fn prepare_fetch(
208 parse_result: &dyn deps_core::ParseResult,
209 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
210 ecosystem: deps_core::EcosystemId,
211 resolved_versions: &HashMap<PackageName, ConcreteVersion>,
212 resolved_version_candidates: &HashMap<PackageName, Vec<ConcreteVersion>>,
213) -> FetchPreparation {
214 let (sources_map, collided_names) = dedup_dependencies_by_source(parse_result, formatter);
215 let dep_sources: DepSources = sources_map.into_iter().collect();
216 let in_use = crate::classify::resolved::collect_in_use_versions(
217 parse_result,
218 resolved_versions,
219 resolved_version_candidates,
220 formatter,
221 ecosystem,
222 );
223 FetchPreparation {
224 dep_sources,
225 in_use,
226 selection_context: parse_result.selection_context(),
227 collided_names,
228 }
229}
230
231#[non_exhaustive]
233pub struct FetchResult {
234 pub versions: HashMap<PackageName, PackageVersions>,
236 pub yanked_versions: HashMap<PackageName, (ConcreteVersion, RemovalStatus)>,
245 pub deprecations: HashMap<PackageName, Deprecation>,
250 pub fetch_failed: HashMap<PackageName, FetchFailure>,
256 pub no_comparable_versions: HashSet<PackageName>,
263 pub failure_summary: Option<FailureSummary>,
273 pub licenses: HashMap<PackageName, Vec<String>>,
288}
289
290#[derive(Debug, Clone, PartialEq, Eq)]
311pub struct FailureSummary {
312 count: NonZeroUsize,
313 message: String,
314}
315
316impl FailureSummary {
317 #[must_use]
319 pub fn new(count: NonZeroUsize, message: String) -> Self {
320 Self { count, message }
321 }
322
323 #[must_use]
326 pub fn count(&self) -> usize {
327 self.count.get()
328 }
329
330 #[must_use]
332 pub fn message(&self) -> &str {
333 &self.message
334 }
335}
336
337impl FetchResult {
338 #[must_use]
370 #[allow(clippy::too_many_arguments)]
371 pub fn new(
372 versions: HashMap<PackageName, PackageVersions>,
373 yanked_versions: HashMap<PackageName, (ConcreteVersion, RemovalStatus)>,
374 deprecations: HashMap<PackageName, Deprecation>,
375 fetch_failed: HashMap<PackageName, FetchFailure>,
376 no_comparable_versions: HashSet<PackageName>,
377 failure_summary: Option<FailureSummary>,
378 licenses: HashMap<PackageName, Vec<String>>,
379 ) -> Self {
380 Self {
381 versions,
382 yanked_versions,
383 deprecations,
384 fetch_failed,
385 no_comparable_versions,
386 failure_summary,
387 licenses,
388 }
389 }
390
391 #[must_use]
397 pub fn failed_count(&self) -> usize {
398 self.failure_summary
399 .as_ref()
400 .map_or(0, FailureSummary::count)
401 }
402
403 #[must_use]
406 pub fn failure_message(&self) -> Option<&str> {
407 self.failure_summary.as_ref().map(FailureSummary::message)
408 }
409}
410
411#[allow(
543 clippy::too_many_arguments,
544 reason = "internal (non-pub) call-site-controlled fetch tuning + ecosystem-context \
545 parameters; grouping into a config struct would only move, not reduce, the \
546 per-call-site churn across this module's ~15 production and test call sites"
547)]
548pub async fn fetch_latest_versions_parallel(
549 registry: Arc<dyn Registry>,
550 package_sources: DepSources,
551 in_use: &HashMap<PackageName, Vec<ConcreteVersion>>,
552 progress_sender: Option<ProgressSender>,
553 freshness: deps_core::freshness::FreshnessSettings,
554 timeout_secs: u64,
555 max_concurrent: usize,
556 selection_context: &deps_core::SelectionContext,
557 gossip: Option<&HashMap<PackageName, deps_core::GossipFindings>>,
558) -> FetchResult {
559 use futures::stream::{self, StreamExt};
560 use std::time::Duration;
561
562 let fetched = Arc::new(std::sync::atomic::AtomicUsize::new(0));
563 let timeout = Duration::from_secs(timeout_secs);
564 let wildcard_req = deps_core::VersionReq::new("*");
565 let check_yanked = registry.reports_yanked();
566
567 let results: Vec<_> = stream::iter(package_sources)
568 .map(|(name, source)| {
569 let registry = Arc::clone(®istry);
570 let fetched = Arc::clone(&fetched);
571 let progress_sender = progress_sender.clone();
572 let wildcard_req = &wildcard_req;
573 let in_use_versions = in_use.get(&name).cloned().unwrap_or_default();
574 async move {
575 fetch_and_classify_package(
576 registry.as_ref(),
577 name,
578 source,
579 in_use_versions,
580 wildcard_req,
581 freshness,
582 timeout,
583 selection_context,
584 check_yanked,
585 gossip,
586 &fetched,
587 progress_sender.as_ref(),
588 )
589 .await
590 }
591 })
592 .buffer_unordered(max_concurrent.max(1))
596 .collect()
597 .await;
598
599 let mut versions = HashMap::with_capacity(results.len());
600 let mut yanked_versions = HashMap::new();
601 let mut fetch_failed = HashMap::new();
602 let mut deprecations = HashMap::new();
603 let mut no_comparable_versions = HashSet::new();
604 let mut licenses = HashMap::new();
605 let mut failed_count: usize = 0;
606 let mut priority_message: Option<String> = None;
610 let mut fallback_message: Option<String> = None;
611 for PackageOutcome {
612 name,
613 status,
614 yanked,
615 } in results
616 {
617 if let Some(y) = yanked {
618 yanked_versions.insert(name.clone(), y);
619 }
620 match status {
621 PackageStatus::Resolved {
622 versions: v,
623 deprecation,
624 license,
625 } => {
626 if let Some(d) = deprecation {
627 deprecations.insert(name.clone(), d);
628 }
629 if let Some(lic) = license {
630 licenses.insert(name.clone(), lic);
631 }
632 versions.insert(name, v);
633 }
634 PackageStatus::NoComparableVersions => {
635 no_comparable_versions.insert(name);
636 }
637 PackageStatus::NotFound { message } => {
638 failed_count += 1;
639 if fallback_message.is_none() {
640 fallback_message = Some(message);
641 }
642 }
643 PackageStatus::Failed { failure, message } => {
644 failed_count += 1;
645 fetch_failed.insert(name, failure);
646 if priority_message.is_none() {
647 priority_message = Some(message.clone());
648 }
649 if fallback_message.is_none() {
650 fallback_message = Some(message);
651 }
652 }
653 }
654 }
655
656 let failure_summary = NonZeroUsize::new(failed_count).map(|count| {
657 #[allow(clippy::expect_used)]
658 let message = priority_message.or(fallback_message).expect(
659 "every branch that increments failed_count also sets fallback_message \
660 in the same match arm",
661 );
662 FailureSummary::new(count, message)
663 });
664
665 FetchResult {
666 versions,
667 yanked_versions,
668 deprecations,
669 fetch_failed,
670 no_comparable_versions,
671 failure_summary,
672 licenses,
673 }
674}
675
676enum PackageStatus {
687 Resolved {
690 versions: PackageVersions,
691 deprecation: Option<Deprecation>,
693 license: Option<Vec<String>>,
696 },
697 NoComparableVersions,
700 NotFound { message: String },
703 Failed {
705 failure: FetchFailure,
706 message: String,
707 },
708}
709
710struct PackageOutcome {
720 name: PackageName,
721 status: PackageStatus,
722 yanked: Option<(ConcreteVersion, RemovalStatus)>,
723}
724
725struct ResolvedPick {
730 version: ConcreteVersion,
731 removal_status: RemovalStatus,
732 published_at: Option<deps_core::freshness::PublishTime>,
733 deprecation: Option<Deprecation>,
734 license: Vec<String>,
735}
736
737enum Pick {
742 Resolved(ResolvedPick),
743 Unresolved(PackageStatus),
744}
745
746impl Pick {
747 fn resolved(v: &dyn Version) -> Self {
750 Self::Resolved(ResolvedPick {
751 version: v.version_string().clone(),
752 removal_status: v.removal_status(),
753 published_at: v.published_at(),
754 deprecation: v.deprecation().cloned(),
755 license: v.license().to_vec(),
756 })
757 }
758}
759
760#[allow(
766 clippy::too_many_arguments,
767 reason = "mirrors the per-package async closure this was extracted from — every \
768 parameter is either call-site fetch tuning already threaded through \
769 fetch_latest_versions_parallel or a counter/sender shared across the \
770 whole stream; grouping into a struct would only move, not reduce, churn"
771)]
772async fn fetch_and_classify_package(
773 registry: &dyn Registry,
774 name: PackageName,
775 source: deps_core::parser::DependencySource,
776 in_use_versions: Vec<ConcreteVersion>,
777 wildcard_req: &VersionReq,
778 freshness: deps_core::freshness::FreshnessSettings,
779 timeout: Duration,
780 selection_context: &deps_core::SelectionContext,
781 check_yanked: bool,
782 gossip: Option<&HashMap<PackageName, deps_core::GossipFindings>>,
783 fetched: &std::sync::atomic::AtomicUsize,
784 progress_sender: Option<&ProgressSender>,
785) -> PackageOutcome {
786 let result = tokio::time::timeout(
792 timeout,
793 registry.get_versions_from(&name, &source, freshness),
794 )
795 .await;
796
797 let mut yanked: Option<(ConcreteVersion, RemovalStatus)> = None;
798
799 let status = match result {
800 Ok(Ok(versions)) => {
801 let available: Arc<[ConcreteVersion]> = versions
802 .iter()
803 .map(|v| v.version_string().clone())
804 .collect();
805 let yanked_list: Arc<[(ConcreteVersion, RemovalStatus)]> = if check_yanked {
812 versions
813 .iter()
814 .filter_map(|v| {
815 let status = v.removal_status();
816 status
817 .is_flagged()
818 .then(|| (v.version_string().clone(), status))
819 })
820 .collect()
821 } else {
822 Arc::from([])
823 };
824
825 let in_use_yanked: Option<(ConcreteVersion, RemovalStatus)> = check_yanked
834 .then(|| {
835 in_use_versions.iter().find_map(|iv| {
836 versions
837 .iter()
838 .find(|v| v.version_string() == iv && v.removal_status().is_flagged())
839 .map(|v| (iv.clone(), v.removal_status()))
840 })
841 })
842 .flatten();
843
844 let unfiltered_pick_idx =
851 registry.select_latest_matching(&versions, wildcard_req, selection_context);
852 let unfiltered_pick_version: Option<ConcreteVersion> = unfiltered_pick_idx
853 .and_then(|idx| versions.get(idx))
854 .map(|v| v.version_string().clone());
855
856 let now = deps_core::freshness::PublishTime::now();
858 let unfiltered_pick_flagged = unfiltered_pick_version.as_ref().is_some_and(|version| {
861 deps_core::lsp_helpers::gossip_cooldown_for(gossip, &name, version.as_str(), now)
862 == deps_core::lsp_helpers::GossipCooldownLookup::Active
863 });
864
865 let cooldown_fallback = compute_cooldown_fallback(
872 registry,
873 &versions,
874 &name,
875 &in_use_versions,
876 wildcard_req,
877 *selection_context,
878 freshness,
879 gossip,
880 now,
881 unfiltered_pick_idx,
882 );
883
884 let (list_pick, gossip_excluded_version) = gossip_floor_protected_pick(
887 registry,
888 versions,
889 &in_use_versions,
890 wildcard_req,
891 *selection_context,
892 unfiltered_pick_idx,
893 unfiltered_pick_version,
894 unfiltered_pick_flagged,
895 gossip,
896 &name,
897 now,
898 );
899
900 let pick = if let Some(v) = list_pick.as_deref() {
905 tracing::debug!(
906 package = %name.for_tracing(),
907 version = %v.version_string(),
908 "fetched"
909 );
910 Pick::resolved(v)
911 } else {
912 get_latest_matching_fallback(
917 registry,
918 &name,
919 &source,
920 wildcard_req,
921 *selection_context,
922 timeout,
923 )
924 .await
925 };
926
927 let resolved: Option<&ResolvedPick> = match &pick {
928 Pick::Resolved(r) => Some(r),
929 Pick::Unresolved(_) => None,
930 };
931
932 if check_yanked {
933 if let Some(r) = resolved
937 && r.removal_status.is_flagged()
938 {
939 yanked = Some((r.version.clone(), r.removal_status));
940 }
941
942 if let Some(iv_yanked) = in_use_yanked {
946 yanked = Some(iv_yanked);
947 }
948 }
949
950 let deprecation = resolved.and_then(|r| r.deprecation.clone());
955
956 let license = resolved
960 .map(|r| &r.license)
961 .filter(|lic| !lic.is_empty())
962 .cloned();
963
964 match pick {
965 Pick::Resolved(ResolvedPick {
966 version,
967 published_at,
968 ..
969 }) => {
970 let mut versions =
971 PackageVersions::new(version, available).with_yanked(yanked_list);
972 if let Some(published_at) = published_at {
973 versions = versions.with_published_at(published_at);
974 }
975 if let Some(excluded) = gossip_excluded_version {
976 versions = versions.with_gossip_excluded_version(excluded);
977 }
978 if let Some(fallback) = cooldown_fallback {
979 versions = versions.with_cooldown_fallback(fallback);
980 }
981 PackageStatus::Resolved {
982 versions,
983 deprecation,
984 license,
985 }
986 }
987 Pick::Unresolved(status) => status,
988 }
989 }
990 Ok(Err(e)) => {
991 if e.is_offline() {
996 tracing::debug!(package = %name.for_tracing(), "fetch skipped: offline");
997 } else {
998 tracing::warn!(package = %name.for_tracing(), error = %e, "fetch failed");
999 }
1000 if e.is_not_found() {
1004 PackageStatus::NotFound {
1005 message: e.to_string(),
1006 }
1007 } else {
1008 PackageStatus::Failed {
1009 failure: e.fetch_failure(),
1010 message: e.to_string(),
1011 }
1012 }
1013 }
1014 Err(_) => {
1015 tracing::warn!(package = %name.for_tracing(), "fetch timed out ({}s)", timeout.as_secs());
1016 PackageStatus::Failed {
1017 failure: FetchFailure::Transient,
1018 message: format!(
1019 "{}: registry request timed out after {}s",
1020 name.for_tracing(),
1021 timeout.as_secs()
1022 ),
1023 }
1024 }
1025 };
1026
1027 let count = fetched.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
1028 if let Some(sender) = progress_sender {
1029 sender.send(count);
1030 }
1031
1032 PackageOutcome {
1033 name,
1034 status,
1035 yanked,
1036 }
1037}
1038
1039#[allow(
1050 clippy::too_many_arguments,
1051 reason = "every parameter is either data already owned by the caller (versions, the \
1052 in-use floor inputs) or a piece of the unfiltered pick the caller computed \
1053 once and must not recompute here — grouping into a struct would only move, \
1054 not reduce, the parameter count"
1055)]
1056fn gossip_floor_protected_pick(
1057 registry: &dyn Registry,
1058 versions: Vec<Box<dyn Version>>,
1059 in_use_versions: &[ConcreteVersion],
1060 wildcard_req: &VersionReq,
1061 selection_context: deps_core::SelectionContext,
1062 unfiltered_pick_idx: Option<usize>,
1063 unfiltered_pick_version: Option<ConcreteVersion>,
1064 unfiltered_pick_flagged: bool,
1065 gossip: Option<&HashMap<PackageName, deps_core::GossipFindings>>,
1066 name: &PackageName,
1067 now: deps_core::freshness::PublishTime,
1068) -> (Option<Box<dyn Version>>, Option<ConcreteVersion>) {
1069 if !unfiltered_pick_flagged {
1070 return (
1071 unfiltered_pick_idx.and_then(|idx| versions.into_iter().nth(idx)),
1072 None,
1073 );
1074 }
1075
1076 let is_gossip_cooldown = |version: &ConcreteVersion| {
1077 deps_core::lsp_helpers::gossip_cooldown_for(gossip, name, version.as_str(), now)
1078 == deps_core::lsp_helpers::GossipCooldownLookup::Active
1079 };
1080
1081 let floor = match in_use_floor(in_use_versions, &versions) {
1086 InUseFloor::Located(floor)
1087 | InUseFloor::Unlocatable {
1088 newest_located: Some(floor),
1089 } => floor,
1090 InUseFloor::Absent
1091 | InUseFloor::Unlocatable {
1092 newest_located: None,
1093 } => {
1094 return (
1095 unfiltered_pick_idx.and_then(|idx| versions.into_iter().nth(idx)),
1096 None,
1097 );
1098 }
1099 };
1100
1101 type IndexedVersion = (usize, Box<dyn Version>);
1107 let mut filtered_indices: Vec<usize> = Vec::new();
1108 let mut filtered_versions: Vec<Box<dyn Version>> = Vec::new();
1109 let mut dropped: Vec<IndexedVersion> = Vec::new();
1110 for (idx, v) in versions.into_iter().enumerate() {
1111 if idx >= floor || !is_gossip_cooldown(v.version_string()) {
1112 filtered_indices.push(idx);
1113 filtered_versions.push(v);
1114 } else {
1115 dropped.push((idx, v));
1116 }
1117 }
1118
1119 let filtered_pick_idx =
1120 registry.select_latest_matching(&filtered_versions, wildcard_req, &selection_context);
1121 let pick_at_or_above_floor = filtered_pick_idx
1123 .and_then(|idx| filtered_indices.get(idx))
1124 .is_some_and(|original_idx| *original_idx <= floor);
1125
1126 if pick_at_or_above_floor {
1127 let filtered_pick_version = filtered_pick_idx
1128 .and_then(|idx| filtered_versions.get(idx))
1129 .map(|v| v.version_string().clone());
1130 let excluded = (filtered_pick_version != unfiltered_pick_version)
1131 .then(|| unfiltered_pick_version.clone())
1132 .flatten();
1133 (
1134 filtered_pick_idx.and_then(|idx| filtered_versions.into_iter().nth(idx)),
1135 excluded,
1136 )
1137 } else {
1138 let recovered = unfiltered_pick_idx.and_then(|target| {
1141 dropped
1142 .iter()
1143 .position(|(idx, _)| *idx == target)
1144 .map(|i| dropped.swap_remove(i).1)
1145 .or_else(|| {
1146 filtered_indices
1147 .iter()
1148 .position(|idx| *idx == target)
1149 .map(|i| filtered_versions.swap_remove(i))
1150 })
1151 });
1152 (recovered, None)
1153 }
1154}
1155
1156async fn get_latest_matching_fallback(
1163 registry: &dyn Registry,
1164 name: &PackageName,
1165 source: &deps_core::parser::DependencySource,
1166 wildcard_req: &VersionReq,
1167 selection_context: deps_core::SelectionContext,
1168 timeout: Duration,
1169) -> Pick {
1170 let fallback = tokio::time::timeout(
1171 timeout,
1172 registry.get_latest_matching_from(name, source, wildcard_req, &selection_context),
1173 )
1174 .await;
1175 match fallback {
1176 Ok(Ok(Some(v))) => {
1177 tracing::debug!(
1178 package = %name.for_tracing(),
1179 version = %v.version_string(),
1180 "fetched via get_latest_matching fallback"
1181 );
1182 Pick::resolved(v.as_ref())
1183 }
1184 Ok(Ok(None)) => {
1185 tracing::debug!(package = %name.for_tracing(), "no version found");
1186 Pick::Unresolved(PackageStatus::NoComparableVersions)
1190 }
1191 Ok(Err(e)) => {
1192 tracing::warn!(
1193 package = %name.for_tracing(),
1194 error = %e,
1195 "fetch fallback failed"
1196 );
1197 if e.is_not_found() {
1200 Pick::Unresolved(PackageStatus::NotFound {
1201 message: e.to_string(),
1202 })
1203 } else {
1204 Pick::Unresolved(PackageStatus::Failed {
1205 failure: e.fetch_failure(),
1206 message: e.to_string(),
1207 })
1208 }
1209 }
1210 Err(_) => {
1211 tracing::warn!(package = %name.for_tracing(), "fetch fallback timed out ({}s)", timeout.as_secs());
1212 Pick::Unresolved(PackageStatus::Failed {
1213 failure: FetchFailure::Transient,
1214 message: format!(
1215 "{}: registry request timed out after {}s",
1216 name.for_tracing(),
1217 timeout.as_secs()
1218 ),
1219 })
1220 }
1221 }
1222}
1223
1224#[derive(Debug, Clone)]
1230struct CooldownCandidate {
1231 version: ConcreteVersion,
1232 published_at: deps_core::freshness::PublishTime,
1233 removal_status: RemovalStatus,
1234 prerelease: bool,
1235}
1236
1237impl Version for CooldownCandidate {
1238 fn version_string(&self) -> &ConcreteVersion {
1239 &self.version
1240 }
1241 fn published_at(&self) -> Option<deps_core::freshness::PublishTime> {
1242 Some(self.published_at)
1243 }
1244 fn removal_status(&self) -> RemovalStatus {
1245 self.removal_status
1246 }
1247 fn is_prerelease(&self) -> bool {
1248 self.prerelease
1249 }
1250 fn as_any(&self) -> &dyn std::any::Any {
1251 self
1252 }
1253}
1254
1255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1262enum InUseFloor {
1263 Absent,
1265 Located(usize),
1268 Unlocatable {
1272 newest_located: Option<usize>,
1274 },
1275}
1276
1277fn in_use_floor(in_use_versions: &[ConcreteVersion], versions: &[Box<dyn Version>]) -> InUseFloor {
1284 if in_use_versions.is_empty() {
1285 return InUseFloor::Absent;
1286 }
1287
1288 let mut newest_located: Option<usize> = None;
1289 let mut all_located = true;
1290 for iv in in_use_versions {
1291 match versions.iter().position(|v| v.version_string() == iv) {
1292 Some(idx) => newest_located = Some(newest_located.map_or(idx, |cur| cur.min(idx))),
1293 None => all_located = false,
1294 }
1295 }
1296
1297 match (all_located, newest_located) {
1298 (true, Some(idx)) => InUseFloor::Located(idx),
1302 (true, None) | (false, _) => InUseFloor::Unlocatable { newest_located },
1303 }
1304}
1305
1306#[allow(
1345 clippy::too_many_arguments,
1346 reason = "mirrors fetch_and_classify_package's own identical rationale — every parameter \
1347 is either registry/selection-rule plumbing already threaded through that \
1348 function or a planning knob this helper alone needs"
1349)]
1350fn compute_cooldown_fallback(
1351 registry: &dyn Registry,
1352 versions: &[Box<dyn Version>],
1353 name: &PackageName,
1354 in_use_versions: &[ConcreteVersion],
1355 wildcard_req: &VersionReq,
1356 selection_context: deps_core::SelectionContext,
1357 freshness: deps_core::freshness::FreshnessSettings,
1358 gossip: Option<&HashMap<PackageName, deps_core::GossipFindings>>,
1359 now: deps_core::freshness::PublishTime,
1360 unfiltered_pick_idx: Option<usize>,
1361) -> Option<deps_core::lsp_helpers::CooldownFallback> {
1362 let deps_core::freshness::FreshnessSettings::Enabled { cooldown } = freshness else {
1363 return None;
1364 };
1365
1366 let unfiltered_pick = unfiltered_pick_idx.and_then(|idx| versions.get(idx));
1367 let latest_is_prerelease = unfiltered_pick.is_some_and(|v| v.is_prerelease());
1368 if let Some(pick) = unfiltered_pick {
1372 let cleared = matches!(
1373 deps_core::lsp_helpers::cooldown_precedence(
1374 gossip,
1375 name,
1376 pick.version_string().as_str(),
1377 pick.published_at(),
1378 cooldown,
1379 now,
1380 ),
1381 deps_core::lsp_helpers::CooldownPrecedence::Cleared
1382 );
1383 if cleared {
1384 return None;
1385 }
1386 }
1387
1388 let floor: Option<usize> = match in_use_floor(in_use_versions, versions) {
1394 InUseFloor::Located(idx) => Some(idx),
1395 InUseFloor::Absent => None,
1396 InUseFloor::Unlocatable { .. } => return None,
1397 };
1398
1399 let (cleared_indices, cleared_versions): (Vec<usize>, Vec<Box<dyn Version>>) = versions
1403 .iter()
1404 .enumerate()
1405 .filter_map(|(idx, v)| {
1406 let published_at = v.published_at()?;
1407 let cleared = matches!(
1408 deps_core::lsp_helpers::cooldown_precedence(
1409 gossip,
1410 name,
1411 v.version_string().as_str(),
1412 Some(published_at),
1413 cooldown,
1414 now,
1415 ),
1416 deps_core::lsp_helpers::CooldownPrecedence::Cleared
1417 );
1418 cleared.then(|| {
1419 let candidate: Box<dyn Version> = Box::new(CooldownCandidate {
1420 version: v.version_string().clone(),
1421 published_at,
1422 removal_status: v.removal_status(),
1423 prerelease: v.is_prerelease(),
1424 });
1425 (idx, candidate)
1426 })
1427 })
1428 .unzip();
1429
1430 let pick =
1433 registry.select_latest_matching(&cleared_versions, wildcard_req, &selection_context)?;
1434 let idx = *cleared_indices.get(pick)?;
1435 let candidate = versions.get(idx)?;
1436 let published_at = candidate.published_at()?;
1437 let ecosystem_safe = !candidate.removal_status().blocks_resolution()
1438 && (!candidate.is_prerelease() || latest_is_prerelease);
1439 let above_floor = floor.is_none_or(|floor| idx < floor);
1440
1441 (ecosystem_safe && above_floor).then(|| {
1442 deps_core::lsp_helpers::CooldownFallback::new(
1443 candidate.version_string().clone(),
1444 published_at,
1445 )
1446 })
1447}
1448
1449pub fn apply_fetch_outcomes(
1507 outcomes: &mut deps_core::lsp_helpers::DependencyOutcomes,
1508 yanked_versions: HashMap<PackageName, (ConcreteVersion, RemovalStatus)>,
1509 fetch_failed: HashMap<PackageName, FetchFailure>,
1510 collided_names: HashSet<PackageName>,
1511 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
1512) {
1513 for (name, version) in yanked_versions {
1514 outcomes.set_yanked(formatter.normalize_package_name(&name), version);
1515 }
1516 for (name, failure) in fetch_failed {
1517 outcomes.set_fetch_failure(formatter.normalize_package_name(&name), failure);
1518 }
1519 for name in collided_names {
1520 outcomes.set_fetch_failure_if_absent(
1521 formatter.normalize_package_name(&name),
1522 FetchFailure::NotAttempted,
1523 );
1524 }
1525}
1526
1527#[cfg(test)]
1528mod tests {
1529 use super::*;
1530 use deps_core::SelectionContext;
1531 use deps_core::parser::DependencySource;
1532
1533 fn with_registry_source(names: Vec<PackageName>) -> Vec<(PackageName, DependencySource)> {
1538 names
1539 .into_iter()
1540 .map(|name| (name, DependencySource::Registry))
1541 .collect()
1542 }
1543
1544 #[cfg(feature = "cargo")]
1545 mod apply_fetch_outcomes_tests {
1546 use super::*;
1547 use crate::setup::CargoFormatter;
1548 use deps_core::lsp_helpers::DependencyOutcomes;
1549
1550 #[test]
1551 fn apply_fetch_outcomes_sets_yanked_re_keyed_to_normalized_name() {
1552 let mut outcomes = DependencyOutcomes::new();
1553 let mut yanked_versions = HashMap::new();
1554 yanked_versions.insert(
1555 PackageName::new("time"),
1556 ("0.1.43".into(), RemovalStatus::Yanked),
1557 );
1558
1559 apply_fetch_outcomes(
1560 &mut outcomes,
1561 yanked_versions,
1562 HashMap::new(),
1563 HashSet::new(),
1564 &CargoFormatter,
1565 );
1566
1567 assert_eq!(
1568 outcomes.yanked("time"),
1569 Some(&("0.1.43".into(), RemovalStatus::Yanked))
1570 );
1571 }
1572
1573 #[test]
1577 fn apply_fetch_outcomes_collided_name_does_not_clobber_existing_fetch_failure() {
1578 let mut outcomes = DependencyOutcomes::new();
1579 let mut fetch_failed = HashMap::new();
1580 fetch_failed.insert(PackageName::new("serde"), FetchFailure::Transient);
1581 let mut collided_names = HashSet::new();
1582 collided_names.insert(PackageName::new("serde"));
1583
1584 apply_fetch_outcomes(
1585 &mut outcomes,
1586 HashMap::new(),
1587 fetch_failed,
1588 collided_names,
1589 &CargoFormatter,
1590 );
1591
1592 assert_eq!(
1593 outcomes.fetch_failure("serde"),
1594 Some(&FetchFailure::Transient),
1595 "a genuine fetch failure must survive a collided name normalizing to the same key"
1596 );
1597 }
1598
1599 #[test]
1602 fn apply_fetch_outcomes_collided_name_alone_is_recorded_as_not_attempted() {
1603 let mut outcomes = DependencyOutcomes::new();
1604 let mut collided_names = HashSet::new();
1605 collided_names.insert(PackageName::new("serde"));
1606
1607 apply_fetch_outcomes(
1608 &mut outcomes,
1609 HashMap::new(),
1610 HashMap::new(),
1611 collided_names,
1612 &CargoFormatter,
1613 );
1614
1615 assert_eq!(
1616 outcomes.fetch_failure("serde"),
1617 Some(&FetchFailure::NotAttempted)
1618 );
1619 }
1620 }
1621
1622 mod dedup_by_source_collision_tests {
1623 use super::*;
1624 use deps_core::Dependency;
1625 use deps_core::position::{Position, Range};
1626 use deps_core::test_util::StubFormatter;
1627 use std::any::Any;
1628
1629 const ALTERNATE_AWARE_FORMATTER: StubFormatter =
1634 StubFormatter::new().with_alternate_registry_resolution();
1635
1636 struct MockDep {
1637 name: PackageName,
1638 source: DependencySource,
1639 addr_tag: u32,
1640 }
1641
1642 impl Dependency for MockDep {
1643 fn name(&self) -> &PackageName {
1644 &self.name
1645 }
1646 fn name_range(&self) -> Range {
1647 Range::new(
1648 Position::new(0, self.addr_tag),
1649 Position::new(0, self.addr_tag + 1),
1650 )
1651 }
1652 fn version_requirement(&self) -> Option<&VersionReq> {
1653 None
1654 }
1655 fn version_range(&self) -> Option<Range> {
1656 None
1657 }
1658 fn source(&self) -> DependencySource {
1659 self.source.clone()
1660 }
1661 fn as_any(&self) -> &dyn Any {
1662 self
1663 }
1664 }
1665
1666 struct MockParseResult {
1667 deps: Vec<MockDep>,
1668 }
1669
1670 impl deps_core::ParseResult for MockParseResult {
1671 fn dependencies(&self) -> Vec<&dyn Dependency> {
1672 self.deps.iter().map(|d| d as &dyn Dependency).collect()
1673 }
1674 fn workspace_root(&self) -> Option<&std::path::Path> {
1675 None
1676 }
1677 fn uri(&self) -> &url::Url {
1678 static URI: std::sync::OnceLock<url::Url> = std::sync::OnceLock::new();
1679 URI.get_or_init(|| deps_core::test_util::test_uri("/test/Cargo.toml"))
1680 }
1681 fn as_any(&self) -> &dyn Any {
1682 self
1683 }
1684 }
1685
1686 #[test]
1687 fn test_two_different_resolvable_sources_collide_and_are_dropped() {
1688 let parse_result = MockParseResult {
1689 deps: vec![
1690 MockDep {
1691 name: PackageName::new("shared-name"),
1692 source: DependencySource::Registry,
1693 addr_tag: 0,
1694 },
1695 MockDep {
1696 name: PackageName::new("shared-name"),
1697 source: DependencySource::AlternateRegistry {
1698 index: "https://index.mycorp.dev".into(),
1699 mirrors_crates_io: false,
1700 },
1701 addr_tag: 1,
1702 },
1703 ],
1704 };
1705
1706 let (sources, collided) =
1707 dedup_dependencies_by_source(&parse_result, &ALTERNATE_AWARE_FORMATTER);
1708
1709 assert!(
1710 !sources.contains_key(&PackageName::new("shared-name")),
1711 "a colliding name must not be fetched under either source"
1712 );
1713 assert!(
1714 collided.contains(&PackageName::new("shared-name")),
1715 "the collision must be recorded so the caller can mark it fetch_failed"
1716 );
1717 }
1718
1719 #[test]
1720 fn test_identical_sources_do_not_collide() {
1721 let parse_result = MockParseResult {
1722 deps: vec![
1723 MockDep {
1724 name: PackageName::new("shared-name"),
1725 source: DependencySource::Registry,
1726 addr_tag: 0,
1727 },
1728 MockDep {
1729 name: PackageName::new("shared-name"),
1730 source: DependencySource::Registry,
1731 addr_tag: 1,
1732 },
1733 ],
1734 };
1735
1736 let (sources, collided) =
1737 dedup_dependencies_by_source(&parse_result, &ALTERNATE_AWARE_FORMATTER);
1738
1739 assert!(collided.is_empty());
1740 assert_eq!(
1741 sources.get(&PackageName::new("shared-name")),
1742 Some(&DependencySource::Registry)
1743 );
1744 }
1745
1746 #[test]
1751 fn test_non_resolvable_source_is_dropped_not_fetched() {
1752 let parse_result = MockParseResult {
1753 deps: vec![MockDep {
1754 name: PackageName::new("local-fork"),
1755 source: DependencySource::Path {
1756 path: "../local-fork".into(),
1757 },
1758 addr_tag: 0,
1759 }],
1760 };
1761
1762 let (sources, collided) =
1763 dedup_dependencies_by_source(&parse_result, &ALTERNATE_AWARE_FORMATTER);
1764
1765 assert!(sources.is_empty());
1766 assert!(collided.is_empty());
1767 }
1768
1769 #[test]
1777 fn test_collision_warning_redacts_credentials_in_alternate_registry_debug_output() {
1778 let parse_result = MockParseResult {
1779 deps: vec![
1780 MockDep {
1781 name: PackageName::new("shared-name"),
1782 source: DependencySource::AlternateRegistry {
1783 index: "https://index-a.mycorp.dev/api?api_key=SECRET_A".into(),
1784 mirrors_crates_io: false,
1785 },
1786 addr_tag: 0,
1787 },
1788 MockDep {
1789 name: PackageName::new("shared-name"),
1790 source: DependencySource::AlternateRegistry {
1791 index: "https://index-b.mycorp.dev/api?api_key=SECRET_B".into(),
1792 mirrors_crates_io: false,
1793 },
1794 addr_tag: 1,
1795 },
1796 ],
1797 };
1798
1799 let log = deps_core::test_util::capture_tracing_output(|| {
1800 let (sources, collided) =
1801 dedup_dependencies_by_source(&parse_result, &ALTERNATE_AWARE_FORMATTER);
1802 assert!(!sources.contains_key(&PackageName::new("shared-name")));
1803 assert!(collided.contains(&PackageName::new("shared-name")));
1804 });
1805
1806 assert!(
1807 log.contains("two different resolved registries"),
1808 "expected the collision WARN to fire: {log:?}"
1809 );
1810 assert!(
1811 !log.contains("SECRET_A") && !log.contains("SECRET_B"),
1812 "tracing output leaked a query-string credential: {log:?}"
1813 );
1814 assert!(
1815 log.contains("index-a.mycorp.dev") && log.contains("index-b.mycorp.dev"),
1816 "host should survive redaction: {log:?}"
1817 );
1818 }
1819 }
1820
1821 #[tokio::test]
1822 async fn test_fetch_latest_versions_parallel_with_timeout() {
1823 use deps_core::{Metadata, Registry, Version};
1824 use std::any::Any;
1825 use std::time::Duration;
1826
1827 struct TimeoutRegistry;
1828
1829 impl Registry for TimeoutRegistry {
1830 fn get_versions<'a>(
1831 &'a self,
1832 _name: &'a deps_core::PackageName,
1833 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1834 {
1835 Box::pin(async move {
1836 tokio::time::sleep(Duration::from_secs(10)).await;
1837 Ok(vec![])
1838 })
1839 }
1840
1841 fn get_latest_matching<'a>(
1842 &'a self,
1843 _name: &'a deps_core::PackageName,
1844 _req: &'a deps_core::VersionReq,
1845 _selection_context: &'a deps_core::SelectionContext,
1846 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1847 {
1848 Box::pin(async move {
1849 tokio::time::sleep(Duration::from_secs(10)).await;
1850 Ok(None)
1851 })
1852 }
1853
1854 fn search_raw<'a>(
1855 &'a self,
1856 _query: &'a str,
1857 _limit: usize,
1858 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1859 {
1860 Box::pin(async move { Ok(vec![]) })
1861 }
1862
1863 fn as_any(&self) -> &dyn Any {
1864 self
1865 }
1866 }
1867
1868 let registry: Arc<dyn Registry> = Arc::new(TimeoutRegistry);
1869 let packages = vec![PackageName::new("slow-package")];
1870
1871 let result = fetch_latest_versions_parallel(
1872 registry,
1873 with_registry_source(packages),
1874 &HashMap::new(),
1875 None,
1876 deps_core::freshness::FreshnessSettings::default(),
1877 1,
1878 10,
1879 &SelectionContext::none(),
1880 None,
1881 )
1882 .await;
1883
1884 assert!(result.versions.is_empty(), "Slow package should timeout");
1885 assert_eq!(result.failed_count(), 1, "Should track 1 failed package");
1886 assert_eq!(
1889 result.fetch_failed,
1890 HashMap::from([(PackageName::new("slow-package"), FetchFailure::Transient)]),
1891 "timed-out package must be recorded in fetch_failed"
1892 );
1893 }
1894
1895 #[tokio::test]
1896 async fn test_fetch_latest_versions_parallel_fast_packages_not_blocked() {
1897 use deps_core::{Metadata, Registry, Version};
1898 use std::any::Any;
1899 use std::time::Duration;
1900
1901 struct MixedRegistry;
1902
1903 impl Registry for MixedRegistry {
1904 fn get_versions<'a>(
1905 &'a self,
1906 name: &'a deps_core::PackageName,
1907 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1908 {
1909 Box::pin(async move {
1910 if name == "slow-package" {
1911 tokio::time::sleep(Duration::from_secs(10)).await;
1912 }
1913 Ok(vec![])
1914 })
1915 }
1916
1917 fn get_latest_matching<'a>(
1918 &'a self,
1919 name: &'a deps_core::PackageName,
1920 _req: &'a deps_core::VersionReq,
1921 _selection_context: &'a deps_core::SelectionContext,
1922 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1923 {
1924 Box::pin(async move {
1925 if name == "slow-package" {
1926 tokio::time::sleep(Duration::from_secs(10)).await;
1927 }
1928 Ok(None)
1929 })
1930 }
1931
1932 fn search_raw<'a>(
1933 &'a self,
1934 _query: &'a str,
1935 _limit: usize,
1936 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1937 {
1938 Box::pin(async move { Ok(vec![]) })
1939 }
1940
1941 fn as_any(&self) -> &dyn Any {
1942 self
1943 }
1944 }
1945
1946 let registry: Arc<dyn Registry> = Arc::new(MixedRegistry);
1947 let packages = vec![
1948 PackageName::new("slow-package"),
1949 PackageName::new("fast-package"),
1950 ];
1951
1952 let start = std::time::Instant::now();
1953 let result = fetch_latest_versions_parallel(
1954 registry,
1955 with_registry_source(packages),
1956 &HashMap::new(),
1957 None,
1958 deps_core::freshness::FreshnessSettings::default(),
1959 1,
1960 10,
1961 &SelectionContext::none(),
1962 None,
1963 )
1964 .await;
1965 let elapsed = start.elapsed();
1966
1967 assert!(
1968 elapsed < Duration::from_secs(3),
1969 "Should not wait for slow package: {:?}",
1970 elapsed
1971 );
1972
1973 assert!(
1974 result.versions.is_empty(),
1975 "No versions returned (test registry returns empty)"
1976 );
1977 assert_eq!(
1978 result.failed_count(),
1979 1,
1980 "Slow package should be marked as failed"
1981 );
1982 }
1983
1984 #[tokio::test]
1985 async fn test_fetch_latest_versions_parallel_concurrency_limit() {
1986 use deps_core::{Metadata, Registry, Version};
1987 use std::any::Any;
1988 use std::sync::atomic::{AtomicUsize, Ordering};
1989 use std::time::Duration;
1990
1991 struct ConcurrencyTrackingRegistry {
1992 current: Arc<AtomicUsize>,
1993 max_seen: Arc<AtomicUsize>,
1994 }
1995
1996 impl Registry for ConcurrencyTrackingRegistry {
1997 fn get_versions<'a>(
1998 &'a self,
1999 _name: &'a deps_core::PackageName,
2000 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2001 {
2002 Box::pin(async move {
2003 let current = self.current.fetch_add(1, Ordering::SeqCst) + 1;
2004 self.max_seen.fetch_max(current, Ordering::SeqCst);
2005 tokio::time::sleep(Duration::from_millis(50)).await;
2006 self.current.fetch_sub(1, Ordering::SeqCst);
2007
2008 Ok(vec![])
2009 })
2010 }
2011
2012 fn get_latest_matching<'a>(
2013 &'a self,
2014 _name: &'a deps_core::PackageName,
2015 _req: &'a deps_core::VersionReq,
2016 _selection_context: &'a deps_core::SelectionContext,
2017 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2018 {
2019 Box::pin(async move {
2020 let current = self.current.fetch_add(1, Ordering::SeqCst) + 1;
2021 self.max_seen.fetch_max(current, Ordering::SeqCst);
2022 tokio::time::sleep(Duration::from_millis(50)).await;
2023 self.current.fetch_sub(1, Ordering::SeqCst);
2024
2025 Ok(None)
2026 })
2027 }
2028
2029 fn search_raw<'a>(
2030 &'a self,
2031 _query: &'a str,
2032 _limit: usize,
2033 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2034 {
2035 Box::pin(async move { Ok(vec![]) })
2036 }
2037
2038 fn as_any(&self) -> &dyn Any {
2039 self
2040 }
2041 }
2042
2043 let current = Arc::new(AtomicUsize::new(0));
2044 let max_seen = Arc::new(AtomicUsize::new(0));
2045
2046 let registry: Arc<dyn Registry> = Arc::new(ConcurrencyTrackingRegistry {
2047 current: Arc::clone(¤t),
2048 max_seen: Arc::clone(&max_seen),
2049 });
2050
2051 let packages: Vec<PackageName> = (0..50)
2052 .map(|i| PackageName::new(format!("package-{}", i)))
2053 .collect();
2054
2055 fetch_latest_versions_parallel(
2056 registry,
2057 with_registry_source(packages),
2058 &HashMap::new(),
2059 None,
2060 deps_core::freshness::FreshnessSettings::default(),
2061 5,
2062 20,
2063 &SelectionContext::none(),
2064 None,
2065 )
2066 .await;
2067
2068 let max = max_seen.load(Ordering::SeqCst);
2070 assert!(
2071 max <= 22,
2072 "Concurrency limit violated: {} concurrent requests (limit: 20)",
2073 max
2074 );
2075 }
2076
2077 #[tokio::test]
2083 async fn test_fetch_latest_versions_parallel_zero_max_concurrent_still_completes() {
2084 use deps_core::Registry;
2085 let registry: Arc<dyn Registry> = Arc::new(deps_core::test_util::MockRegistry::new());
2086 let packages = vec![PackageName::new("some-package")];
2087
2088 let result = tokio::time::timeout(
2089 std::time::Duration::from_secs(5),
2090 fetch_latest_versions_parallel(
2091 registry,
2092 with_registry_source(packages),
2093 &HashMap::new(),
2094 None,
2095 deps_core::freshness::FreshnessSettings::default(),
2096 5,
2097 0,
2098 &SelectionContext::none(),
2099 None,
2100 ),
2101 )
2102 .await
2103 .expect("fetch with max_concurrent=0 must not hang forever");
2104
2105 assert!(
2106 result
2107 .no_comparable_versions
2108 .contains(&PackageName::new("some-package")),
2109 "fetch must still run to completion when max_concurrent is 0"
2110 );
2111 }
2112
2113 #[tokio::test]
2114 async fn test_fetch_partial_success_with_mixed_outcomes() {
2115 use deps_core::test_util::MockVersion;
2116 use deps_core::{Metadata, Registry, Version};
2117 use std::any::Any;
2118 use std::time::Duration;
2119
2120 struct MixedOutcomeRegistry;
2121
2122 impl Registry for MixedOutcomeRegistry {
2123 fn get_versions<'a>(
2124 &'a self,
2125 name: &'a deps_core::PackageName,
2126 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2127 {
2128 Box::pin(async move {
2129 match name.as_str() {
2130 "package-fast" => {
2131 Ok(vec![
2132 Box::new(MockVersion::new("1.0.0").with_prerelease(false))
2133 as Box<dyn Version>,
2134 ])
2135 }
2136 "package-slow" => {
2137 tokio::time::sleep(Duration::from_secs(10)).await;
2138 Ok(vec![])
2139 }
2140 "package-error" => Err(deps_core::error::DepsError::CacheError(
2141 "Mock registry error".to_string(),
2142 )),
2143 _ => Ok(vec![]),
2144 }
2145 })
2146 }
2147
2148 fn get_latest_matching<'a>(
2149 &'a self,
2150 name: &'a deps_core::PackageName,
2151 _req: &'a deps_core::VersionReq,
2152 _selection_context: &'a deps_core::SelectionContext,
2153 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2154 {
2155 Box::pin(async move {
2156 match name.as_str() {
2157 "package-fast" => Ok(Some(Box::new(
2158 MockVersion::new("1.0.0").with_prerelease(false),
2159 ) as Box<dyn Version>)),
2160 "package-slow" => {
2161 tokio::time::sleep(Duration::from_secs(10)).await;
2162 Ok(None)
2163 }
2164 "package-error" => Err(deps_core::error::DepsError::CacheError(
2165 "Mock registry error".to_string(),
2166 )),
2167 _ => Ok(None),
2168 }
2169 })
2170 }
2171
2172 fn search_raw<'a>(
2173 &'a self,
2174 _query: &'a str,
2175 _limit: usize,
2176 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2177 {
2178 Box::pin(async move { Ok(vec![]) })
2179 }
2180
2181 fn select_latest_matching(
2182 &self,
2183 versions: &[Box<dyn Version>],
2184 _req: &deps_core::VersionReq,
2185 _selection_context: &deps_core::SelectionContext,
2186 ) -> Option<usize> {
2187 if versions.is_empty() { None } else { Some(0) }
2191 }
2192
2193 fn as_any(&self) -> &dyn Any {
2194 self
2195 }
2196 }
2197
2198 let registry: Arc<dyn Registry> = Arc::new(MixedOutcomeRegistry);
2199 let packages = vec![
2200 PackageName::new("package-fast"),
2201 PackageName::new("package-slow"),
2202 PackageName::new("package-error"),
2203 ];
2204
2205 let result = fetch_latest_versions_parallel(
2206 registry,
2207 with_registry_source(packages),
2208 &HashMap::new(),
2209 None,
2210 deps_core::freshness::FreshnessSettings::default(),
2211 1,
2212 10,
2213 &SelectionContext::none(),
2214 None,
2215 )
2216 .await;
2217
2218 assert_eq!(
2219 result.versions.len(),
2220 1,
2221 "Should have exactly 1 successful package"
2222 );
2223 assert_eq!(
2224 result
2225 .versions
2226 .get("package-fast")
2227 .map(|v| v.latest.as_str()),
2228 Some("1.0.0"),
2229 "Fast package should have correct version"
2230 );
2231 assert!(
2232 !result.versions.contains_key("package-slow"),
2233 "Slow package should not be in results (timeout)"
2234 );
2235 assert!(
2236 !result.versions.contains_key("package-error"),
2237 "Error package should not be in results"
2238 );
2239 }
2240
2241 #[tokio::test]
2246 async fn test_fetch_latest_versions_parallel_carries_yanked_flag_into_cache() {
2247 use deps_core::Registry;
2248 use deps_core::test_util::MockVersion;
2249
2250 let registry: Arc<dyn Registry> = Arc::new(
2251 deps_core::test_util::MockRegistry::new().with_versions(vec![
2252 MockVersion::new("1.0.214"),
2253 MockVersion::new("1.0.213").yanked(true),
2254 ]),
2255 );
2256 let packages = vec![PackageName::new("serde")];
2257
2258 let result = fetch_latest_versions_parallel(
2259 registry,
2260 with_registry_source(packages),
2261 &HashMap::new(),
2262 None,
2263 deps_core::freshness::FreshnessSettings::default(),
2264 10,
2265 10,
2266 &SelectionContext::none(),
2267 None,
2268 )
2269 .await;
2270
2271 let serde = result
2272 .versions
2273 .get("serde")
2274 .expect("serde should be fetched");
2275 assert_eq!(serde.latest, "1.0.214", "latest must skip the yanked entry");
2276 assert_eq!(
2277 &*serde.available,
2278 &[
2279 ConcreteVersion::new("1.0.214"),
2280 ConcreteVersion::new("1.0.213")
2281 ],
2282 "available must remain unfiltered"
2283 );
2284 assert_eq!(
2285 &*serde.yanked,
2286 &[(
2287 ConcreteVersion::new("1.0.213"),
2288 deps_core::RemovalStatus::Yanked
2289 )],
2290 "yanked must carry only the entries reported as yanked, paired with their status"
2291 );
2292 }
2293
2294 #[tokio::test]
2301 async fn test_fetch_latest_versions_parallel_carries_published_at_for_latest_only() {
2302 use deps_core::Registry;
2303 use deps_core::freshness::PublishTime;
2304 use deps_core::test_util::MockVersion;
2305
2306 let registry: Arc<dyn Registry> = Arc::new(
2307 deps_core::test_util::MockRegistry::new().with_versions(vec![
2308 MockVersion::new("1.0.214").with_published_at(PublishTime::from_unix_secs(2_000)),
2309 MockVersion::new("1.0.213")
2312 .yanked(true)
2313 .with_published_at(PublishTime::from_unix_secs(1_000)),
2314 ]),
2315 );
2316 let packages = vec![PackageName::new("serde")];
2317
2318 let result = fetch_latest_versions_parallel(
2319 registry,
2320 with_registry_source(packages),
2321 &HashMap::new(),
2322 None,
2323 deps_core::freshness::FreshnessSettings::default(),
2324 10,
2325 10,
2326 &SelectionContext::none(),
2327 None,
2328 )
2329 .await;
2330
2331 let serde = result
2332 .versions
2333 .get("serde")
2334 .expect("serde should be fetched");
2335 assert_eq!(serde.latest, "1.0.214");
2336 assert_eq!(
2337 serde.published_at,
2338 Some(PublishTime::from_unix_secs(2_000)),
2339 "published_at must be 1.0.214's own timestamp, not the yanked 1.0.213 entry's"
2340 );
2341 }
2342
2343 #[tokio::test]
2353 async fn test_fetch_latest_versions_parallel_carries_license_into_fetch_result() {
2354 use deps_core::test_util::MockVersion;
2355 use deps_core::{Metadata, Registry, Version};
2356 use std::any::Any;
2357
2358 struct LicensedRegistry;
2359
2360 impl Registry for LicensedRegistry {
2361 fn get_versions<'a>(
2362 &'a self,
2363 name: &'a PackageName,
2364 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2365 {
2366 let license = if name.as_str() == "licensed-pkg" {
2367 vec!["MIT".to_string()]
2368 } else {
2369 vec![]
2370 };
2371 Box::pin(async move {
2372 Ok(vec![
2373 Box::new(MockVersion::new("1.0.0").with_license(license))
2374 as Box<dyn Version>,
2375 ])
2376 })
2377 }
2378
2379 fn get_latest_matching<'a>(
2380 &'a self,
2381 _name: &'a PackageName,
2382 _req: &'a deps_core::VersionReq,
2383 _selection_context: &'a deps_core::SelectionContext,
2384 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2385 {
2386 Box::pin(async move { Ok(None) })
2387 }
2388
2389 fn search_raw<'a>(
2390 &'a self,
2391 _query: &'a str,
2392 _limit: usize,
2393 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2394 {
2395 Box::pin(async move { Ok(vec![]) })
2396 }
2397
2398 fn select_latest_matching(
2399 &self,
2400 versions: &[Box<dyn Version>],
2401 _req: &deps_core::VersionReq,
2402 _selection_context: &deps_core::SelectionContext,
2403 ) -> Option<usize> {
2404 (!versions.is_empty()).then_some(0)
2405 }
2406
2407 fn as_any(&self) -> &dyn Any {
2408 self
2409 }
2410 }
2411
2412 let registry: Arc<dyn Registry> = Arc::new(LicensedRegistry);
2413 let packages = vec![
2414 PackageName::new("licensed-pkg"),
2415 PackageName::new("unlicensed-pkg"),
2416 ];
2417
2418 let result = fetch_latest_versions_parallel(
2419 registry,
2420 with_registry_source(packages),
2421 &HashMap::new(),
2422 None,
2423 deps_core::freshness::FreshnessSettings::default(),
2424 10,
2425 10,
2426 &SelectionContext::none(),
2427 None,
2428 )
2429 .await;
2430
2431 assert_eq!(
2432 result.licenses.get(&PackageName::new("licensed-pkg")),
2433 Some(&vec!["MIT".to_string()])
2434 );
2435 assert!(
2436 !result
2437 .licenses
2438 .contains_key(&PackageName::new("unlicensed-pkg")),
2439 "an empty Version::license() must produce no entry, not an empty-vec one"
2440 );
2441 }
2442
2443 #[tokio::test]
2449 async fn test_fetch_latest_versions_parallel_uses_get_versions_with_for_freshness() {
2450 use deps_core::freshness::{FreshnessSettings, PublishTime};
2451 use deps_core::test_util::MockVersion;
2452 use deps_core::{Metadata, Registry, Version};
2453 use std::any::Any;
2454
2455 struct FreshnessAwareRegistry;
2456
2457 impl Registry for FreshnessAwareRegistry {
2458 fn get_versions<'a>(
2459 &'a self,
2460 _name: &'a deps_core::PackageName,
2461 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2462 {
2463 Box::pin(async move {
2466 Ok(vec![Box::new(MockVersion::new("1.0.0")) as Box<dyn Version>])
2467 })
2468 }
2469
2470 fn get_versions_with<'a>(
2471 &'a self,
2472 _name: &'a deps_core::PackageName,
2473 freshness: FreshnessSettings,
2474 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2475 {
2476 let version = MockVersion::new("1.0.0");
2477 let version = match freshness
2478 .is_enabled()
2479 .then(|| PublishTime::from_unix_secs(5_000))
2480 {
2481 Some(published_at) => version.with_published_at(published_at),
2482 None => version,
2483 };
2484 Box::pin(async move { Ok(vec![Box::new(version) as Box<dyn Version>]) })
2485 }
2486
2487 fn get_latest_matching<'a>(
2488 &'a self,
2489 _name: &'a deps_core::PackageName,
2490 _req: &'a deps_core::VersionReq,
2491 _selection_context: &'a deps_core::SelectionContext,
2492 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2493 {
2494 Box::pin(async move { Ok(None) })
2495 }
2496
2497 fn search_raw<'a>(
2498 &'a self,
2499 _query: &'a str,
2500 _limit: usize,
2501 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2502 {
2503 Box::pin(async move { Ok(vec![]) })
2504 }
2505
2506 fn select_latest_matching(
2507 &self,
2508 versions: &[Box<dyn Version>],
2509 _req: &deps_core::VersionReq,
2510 _selection_context: &deps_core::SelectionContext,
2511 ) -> Option<usize> {
2512 if versions.is_empty() { None } else { Some(0) }
2513 }
2514
2515 fn as_any(&self) -> &dyn Any {
2516 self
2517 }
2518 }
2519
2520 let registry: Arc<dyn Registry> = Arc::new(FreshnessAwareRegistry);
2521 let packages = vec![PackageName::new("widget")];
2522
2523 let result = fetch_latest_versions_parallel(
2524 registry,
2525 with_registry_source(packages),
2526 &HashMap::new(),
2527 None,
2528 FreshnessSettings::default(),
2529 10,
2530 10,
2531 &SelectionContext::none(),
2532 None,
2533 )
2534 .await;
2535
2536 let widget = result
2537 .versions
2538 .get("widget")
2539 .expect("widget should be fetched");
2540 assert_eq!(
2541 widget.published_at,
2542 Some(PublishTime::from_unix_secs(5_000)),
2543 "published_at must come from get_versions_with, not the freshness-blind \
2544 get_versions (#339)"
2545 );
2546 }
2547
2548 #[tokio::test]
2554 async fn test_fetch_latest_versions_parallel_threads_minimum_stability_into_select_latest_matching()
2555 {
2556 use deps_core::test_util::MockVersion;
2557 use deps_core::{Metadata, Registry, StabilityFloor, Version};
2558 use std::any::Any;
2559 use std::sync::Mutex;
2560
2561 struct ContextAwareRegistry {
2562 seen_minimum_stability: Mutex<Vec<Option<StabilityFloor>>>,
2565 }
2566
2567 impl Registry for ContextAwareRegistry {
2568 fn get_versions<'a>(
2569 &'a self,
2570 _name: &'a deps_core::PackageName,
2571 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2572 {
2573 Box::pin(async move {
2574 Ok(vec![Box::new(MockVersion::new("1.0.0")) as Box<dyn Version>])
2575 })
2576 }
2577
2578 fn get_latest_matching<'a>(
2579 &'a self,
2580 _name: &'a deps_core::PackageName,
2581 _req: &'a deps_core::VersionReq,
2582 _selection_context: &'a deps_core::SelectionContext,
2583 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2584 {
2585 Box::pin(async move { Ok(None) })
2586 }
2587
2588 fn search_raw<'a>(
2589 &'a self,
2590 _query: &'a str,
2591 _limit: usize,
2592 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2593 {
2594 Box::pin(async move { Ok(vec![]) })
2595 }
2596
2597 fn select_latest_matching(
2598 &self,
2599 versions: &[Box<dyn Version>],
2600 _req: &deps_core::VersionReq,
2601 selection_context: &SelectionContext,
2602 ) -> Option<usize> {
2603 self.seen_minimum_stability
2604 .lock()
2605 .unwrap_or_else(|p| p.into_inner())
2606 .push(selection_context.minimum_stability());
2607 if versions.is_empty() { None } else { Some(0) }
2608 }
2609
2610 fn as_any(&self) -> &dyn Any {
2611 self
2612 }
2613 }
2614
2615 let registry = Arc::new(ContextAwareRegistry {
2616 seen_minimum_stability: Mutex::new(Vec::new()),
2617 });
2618 let packages = vec![PackageName::new("vendor/pkg")];
2619
2620 let result = fetch_latest_versions_parallel(
2621 Arc::clone(®istry) as Arc<dyn Registry>,
2622 with_registry_source(packages),
2623 &HashMap::new(),
2624 None,
2625 deps_core::freshness::FreshnessSettings::default(),
2626 10,
2627 10,
2628 &SelectionContext::with_minimum_stability(StabilityFloor::Beta),
2629 None,
2630 )
2631 .await;
2632
2633 assert_eq!(
2634 *registry
2635 .seen_minimum_stability
2636 .lock()
2637 .unwrap_or_else(|p| p.into_inner()),
2638 vec![Some(StabilityFloor::Beta)],
2639 "select_latest_matching must receive the caller's minimum_stability"
2640 );
2641 assert!(
2642 result.versions.contains_key("vendor/pkg"),
2643 "the pick must still succeed"
2644 );
2645 }
2646
2647 #[tokio::test]
2650 async fn test_fetch_latest_versions_parallel_threads_minimum_stability_into_get_latest_matching_fallback()
2651 {
2652 use deps_core::test_util::MockVersion;
2653 use deps_core::{Metadata, Registry, StabilityFloor, Version};
2654 use std::any::Any;
2655 use std::sync::Mutex;
2656
2657 struct FallbackContextAwareRegistry {
2658 seen_minimum_stability: Mutex<Vec<Option<StabilityFloor>>>,
2661 }
2662
2663 impl Registry for FallbackContextAwareRegistry {
2664 fn get_versions<'a>(
2665 &'a self,
2666 _name: &'a deps_core::PackageName,
2667 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2668 {
2669 Box::pin(async move { Ok(vec![]) })
2672 }
2673
2674 fn get_latest_matching<'a>(
2675 &'a self,
2676 _name: &'a deps_core::PackageName,
2677 _req: &'a deps_core::VersionReq,
2678 selection_context: &'a SelectionContext,
2679 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2680 {
2681 self.seen_minimum_stability
2682 .lock()
2683 .unwrap_or_else(|p| p.into_inner())
2684 .push(selection_context.minimum_stability());
2685 Box::pin(async move {
2686 Ok(Some(
2687 Box::new(MockVersion::new("2.0.0-beta1")) as Box<dyn Version>
2688 ))
2689 })
2690 }
2691
2692 fn search_raw<'a>(
2693 &'a self,
2694 _query: &'a str,
2695 _limit: usize,
2696 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2697 {
2698 Box::pin(async move { Ok(vec![]) })
2699 }
2700
2701 fn as_any(&self) -> &dyn Any {
2702 self
2703 }
2704 }
2705
2706 let registry = Arc::new(FallbackContextAwareRegistry {
2707 seen_minimum_stability: Mutex::new(Vec::new()),
2708 });
2709 let packages = vec![PackageName::new("vendor/pkg")];
2710
2711 let result = fetch_latest_versions_parallel(
2712 Arc::clone(®istry) as Arc<dyn Registry>,
2713 with_registry_source(packages),
2714 &HashMap::new(),
2715 None,
2716 deps_core::freshness::FreshnessSettings::default(),
2717 10,
2718 10,
2719 &SelectionContext::with_minimum_stability(StabilityFloor::Beta),
2720 None,
2721 )
2722 .await;
2723
2724 assert_eq!(
2725 *registry
2726 .seen_minimum_stability
2727 .lock()
2728 .unwrap_or_else(|p| p.into_inner()),
2729 vec![Some(StabilityFloor::Beta)],
2730 "get_latest_matching must receive the caller's minimum_stability"
2731 );
2732 let widget = result
2733 .versions
2734 .get("vendor/pkg")
2735 .expect("fallback pick should succeed");
2736 assert_eq!(widget.latest, "2.0.0-beta1");
2737 }
2738
2739 #[tokio::test]
2745 async fn test_fetch_falls_back_to_get_latest_matching_when_list_based_pick_finds_nothing() {
2746 use deps_core::test_util::MockVersion;
2747 use deps_core::{Metadata, Registry, Version};
2748 use std::any::Any;
2749
2750 struct UntaggedModuleRegistry;
2756
2757 impl Registry for UntaggedModuleRegistry {
2758 fn get_versions<'a>(
2759 &'a self,
2760 _name: &'a deps_core::PackageName,
2761 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2762 {
2763 Box::pin(async move { Ok(vec![]) })
2764 }
2765
2766 fn get_latest_matching<'a>(
2767 &'a self,
2768 _name: &'a deps_core::PackageName,
2769 _req: &'a deps_core::VersionReq,
2770 _selection_context: &'a deps_core::SelectionContext,
2771 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2772 {
2773 Box::pin(async move {
2774 Ok(Some(
2775 Box::new(MockVersion::new("v0.0.0-20191109021931-daa7c04131f5"))
2776 as Box<dyn Version>,
2777 ))
2778 })
2779 }
2780
2781 fn search_raw<'a>(
2782 &'a self,
2783 _query: &'a str,
2784 _limit: usize,
2785 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2786 {
2787 Box::pin(async move { Ok(vec![]) })
2788 }
2789
2790 fn as_any(&self) -> &dyn Any {
2791 self
2792 }
2793 }
2794
2795 let registry: Arc<dyn Registry> = Arc::new(UntaggedModuleRegistry);
2796 let packages = vec![PackageName::new("golang.org/x/exp")];
2797
2798 let result = fetch_latest_versions_parallel(
2799 registry,
2800 with_registry_source(packages),
2801 &HashMap::new(),
2802 None,
2803 deps_core::freshness::FreshnessSettings::default(),
2804 5,
2805 10,
2806 &SelectionContext::none(),
2807 None,
2808 )
2809 .await;
2810
2811 assert_eq!(
2812 result
2813 .versions
2814 .get("golang.org/x/exp")
2815 .map(|v| v.latest.as_str()),
2816 Some("v0.0.0-20191109021931-daa7c04131f5"),
2817 "must fall back to get_latest_matching instead of reporting no version found"
2818 );
2819 }
2820
2821 #[tokio::test]
2822 async fn test_fetch_registry_error_handled() {
2823 use deps_core::{Metadata, Registry, Version};
2824 use std::any::Any;
2825
2826 struct ErrorRegistry;
2827
2828 impl Registry for ErrorRegistry {
2829 fn get_versions<'a>(
2830 &'a self,
2831 name: &'a deps_core::PackageName,
2832 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2833 {
2834 Box::pin(async move {
2835 Err(deps_core::error::DepsError::CacheError(format!(
2836 "Failed to fetch package: {}",
2837 name.as_str()
2838 )))
2839 })
2840 }
2841
2842 fn get_latest_matching<'a>(
2843 &'a self,
2844 name: &'a deps_core::PackageName,
2845 _req: &'a deps_core::VersionReq,
2846 _selection_context: &'a deps_core::SelectionContext,
2847 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2848 {
2849 Box::pin(async move {
2850 Err(deps_core::error::DepsError::CacheError(format!(
2851 "Failed to fetch package: {}",
2852 name.as_str()
2853 )))
2854 })
2855 }
2856
2857 fn search_raw<'a>(
2858 &'a self,
2859 _query: &'a str,
2860 _limit: usize,
2861 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2862 {
2863 Box::pin(async move { Ok(vec![]) })
2864 }
2865
2866 fn as_any(&self) -> &dyn Any {
2867 self
2868 }
2869 }
2870
2871 let registry: Arc<dyn Registry> = Arc::new(ErrorRegistry);
2872 let packages = vec![
2873 PackageName::new("package-1"),
2874 PackageName::new("package-2"),
2875 PackageName::new("package-3"),
2876 ];
2877
2878 let result = fetch_latest_versions_parallel(
2879 registry,
2880 with_registry_source(packages),
2881 &HashMap::new(),
2882 None,
2883 deps_core::freshness::FreshnessSettings::default(),
2884 5,
2885 10,
2886 &SelectionContext::none(),
2887 None,
2888 )
2889 .await;
2890
2891 assert!(
2892 result.versions.is_empty(),
2893 "All packages with errors should be omitted from results"
2894 );
2895 assert_eq!(
2896 result.failed_count(),
2897 3,
2898 "All 3 packages should be marked as failed"
2899 );
2900 assert_eq!(
2904 result.fetch_failed,
2905 HashMap::from([
2906 (PackageName::new("package-1"), FetchFailure::Transient),
2907 (PackageName::new("package-2"), FetchFailure::Transient),
2908 (PackageName::new("package-3"), FetchFailure::Transient),
2909 ]),
2910 "every errored package must be recorded in fetch_failed"
2911 );
2912 }
2913
2914 #[tokio::test]
2923 async fn test_fetch_failed_log_redacts_credential_shaped_package_name() {
2924 use deps_core::{Metadata, Registry, Version};
2925 use std::any::Any;
2926
2927 #[tracing::instrument(skip_all, fields(package = %name.for_tracing()), level = "debug")]
2937 async fn inner_fetch(name: &PackageName) -> deps_core::Result<Vec<Box<dyn Version>>> {
2938 tracing::debug!("mock registry fetch invoked");
2943 Err(deps_core::error::DepsError::CacheError(
2944 "transient backend failure".to_string(),
2945 ))
2946 }
2947
2948 struct AlwaysFailsRegistry;
2949
2950 impl Registry for AlwaysFailsRegistry {
2951 fn get_versions<'a>(
2952 &'a self,
2953 name: &'a deps_core::PackageName,
2954 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2955 {
2956 Box::pin(inner_fetch(name))
2957 }
2958
2959 fn get_latest_matching<'a>(
2960 &'a self,
2961 _name: &'a deps_core::PackageName,
2962 _req: &'a deps_core::VersionReq,
2963 _selection_context: &'a deps_core::SelectionContext,
2964 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2965 {
2966 Box::pin(async move {
2967 Err(deps_core::error::DepsError::CacheError(
2968 "transient backend failure".to_string(),
2969 ))
2970 })
2971 }
2972
2973 fn search_raw<'a>(
2974 &'a self,
2975 _query: &'a str,
2976 _limit: usize,
2977 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2978 {
2979 Box::pin(async move { Ok(vec![]) })
2980 }
2981
2982 fn as_any(&self) -> &dyn Any {
2983 self
2984 }
2985 }
2986
2987 let sentinel_name =
2988 PackageName::new("com.example:deploy:AUDITSENTINEL0000@git.internal.corp");
2989 let registry: Arc<dyn Registry> = Arc::new(AlwaysFailsRegistry);
2990 let packages = vec![sentinel_name.clone()];
2991
2992 let log =
2993 deps_core::test_util::capture_tracing_output_async_at(tracing::Level::DEBUG, async {
2994 let result = fetch_latest_versions_parallel(
2995 registry,
2996 with_registry_source(packages),
2997 &HashMap::new(),
2998 None,
2999 deps_core::freshness::FreshnessSettings::default(),
3000 5,
3001 10,
3002 &SelectionContext::none(),
3003 None,
3004 )
3005 .await;
3006 assert_eq!(result.failed_count(), 1);
3007 })
3008 .await;
3009
3010 assert!(
3011 log.contains("fetch failed"),
3012 "expected the fetch-failed WARN to fire: {log:?}"
3013 );
3014 assert!(
3015 log.contains("mock registry fetch invoked"),
3016 "expected the in-span event to fire — without it the span's fields never render, \
3017 silently downgrading this test back to event-field-only coverage: {log:?}"
3018 );
3019 assert!(
3020 !log.contains("AUDITSENTINEL0000"),
3021 "tracing output leaked a credential-shaped package name: {log:?}"
3022 );
3023 assert!(
3024 log.contains("git.internal.corp"),
3025 "host should survive redaction: {log:?}"
3026 );
3027 }
3028
3029 #[tokio::test]
3030 async fn test_fetch_not_found_is_not_recorded_as_fetch_failed() {
3031 use deps_core::{Metadata, Registry, Version};
3039 use std::any::Any;
3040
3041 struct NotFoundRegistry;
3042
3043 impl Registry for NotFoundRegistry {
3044 fn get_versions<'a>(
3045 &'a self,
3046 name: &'a deps_core::PackageName,
3047 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3048 {
3049 Box::pin(async move {
3050 Err(deps_core::error::DepsError::PackageNotFound {
3051 package: name.as_str().into(),
3052 registry: "mock",
3053 })
3054 })
3055 }
3056
3057 fn get_latest_matching<'a>(
3058 &'a self,
3059 name: &'a deps_core::PackageName,
3060 _req: &'a deps_core::VersionReq,
3061 _selection_context: &'a deps_core::SelectionContext,
3062 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3063 {
3064 Box::pin(async move {
3065 Err(deps_core::error::DepsError::PackageNotFound {
3066 package: name.as_str().into(),
3067 registry: "mock",
3068 })
3069 })
3070 }
3071
3072 fn search_raw<'a>(
3073 &'a self,
3074 _query: &'a str,
3075 _limit: usize,
3076 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3077 {
3078 Box::pin(async move { Ok(vec![]) })
3079 }
3080
3081 fn as_any(&self) -> &dyn Any {
3082 self
3083 }
3084 }
3085
3086 let registry: Arc<dyn Registry> = Arc::new(NotFoundRegistry);
3087 let packages = vec![PackageName::new("typo-pkg")];
3088
3089 let result = fetch_latest_versions_parallel(
3090 registry,
3091 with_registry_source(packages),
3092 &HashMap::new(),
3093 None,
3094 deps_core::freshness::FreshnessSettings::default(),
3095 5,
3096 10,
3097 &SelectionContext::none(),
3098 None,
3099 )
3100 .await;
3101
3102 assert!(result.versions.is_empty());
3103 assert!(
3104 result.fetch_failed.is_empty(),
3105 "a genuine not-found must not be recorded in fetch_failed, or \
3106 generate_diagnostics_from_cache would report it as a registry \
3107 error instead of Unknown package"
3108 );
3109 }
3110
3111 #[tokio::test]
3119 async fn test_fetch_success_with_zero_versions_is_recorded_as_no_comparable_versions() {
3120 use deps_core::Registry;
3121
3122 let registry: Arc<dyn Registry> = Arc::new(deps_core::test_util::MockRegistry::new());
3123 let packages = vec![PackageName::new("dtolnay/rust-toolchain")];
3124
3125 let result = fetch_latest_versions_parallel(
3126 registry,
3127 with_registry_source(packages),
3128 &HashMap::new(),
3129 None,
3130 deps_core::freshness::FreshnessSettings::default(),
3131 5,
3132 10,
3133 &SelectionContext::none(),
3134 None,
3135 )
3136 .await;
3137
3138 assert!(result.versions.is_empty());
3139 assert!(
3140 result.fetch_failed.is_empty(),
3141 "a genuine empty-but-successful fetch must not be recorded as a fetch \
3142 failure, or generate_diagnostics_from_cache would report a registry \
3143 error instead of nothing"
3144 );
3145 assert!(
3146 result
3147 .no_comparable_versions
3148 .contains(&PackageName::new("dtolnay/rust-toolchain")),
3149 "a package whose fetch succeeded with zero comparable versions must be \
3150 recorded in no_comparable_versions, or R5 would misreport it as Unknown \
3151 package; got: {:?}",
3152 result.no_comparable_versions
3153 );
3154 }
3155
3156 #[tokio::test]
3157 async fn test_fetch_http_404_is_not_recorded_as_fetch_failed() {
3158 use deps_core::{Metadata, Registry, Version};
3163 use std::any::Any;
3164
3165 struct Http404Registry;
3166
3167 impl Registry for Http404Registry {
3168 fn get_versions<'a>(
3169 &'a self,
3170 name: &'a deps_core::PackageName,
3171 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3172 {
3173 Box::pin(async move {
3174 Err(deps_core::error::DepsError::HttpStatus {
3175 url: format!("https://example.com/{}", name.as_str()).into(),
3176 status: 404,
3177 })
3178 })
3179 }
3180
3181 fn get_latest_matching<'a>(
3182 &'a self,
3183 name: &'a deps_core::PackageName,
3184 _req: &'a deps_core::VersionReq,
3185 _selection_context: &'a deps_core::SelectionContext,
3186 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3187 {
3188 Box::pin(async move {
3189 Err(deps_core::error::DepsError::HttpStatus {
3190 url: format!("https://example.com/{}", name.as_str()).into(),
3191 status: 404,
3192 })
3193 })
3194 }
3195
3196 fn search_raw<'a>(
3197 &'a self,
3198 _query: &'a str,
3199 _limit: usize,
3200 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3201 {
3202 Box::pin(async move { Ok(vec![]) })
3203 }
3204
3205 fn as_any(&self) -> &dyn Any {
3206 self
3207 }
3208 }
3209
3210 let registry: Arc<dyn Registry> = Arc::new(Http404Registry);
3211 let packages = vec![PackageName::new("typo-pkg")];
3212
3213 let result = fetch_latest_versions_parallel(
3214 registry,
3215 with_registry_source(packages),
3216 &HashMap::new(),
3217 None,
3218 deps_core::freshness::FreshnessSettings::default(),
3219 5,
3220 10,
3221 &SelectionContext::none(),
3222 None,
3223 )
3224 .await;
3225
3226 assert!(result.versions.is_empty());
3227 assert!(
3228 result.fetch_failed.is_empty(),
3229 "a bare HTTP 404 must not be recorded in fetch_failed either"
3230 );
3231 }
3232
3233 #[tokio::test]
3234 async fn test_fetch_fallback_error_recorded_as_fetch_failed_unless_not_found() {
3235 use deps_core::{Metadata, Registry, Version};
3242 use std::any::Any;
3243
3244 struct FallbackErrorRegistry;
3245
3246 impl Registry for FallbackErrorRegistry {
3247 fn get_versions<'a>(
3248 &'a self,
3249 _name: &'a deps_core::PackageName,
3250 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3251 {
3252 Box::pin(async move { Ok(vec![]) })
3253 }
3254
3255 fn get_latest_matching<'a>(
3256 &'a self,
3257 name: &'a deps_core::PackageName,
3258 _req: &'a deps_core::VersionReq,
3259 _selection_context: &'a deps_core::SelectionContext,
3260 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3261 {
3262 let name = name.clone();
3263 Box::pin(async move {
3264 if name.as_str() == "not-found" {
3265 Err(deps_core::error::DepsError::PackageNotFound {
3266 package: name.as_str().into(),
3267 registry: "mock",
3268 })
3269 } else {
3270 Err(deps_core::error::DepsError::CacheError(
3271 "mock fallback failure".to_string(),
3272 ))
3273 }
3274 })
3275 }
3276
3277 fn search_raw<'a>(
3278 &'a self,
3279 _query: &'a str,
3280 _limit: usize,
3281 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3282 {
3283 Box::pin(async move { Ok(vec![]) })
3284 }
3285
3286 fn as_any(&self) -> &dyn Any {
3287 self
3288 }
3289 }
3290
3291 let registry: Arc<dyn Registry> = Arc::new(FallbackErrorRegistry);
3292 let packages = vec![PackageName::new("flaky"), PackageName::new("not-found")];
3293
3294 let result = fetch_latest_versions_parallel(
3295 registry,
3296 with_registry_source(packages),
3297 &HashMap::new(),
3298 None,
3299 deps_core::freshness::FreshnessSettings::default(),
3300 5,
3301 10,
3302 &SelectionContext::none(),
3303 None,
3304 )
3305 .await;
3306
3307 assert!(result.versions.is_empty());
3308 assert_eq!(
3309 result.fetch_failed,
3310 HashMap::from([(PackageName::new("flaky"), FetchFailure::Transient)]),
3311 "the fallback's own non-not-found error must be recorded in fetch_failed, \
3312 but its not-found error must not"
3313 );
3314 assert_eq!(
3315 result.failed_count(),
3316 2,
3317 "both fallback failures count toward failed_count regardless of cause (S2)"
3318 );
3319 }
3320
3321 #[tokio::test]
3322 async fn test_fetch_fallback_timeout_recorded_as_fetch_failed() {
3323 use deps_core::{Metadata, Registry, Version};
3327 use std::any::Any;
3328 use std::time::Duration;
3329
3330 struct FallbackTimeoutRegistry;
3331
3332 impl Registry for FallbackTimeoutRegistry {
3333 fn get_versions<'a>(
3334 &'a self,
3335 _name: &'a deps_core::PackageName,
3336 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3337 {
3338 Box::pin(async move { Ok(vec![]) })
3339 }
3340
3341 fn get_latest_matching<'a>(
3342 &'a self,
3343 _name: &'a deps_core::PackageName,
3344 _req: &'a deps_core::VersionReq,
3345 _selection_context: &'a deps_core::SelectionContext,
3346 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3347 {
3348 Box::pin(async move {
3349 tokio::time::sleep(Duration::from_secs(10)).await;
3350 Ok(None)
3351 })
3352 }
3353
3354 fn search_raw<'a>(
3355 &'a self,
3356 _query: &'a str,
3357 _limit: usize,
3358 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3359 {
3360 Box::pin(async move { Ok(vec![]) })
3361 }
3362
3363 fn as_any(&self) -> &dyn Any {
3364 self
3365 }
3366 }
3367
3368 let registry: Arc<dyn Registry> = Arc::new(FallbackTimeoutRegistry);
3369 let packages = vec![PackageName::new("slow-fallback")];
3370
3371 let result = fetch_latest_versions_parallel(
3372 registry,
3373 with_registry_source(packages),
3374 &HashMap::new(),
3375 None,
3376 deps_core::freshness::FreshnessSettings::default(),
3377 1,
3378 10,
3379 &SelectionContext::none(),
3380 None,
3381 )
3382 .await;
3383
3384 assert!(result.versions.is_empty());
3385 assert_eq!(
3386 result.fetch_failed,
3387 HashMap::from([(PackageName::new("slow-fallback"), FetchFailure::Transient)])
3388 );
3389 assert_eq!(result.failed_count(), 1);
3390 }
3391
3392 #[tokio::test]
3393 async fn test_first_error_prefers_actionable_error_over_not_found_regardless_of_race_order() {
3394 use deps_core::{Metadata, Registry, Version};
3399 use std::any::Any;
3400 use std::time::Duration;
3401
3402 struct MixedErrorRegistry;
3403
3404 impl Registry for MixedErrorRegistry {
3405 fn get_versions<'a>(
3406 &'a self,
3407 name: &'a deps_core::PackageName,
3408 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3409 {
3410 Box::pin(async move {
3411 if name.as_str() == "typo-pkg" {
3412 Err(deps_core::error::DepsError::PackageNotFound {
3413 package: name.as_str().into(),
3414 registry: "mock",
3415 })
3416 } else {
3417 tokio::time::sleep(Duration::from_millis(50)).await;
3418 Err(deps_core::error::DepsError::CacheError(
3419 "rate limit exceeded".to_string(),
3420 ))
3421 }
3422 })
3423 }
3424
3425 fn get_latest_matching<'a>(
3426 &'a self,
3427 _name: &'a deps_core::PackageName,
3428 _req: &'a deps_core::VersionReq,
3429 _selection_context: &'a deps_core::SelectionContext,
3430 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3431 {
3432 Box::pin(async move { Ok(None) })
3433 }
3434
3435 fn search_raw<'a>(
3436 &'a self,
3437 _query: &'a str,
3438 _limit: usize,
3439 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3440 {
3441 Box::pin(async move { Ok(vec![]) })
3442 }
3443
3444 fn as_any(&self) -> &dyn Any {
3445 self
3446 }
3447 }
3448
3449 let registry: Arc<dyn Registry> = Arc::new(MixedErrorRegistry);
3450 let packages = vec![
3451 PackageName::new("typo-pkg"),
3452 PackageName::new("rate-limited"),
3453 ];
3454
3455 let result = fetch_latest_versions_parallel(
3456 registry,
3457 with_registry_source(packages),
3458 &HashMap::new(),
3459 None,
3460 deps_core::freshness::FreshnessSettings::default(),
3461 5,
3462 10,
3463 &SelectionContext::none(),
3464 None,
3465 )
3466 .await;
3467
3468 let err = result
3469 .failure_message()
3470 .expect("an actionable failure occurred and must be reported");
3471 assert!(
3472 err.contains("rate limit exceeded"),
3473 "the actionable error must win the toast over the faster-finishing not-found, \
3474 got: {err}"
3475 );
3476 assert!(
3477 !err.contains("not found"),
3478 "a not-found error must never outrank an actionable error, got: {err}"
3479 );
3480 }
3481
3482 #[tokio::test]
3483 async fn test_first_error_falls_back_to_not_found_when_no_actionable_error_occurred() {
3484 use deps_core::{Metadata, Registry, Version};
3489 use std::any::Any;
3490
3491 struct AllNotFoundRegistry;
3492
3493 impl Registry for AllNotFoundRegistry {
3494 fn get_versions<'a>(
3495 &'a self,
3496 name: &'a deps_core::PackageName,
3497 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3498 {
3499 Box::pin(async move {
3500 Err(deps_core::error::DepsError::PackageNotFound {
3501 package: name.as_str().into(),
3502 registry: "mock",
3503 })
3504 })
3505 }
3506
3507 fn get_latest_matching<'a>(
3508 &'a self,
3509 _name: &'a deps_core::PackageName,
3510 _req: &'a deps_core::VersionReq,
3511 _selection_context: &'a deps_core::SelectionContext,
3512 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3513 {
3514 Box::pin(async move { Ok(None) })
3515 }
3516
3517 fn search_raw<'a>(
3518 &'a self,
3519 _query: &'a str,
3520 _limit: usize,
3521 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3522 {
3523 Box::pin(async move { Ok(vec![]) })
3524 }
3525
3526 fn as_any(&self) -> &dyn Any {
3527 self
3528 }
3529 }
3530
3531 let registry: Arc<dyn Registry> = Arc::new(AllNotFoundRegistry);
3532 let packages = vec![
3533 PackageName::new("typo-pkg-1"),
3534 PackageName::new("typo-pkg-2"),
3535 ];
3536
3537 let result = fetch_latest_versions_parallel(
3538 registry,
3539 with_registry_source(packages),
3540 &HashMap::new(),
3541 None,
3542 deps_core::freshness::FreshnessSettings::default(),
3543 5,
3544 10,
3545 &SelectionContext::none(),
3546 None,
3547 )
3548 .await;
3549
3550 assert!(
3551 result.fetch_failed.is_empty(),
3552 "not-found errors must never be recorded in fetch_failed"
3553 );
3554 let err = result
3555 .failure_message()
3556 .expect("a not-found-only batch must still fall back to reporting one via first_error");
3557 assert!(err.contains("not found"), "got: {err}");
3558 }
3559
3560 #[tokio::test]
3561 async fn test_timeout_only_batch_reports_first_error_alongside_failed_count() {
3562 use deps_core::{Metadata, Registry, Version};
3567 use std::any::Any;
3568 use std::time::Duration;
3569
3570 struct AlwaysTimesOutRegistry;
3571
3572 impl Registry for AlwaysTimesOutRegistry {
3573 fn get_versions<'a>(
3574 &'a self,
3575 _name: &'a deps_core::PackageName,
3576 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3577 {
3578 Box::pin(async move {
3579 tokio::time::sleep(Duration::from_secs(10)).await;
3580 Ok(vec![])
3581 })
3582 }
3583
3584 fn get_latest_matching<'a>(
3585 &'a self,
3586 _name: &'a deps_core::PackageName,
3587 _req: &'a deps_core::VersionReq,
3588 _selection_context: &'a deps_core::SelectionContext,
3589 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3590 {
3591 Box::pin(async move {
3592 tokio::time::sleep(Duration::from_secs(10)).await;
3593 Ok(None)
3594 })
3595 }
3596
3597 fn search_raw<'a>(
3598 &'a self,
3599 _query: &'a str,
3600 _limit: usize,
3601 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3602 {
3603 Box::pin(async move { Ok(vec![]) })
3604 }
3605
3606 fn as_any(&self) -> &dyn Any {
3607 self
3608 }
3609 }
3610
3611 let registry: Arc<dyn Registry> = Arc::new(AlwaysTimesOutRegistry);
3612 let packages = vec![
3613 PackageName::new("slow-1"),
3614 PackageName::new("slow-2"),
3615 PackageName::new("slow-3"),
3616 ];
3617
3618 let result = fetch_latest_versions_parallel(
3619 registry,
3620 with_registry_source(packages),
3621 &HashMap::new(),
3622 None,
3623 deps_core::freshness::FreshnessSettings::default(),
3624 1,
3625 10,
3626 &SelectionContext::none(),
3627 None,
3628 )
3629 .await;
3630
3631 assert_eq!(
3632 result.failed_count(),
3633 3,
3634 "all 3 packages must count toward failed_count"
3635 );
3636 let err = result
3637 .failure_message()
3638 .expect("a timeout is actionable and must populate first_error, not just failed_count");
3639 assert!(
3640 err.contains("timed out"),
3641 "first_error must be the actionable timeout message, got: {err}"
3642 );
3643 }
3644
3645 #[cfg(feature = "composer")]
3647 mod composer_tests {
3648 use super::*;
3649
3650 #[tokio::test]
3655 async fn test_prepare_fetch_selection_context_extracts_from_real_parse_result() {
3656 let json = r#"{
3657 "minimum-stability": "beta",
3658 "require": {
3659 "symfony/console": "^6.0"
3660 }
3661}"#;
3662 let uri = deps_core::test_util::test_uri("/test/composer.json");
3663 let parse_result = crate::setup::parse_composer_json(json, &uri).unwrap();
3664 let formatter = deps_composer::ComposerFormatter;
3665
3666 let prep = prepare_fetch(
3667 &parse_result as &dyn deps_core::ParseResult,
3668 &formatter,
3669 deps_core::EcosystemId::Composer,
3670 &HashMap::new(),
3671 &HashMap::new(),
3672 );
3673
3674 assert_eq!(
3675 prep.selection_context.minimum_stability(),
3676 Some(deps_core::StabilityFloor::Beta)
3677 );
3678 }
3679
3680 #[tokio::test]
3683 async fn test_prepare_fetch_selection_context_none_when_absent() {
3684 let json = r#"{"require": {"symfony/console": "^6.0"}}"#;
3685 let uri = deps_core::test_util::test_uri("/test/composer.json");
3686 let parse_result = crate::setup::parse_composer_json(json, &uri).unwrap();
3687 let formatter = deps_composer::ComposerFormatter;
3688
3689 let prep = prepare_fetch(
3690 &parse_result as &dyn deps_core::ParseResult,
3691 &formatter,
3692 deps_core::EcosystemId::Composer,
3693 &HashMap::new(),
3694 &HashMap::new(),
3695 );
3696
3697 assert_eq!(prep.selection_context.minimum_stability(), None);
3698 }
3699 }
3700 mod yanked_check_tests {
3701 use super::*;
3702 use deps_core::test_util::MockVersion;
3703 use deps_core::{Metadata, Version};
3704 use std::any::Any;
3705 use std::sync::atomic::{AtomicUsize, Ordering};
3706
3707 enum FetchOutcome {
3710 Versions(Vec<(&'static str, bool)>),
3711 Error,
3712 Timeout,
3713 }
3714
3715 struct MockRegistry {
3725 reports_yanked: bool,
3726 versions: HashMap<&'static str, FetchOutcome>,
3727 latest_fallback: HashMap<&'static str, (&'static str, bool)>,
3728 fetch_calls: Arc<AtomicUsize>,
3729 }
3730
3731 impl Registry for MockRegistry {
3732 fn get_versions<'a>(
3733 &'a self,
3734 name: &'a PackageName,
3735 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3736 {
3737 self.fetch_calls.fetch_add(1, Ordering::Relaxed);
3738 let outcome = self.versions.get(name.as_str());
3739 Box::pin(async move {
3740 match outcome {
3741 Some(FetchOutcome::Versions(vs)) => Ok(vs
3742 .iter()
3743 .map(|(v, y)| {
3744 Box::new(MockVersion::new(*v).yanked(*y)) as Box<dyn Version>
3745 })
3746 .collect()),
3747 Some(FetchOutcome::Error) => Err(deps_core::error::DepsError::CacheError(
3748 "mock fetch error".to_string(),
3749 )),
3750 Some(FetchOutcome::Timeout) => {
3751 tokio::time::sleep(std::time::Duration::from_secs(10)).await;
3752 Ok(vec![])
3753 }
3754 None => Ok(vec![]),
3755 }
3756 })
3757 }
3758
3759 fn select_latest_matching(
3760 &self,
3761 versions: &[Box<dyn Version>],
3762 _req: &VersionReq,
3763 _selection_context: &deps_core::SelectionContext,
3764 ) -> Option<usize> {
3765 versions
3766 .iter()
3767 .position(|v| !v.removal_status().blocks_resolution())
3768 }
3769
3770 fn get_latest_matching<'a>(
3771 &'a self,
3772 name: &'a PackageName,
3773 _req: &'a VersionReq,
3774 _selection_context: &'a deps_core::SelectionContext,
3775 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3776 {
3777 let outcome = self.latest_fallback.get(name.as_str()).copied();
3778 Box::pin(async move {
3779 Ok(outcome
3780 .map(|(v, y)| Box::new(MockVersion::new(v).yanked(y)) as Box<dyn Version>))
3781 })
3782 }
3783
3784 fn search_raw<'a>(
3785 &'a self,
3786 _query: &'a str,
3787 _limit: usize,
3788 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3789 {
3790 Box::pin(async move { Ok(vec![]) })
3791 }
3792
3793 fn reports_yanked(&self) -> bool {
3794 self.reports_yanked
3795 }
3796
3797 fn as_any(&self) -> &dyn Any {
3798 self
3799 }
3800 }
3801
3802 #[tokio::test]
3803 async fn reports_yanked_false_never_recorded() {
3804 let fetch_calls = Arc::new(AtomicUsize::new(0));
3808 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3809 reports_yanked: false,
3810 versions: HashMap::from([(
3811 "pkg",
3812 FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", true)]),
3813 )]),
3814 latest_fallback: HashMap::new(),
3815 fetch_calls: Arc::clone(&fetch_calls),
3816 });
3817 let mut in_use = HashMap::new();
3818 in_use.insert(
3819 PackageName::new("pkg"),
3820 vec![ConcreteVersion::from("1.0.0")],
3821 );
3822
3823 let result = fetch_latest_versions_parallel(
3824 registry,
3825 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3826 &in_use,
3827 None,
3828 deps_core::freshness::FreshnessSettings::default(),
3829 5,
3830 10,
3831 &SelectionContext::none(),
3832 None,
3833 )
3834 .await;
3835
3836 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3837 assert!(result.yanked_versions.is_empty());
3838 }
3839
3840 #[tokio::test]
3841 async fn in_use_equal_to_latest_not_yanked() {
3842 let fetch_calls = Arc::new(AtomicUsize::new(0));
3843 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3844 reports_yanked: true,
3845 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", false)]))]),
3846 latest_fallback: HashMap::new(),
3847 fetch_calls: Arc::clone(&fetch_calls),
3848 });
3849 let mut in_use = HashMap::new();
3850 in_use.insert(
3851 PackageName::new("pkg"),
3852 vec![ConcreteVersion::from("1.0.0")],
3853 );
3854
3855 let result = fetch_latest_versions_parallel(
3856 registry,
3857 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3858 &in_use,
3859 None,
3860 deps_core::freshness::FreshnessSettings::default(),
3861 5,
3862 10,
3863 &SelectionContext::none(),
3864 None,
3865 )
3866 .await;
3867
3868 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3869 assert!(result.yanked_versions.is_empty());
3870 }
3871
3872 #[tokio::test]
3873 async fn no_known_in_use_version_skips_the_check() {
3874 let fetch_calls = Arc::new(AtomicUsize::new(0));
3875 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3876 reports_yanked: true,
3877 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("2.0.0", false)]))]),
3878 latest_fallback: HashMap::new(),
3879 fetch_calls: Arc::clone(&fetch_calls),
3880 });
3881
3882 let result = fetch_latest_versions_parallel(
3883 registry,
3884 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3885 &HashMap::new(),
3886 None,
3887 deps_core::freshness::FreshnessSettings::default(),
3888 5,
3889 10,
3890 &SelectionContext::none(),
3891 None,
3892 )
3893 .await;
3894
3895 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3896 assert!(result.yanked_versions.is_empty());
3897 }
3898
3899 #[tokio::test]
3900 async fn in_use_differs_and_yanked_is_recorded() {
3901 let fetch_calls = Arc::new(AtomicUsize::new(0));
3905 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3906 reports_yanked: true,
3907 versions: HashMap::from([(
3908 "pkg",
3909 FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", true)]),
3910 )]),
3911 latest_fallback: HashMap::new(),
3912 fetch_calls: Arc::clone(&fetch_calls),
3913 });
3914 let mut in_use = HashMap::new();
3915 in_use.insert(
3916 PackageName::new("pkg"),
3917 vec![ConcreteVersion::from("1.0.0")],
3918 );
3919
3920 let result = fetch_latest_versions_parallel(
3921 registry,
3922 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3923 &in_use,
3924 None,
3925 deps_core::freshness::FreshnessSettings::default(),
3926 5,
3927 10,
3928 &SelectionContext::none(),
3929 None,
3930 )
3931 .await;
3932
3933 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3934 assert_eq!(
3935 result.yanked_versions.get(&PackageName::new("pkg")),
3936 Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
3937 );
3938 }
3939
3940 #[tokio::test]
3941 async fn in_use_differs_and_not_yanked_is_not_recorded() {
3942 let fetch_calls = Arc::new(AtomicUsize::new(0));
3943 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3944 reports_yanked: true,
3945 versions: HashMap::from([(
3946 "pkg",
3947 FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", false)]),
3948 )]),
3949 latest_fallback: HashMap::new(),
3950 fetch_calls: Arc::clone(&fetch_calls),
3951 });
3952 let mut in_use = HashMap::new();
3953 in_use.insert(
3954 PackageName::new("pkg"),
3955 vec![ConcreteVersion::from("1.0.0")],
3956 );
3957
3958 let result = fetch_latest_versions_parallel(
3959 registry,
3960 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3961 &in_use,
3962 None,
3963 deps_core::freshness::FreshnessSettings::default(),
3964 5,
3965 10,
3966 &SelectionContext::none(),
3967 None,
3968 )
3969 .await;
3970
3971 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3972 assert!(result.yanked_versions.is_empty());
3973 }
3974
3975 #[tokio::test]
3976 async fn every_version_yanked_still_checks_in_use() {
3977 let fetch_calls = Arc::new(AtomicUsize::new(0));
3984 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3985 reports_yanked: true,
3986 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
3987 latest_fallback: HashMap::new(),
3988 fetch_calls: Arc::clone(&fetch_calls),
3989 });
3990 let mut in_use = HashMap::new();
3991 in_use.insert(
3992 PackageName::new("pkg"),
3993 vec![ConcreteVersion::from("1.0.0")],
3994 );
3995
3996 let result = fetch_latest_versions_parallel(
3997 registry,
3998 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3999 &in_use,
4000 None,
4001 deps_core::freshness::FreshnessSettings::default(),
4002 5,
4003 10,
4004 &SelectionContext::none(),
4005 None,
4006 )
4007 .await;
4008
4009 assert_eq!(
4010 result.yanked_versions.get(&PackageName::new("pkg")),
4011 Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
4012 );
4013 assert!(result.versions.is_empty());
4014 }
4015
4016 #[tokio::test]
4017 async fn latest_pick_needs_fallback_in_use_yanked_still_found() {
4018 let fetch_calls = Arc::new(AtomicUsize::new(0));
4024 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
4025 reports_yanked: true,
4026 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
4027 latest_fallback: HashMap::from([("pkg", ("2.0.0", false))]),
4028 fetch_calls: Arc::clone(&fetch_calls),
4029 });
4030 let mut in_use = HashMap::new();
4031 in_use.insert(
4032 PackageName::new("pkg"),
4033 vec![ConcreteVersion::from("1.0.0")],
4034 );
4035
4036 let result = fetch_latest_versions_parallel(
4037 registry,
4038 vec![(PackageName::new("pkg"), DependencySource::Registry)],
4039 &in_use,
4040 None,
4041 deps_core::freshness::FreshnessSettings::default(),
4042 5,
4043 10,
4044 &SelectionContext::none(),
4045 None,
4046 )
4047 .await;
4048
4049 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
4050 assert_eq!(
4051 result
4052 .versions
4053 .get(&PackageName::new("pkg"))
4054 .map(|v| v.latest.as_str()),
4055 Some("2.0.0")
4056 );
4057 assert_eq!(
4058 result.yanked_versions.get(&PackageName::new("pkg")),
4059 Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
4060 );
4061 }
4062
4063 struct YankedThenFallbackErrorRegistry;
4070
4071 impl Registry for YankedThenFallbackErrorRegistry {
4072 fn get_versions<'a>(
4073 &'a self,
4074 _name: &'a PackageName,
4075 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4076 {
4077 Box::pin(async move {
4078 Ok(vec![
4079 Box::new(MockVersion::new("1.0.0").yanked(true)) as Box<dyn Version>
4080 ])
4081 })
4082 }
4083
4084 fn select_latest_matching(
4085 &self,
4086 versions: &[Box<dyn Version>],
4087 _req: &VersionReq,
4088 _selection_context: &deps_core::SelectionContext,
4089 ) -> Option<usize> {
4090 versions
4091 .iter()
4092 .position(|v| !v.removal_status().blocks_resolution())
4093 }
4094
4095 fn get_latest_matching<'a>(
4096 &'a self,
4097 _name: &'a PackageName,
4098 _req: &'a VersionReq,
4099 _selection_context: &'a deps_core::SelectionContext,
4100 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4101 {
4102 Box::pin(async move {
4103 Err(deps_core::error::DepsError::CacheError(
4104 "mock fallback failure".to_string(),
4105 ))
4106 })
4107 }
4108
4109 fn search_raw<'a>(
4110 &'a self,
4111 _query: &'a str,
4112 _limit: usize,
4113 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4114 {
4115 Box::pin(async move { Ok(vec![]) })
4116 }
4117
4118 fn reports_yanked(&self) -> bool {
4119 true
4120 }
4121
4122 fn as_any(&self) -> &dyn Any {
4123 self
4124 }
4125 }
4126
4127 #[tokio::test]
4128 async fn fallback_error_does_not_suppress_an_already_found_yanked_in_use_version() {
4129 let mut in_use = HashMap::new();
4130 in_use.insert(
4131 PackageName::new("pkg"),
4132 vec![ConcreteVersion::from("1.0.0")],
4133 );
4134
4135 let result = fetch_latest_versions_parallel(
4136 Arc::new(YankedThenFallbackErrorRegistry),
4137 vec![(PackageName::new("pkg"), DependencySource::Registry)],
4138 &in_use,
4139 None,
4140 deps_core::freshness::FreshnessSettings::default(),
4141 5,
4142 10,
4143 &SelectionContext::none(),
4144 None,
4145 )
4146 .await;
4147
4148 assert!(
4149 result.versions.is_empty(),
4150 "the fallback errored, so no version was resolved"
4151 );
4152 assert_eq!(
4153 result.yanked_versions.get(&PackageName::new("pkg")),
4154 Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked)),
4155 "the yanked in-use finding must survive even though the fallback pick failed"
4156 );
4157 assert_eq!(
4158 result.fetch_failed.get(&PackageName::new("pkg")),
4159 Some(&FetchFailure::Transient)
4160 );
4161 assert_eq!(result.failed_count(), 1);
4162 }
4163
4164 #[tokio::test]
4165 async fn in_use_checks_every_occurrence_of_a_duplicate_name() {
4166 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
4172 reports_yanked: true,
4173 versions: HashMap::from([(
4174 "pkg",
4175 FetchOutcome::Versions(vec![
4176 ("3.0.0", false),
4177 ("2.0.0", true),
4178 ("1.0.0", false),
4179 ]),
4180 )]),
4181 latest_fallback: HashMap::new(),
4182 fetch_calls: Arc::new(AtomicUsize::new(0)),
4183 });
4184 let mut in_use = HashMap::new();
4185 in_use.insert(
4186 PackageName::new("pkg"),
4187 vec![
4188 ConcreteVersion::from("1.0.0"),
4189 ConcreteVersion::from("2.0.0"),
4190 ],
4191 );
4192
4193 let result = fetch_latest_versions_parallel(
4194 registry,
4195 vec![(PackageName::new("pkg"), DependencySource::Registry)],
4196 &in_use,
4197 None,
4198 deps_core::freshness::FreshnessSettings::default(),
4199 5,
4200 10,
4201 &SelectionContext::none(),
4202 None,
4203 )
4204 .await;
4205
4206 assert_eq!(
4207 result.yanked_versions.get(&PackageName::new("pkg")),
4208 Some(&(ConcreteVersion::new("2.0.0"), RemovalStatus::Yanked)),
4209 "the yanked occurrence must be found even though a name-keyed \
4210 single-value map could have kept only the non-yanked \"1.0.0\" pin"
4211 );
4212 }
4213
4214 #[tokio::test]
4215 async fn latest_is_yanked_recorded_as_defense_in_depth() {
4216 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
4222 reports_yanked: true,
4223 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
4224 latest_fallback: HashMap::from([("pkg", ("1.0.0", true))]),
4225 fetch_calls: Arc::new(AtomicUsize::new(0)),
4226 });
4227
4228 let result = fetch_latest_versions_parallel(
4229 registry,
4230 vec![(PackageName::new("pkg"), DependencySource::Registry)],
4231 &HashMap::new(),
4232 None,
4233 deps_core::freshness::FreshnessSettings::default(),
4234 5,
4235 10,
4236 &SelectionContext::none(),
4237 None,
4238 )
4239 .await;
4240
4241 assert_eq!(
4242 result.yanked_versions.get(&PackageName::new("pkg")),
4243 Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
4244 );
4245 }
4246
4247 #[tokio::test]
4248 async fn latest_is_yanked_not_recorded_when_reports_yanked_false() {
4249 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
4257 reports_yanked: false,
4258 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
4259 latest_fallback: HashMap::from([("pkg", ("1.0.0", true))]),
4260 fetch_calls: Arc::new(AtomicUsize::new(0)),
4261 });
4262
4263 let result = fetch_latest_versions_parallel(
4264 registry,
4265 vec![(PackageName::new("pkg"), DependencySource::Registry)],
4266 &HashMap::new(),
4267 None,
4268 deps_core::freshness::FreshnessSettings::default(),
4269 5,
4270 10,
4271 &SelectionContext::none(),
4272 None,
4273 )
4274 .await;
4275
4276 assert!(
4277 result.yanked_versions.is_empty(),
4278 "a `reports_yanked() == false` registry's `removal_status()` must never be \
4279 trusted, even on the zero-cost row-1 path"
4280 );
4281 assert!(
4282 result
4283 .versions
4284 .get(&PackageName::new("pkg"))
4285 .expect("pkg was fetched")
4286 .yanked
4287 .is_empty(),
4288 "`PackageVersions::yanked` must stay empty for a `reports_yanked() == false` \
4289 registry, even though the fetched version is itself flagged"
4290 );
4291 }
4292
4293 #[tokio::test]
4294 async fn primary_fetch_error_counts_as_failed_no_yanked_data() {
4295 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
4301 reports_yanked: true,
4302 versions: HashMap::from([("pkg", FetchOutcome::Error)]),
4303 latest_fallback: HashMap::new(),
4304 fetch_calls: Arc::new(AtomicUsize::new(0)),
4305 });
4306 let mut in_use = HashMap::new();
4307 in_use.insert(
4308 PackageName::new("pkg"),
4309 vec![ConcreteVersion::from("1.0.0")],
4310 );
4311
4312 let result = fetch_latest_versions_parallel(
4313 registry,
4314 vec![(PackageName::new("pkg"), DependencySource::Registry)],
4315 &in_use,
4316 None,
4317 deps_core::freshness::FreshnessSettings::default(),
4318 5,
4319 10,
4320 &SelectionContext::none(),
4321 None,
4322 )
4323 .await;
4324
4325 assert!(result.yanked_versions.is_empty());
4326 assert_eq!(result.failed_count(), 1);
4327 assert!(result.versions.is_empty());
4328 }
4329
4330 #[tokio::test]
4331 async fn primary_fetch_timeout_counts_as_failed_no_yanked_data() {
4332 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
4334 reports_yanked: true,
4335 versions: HashMap::from([("pkg", FetchOutcome::Timeout)]),
4336 latest_fallback: HashMap::new(),
4337 fetch_calls: Arc::new(AtomicUsize::new(0)),
4338 });
4339 let mut in_use = HashMap::new();
4340 in_use.insert(
4341 PackageName::new("pkg"),
4342 vec![ConcreteVersion::from("1.0.0")],
4343 );
4344
4345 let result = fetch_latest_versions_parallel(
4346 registry,
4347 vec![(PackageName::new("pkg"), DependencySource::Registry)],
4348 &in_use,
4349 None,
4350 deps_core::freshness::FreshnessSettings::default(),
4351 1,
4352 10,
4353 &SelectionContext::none(),
4354 None,
4355 )
4356 .await;
4357
4358 assert!(result.yanked_versions.is_empty());
4359 assert_eq!(result.failed_count(), 1);
4360 assert!(result.versions.is_empty());
4361 }
4362 }
4363
4364 mod deprecation_derivation_tests {
4370 use super::*;
4371 use deps_core::{Metadata, Version};
4372 use std::any::Any;
4373
4374 struct MockDeprecatedVersion {
4375 version: ConcreteVersion,
4376 deprecation: Option<Deprecation>,
4377 }
4378
4379 impl Version for MockDeprecatedVersion {
4380 fn version_string(&self) -> &ConcreteVersion {
4381 &self.version
4382 }
4383 fn removal_status(&self) -> RemovalStatus {
4384 RemovalStatus::from_advisory(self.deprecation.is_some())
4385 }
4386 fn deprecation(&self) -> Option<&Deprecation> {
4387 self.deprecation.as_ref()
4388 }
4389 fn as_any(&self) -> &dyn Any {
4390 self
4391 }
4392 }
4393
4394 struct SingleVersionRegistry {
4396 deprecation: Option<Deprecation>,
4397 }
4398
4399 impl Registry for SingleVersionRegistry {
4400 fn get_versions<'a>(
4401 &'a self,
4402 _name: &'a PackageName,
4403 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4404 {
4405 let deprecation = self.deprecation.clone();
4406 Box::pin(async move {
4407 Ok(vec![Box::new(MockDeprecatedVersion {
4408 version: "1.0.0".into(),
4409 deprecation,
4410 }) as Box<dyn Version>])
4411 })
4412 }
4413
4414 fn select_latest_matching(
4415 &self,
4416 versions: &[Box<dyn Version>],
4417 _req: &VersionReq,
4418 _selection_context: &deps_core::SelectionContext,
4419 ) -> Option<usize> {
4420 (!versions.is_empty()).then_some(0)
4421 }
4422
4423 fn get_latest_matching<'a>(
4424 &'a self,
4425 _name: &'a PackageName,
4426 _req: &'a VersionReq,
4427 _selection_context: &'a deps_core::SelectionContext,
4428 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4429 {
4430 Box::pin(async move { Ok(None) })
4431 }
4432
4433 fn search_raw<'a>(
4434 &'a self,
4435 _query: &'a str,
4436 _limit: usize,
4437 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4438 {
4439 Box::pin(async move { Ok(vec![]) })
4440 }
4441
4442 fn as_any(&self) -> &dyn Any {
4443 self
4444 }
4445 }
4446
4447 #[tokio::test]
4448 async fn fetch_result_carries_deprecation_from_resolved_pick() {
4449 let registry: Arc<dyn Registry> = Arc::new(SingleVersionRegistry {
4450 deprecation: Some(Deprecation {
4451 reason: Some("archived".to_string()),
4452 replacement: Some("other/pkg".to_string()),
4453 }),
4454 });
4455
4456 let result = fetch_latest_versions_parallel(
4457 registry,
4458 vec![(PackageName::new("pkg"), DependencySource::Registry)],
4459 &HashMap::new(),
4460 None,
4461 deps_core::freshness::FreshnessSettings::default(),
4462 5,
4463 10,
4464 &SelectionContext::none(),
4465 None,
4466 )
4467 .await;
4468
4469 assert_eq!(
4470 result.deprecations.get(&PackageName::new("pkg")),
4471 Some(&Deprecation {
4472 reason: Some("archived".to_string()),
4473 replacement: Some("other/pkg".to_string()),
4474 })
4475 );
4476 }
4477
4478 #[tokio::test]
4479 async fn fetch_result_has_no_deprecation_when_resolved_pick_is_clean() {
4480 let registry: Arc<dyn Registry> = Arc::new(SingleVersionRegistry { deprecation: None });
4481
4482 let result = fetch_latest_versions_parallel(
4483 registry,
4484 vec![(PackageName::new("pkg"), DependencySource::Registry)],
4485 &HashMap::new(),
4486 None,
4487 deps_core::freshness::FreshnessSettings::default(),
4488 5,
4489 10,
4490 &SelectionContext::none(),
4491 None,
4492 )
4493 .await;
4494
4495 assert!(result.deprecations.is_empty());
4496 }
4497 }
4498
4499 mod gossip_cooldown_filter_tests {
4503 use super::*;
4504 use deps_core::test_util::{MockVersion, stub_gossip_findings};
4505 use deps_core::{
4506 GossipCooldown, GossipRiskLevel, Metadata, PublishTime, Registry, Version,
4507 };
4508 use std::any::Any;
4509
4510 struct FixedListRegistry {
4518 versions: Vec<&'static str>,
4519 fallback: Option<&'static str>,
4520 reject_prerelease: bool,
4526 fallback_calls: std::sync::atomic::AtomicUsize,
4530 }
4531
4532 impl FixedListRegistry {
4533 fn new(versions: Vec<&'static str>) -> Self {
4534 Self {
4535 versions,
4536 fallback: None,
4537 reject_prerelease: false,
4538 fallback_calls: std::sync::atomic::AtomicUsize::new(0),
4539 }
4540 }
4541
4542 fn with_fallback(versions: Vec<&'static str>, fallback: &'static str) -> Self {
4543 Self {
4544 versions,
4545 fallback: Some(fallback),
4546 reject_prerelease: false,
4547 fallback_calls: std::sync::atomic::AtomicUsize::new(0),
4548 }
4549 }
4550
4551 fn with_prerelease_rejection(versions: Vec<&'static str>) -> Self {
4552 Self {
4553 versions,
4554 fallback: None,
4555 reject_prerelease: true,
4556 fallback_calls: std::sync::atomic::AtomicUsize::new(0),
4557 }
4558 }
4559
4560 fn fallback_call_count(&self) -> usize {
4561 self.fallback_calls
4562 .load(std::sync::atomic::Ordering::SeqCst)
4563 }
4564 }
4565
4566 impl Registry for FixedListRegistry {
4567 fn get_versions<'a>(
4568 &'a self,
4569 _name: &'a PackageName,
4570 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4571 {
4572 let versions = self.versions.clone();
4573 Box::pin(async move {
4574 Ok(versions
4575 .into_iter()
4576 .map(|v| Box::new(MockVersion::new(v)) as Box<dyn Version>)
4577 .collect())
4578 })
4579 }
4580
4581 fn get_latest_matching<'a>(
4582 &'a self,
4583 _name: &'a PackageName,
4584 _req: &'a VersionReq,
4585 _selection_context: &'a SelectionContext,
4586 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4587 {
4588 self.fallback_calls
4591 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4592 let fallback = self.fallback;
4593 Box::pin(async move {
4594 Ok(fallback.map(|v| Box::new(MockVersion::new(v)) as Box<dyn Version>))
4595 })
4596 }
4597
4598 fn search_raw<'a>(
4599 &'a self,
4600 _query: &'a str,
4601 _limit: usize,
4602 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4603 {
4604 Box::pin(async move { Ok(vec![]) })
4605 }
4606
4607 fn select_latest_matching(
4608 &self,
4609 versions: &[Box<dyn Version>],
4610 _req: &VersionReq,
4611 _selection_context: &SelectionContext,
4612 ) -> Option<usize> {
4613 if self.reject_prerelease {
4614 versions
4615 .iter()
4616 .position(|v| !v.version_string().as_str().contains('-'))
4617 } else if versions.is_empty() {
4618 None
4619 } else {
4620 Some(0)
4624 }
4625 }
4626
4627 fn as_any(&self) -> &dyn Any {
4628 self
4629 }
4630 }
4631
4632 fn active_cooldown() -> GossipCooldown {
4633 GossipCooldown::new(
4634 PublishTime::from_unix_secs(i64::MAX / 2),
4635 GossipRiskLevel::High,
4636 )
4637 }
4638
4639 fn expired_cooldown() -> GossipCooldown {
4640 GossipCooldown::new(PublishTime::from_unix_secs(1), GossipRiskLevel::High)
4641 }
4642
4643 async fn fetch_pkg(
4644 registry: Arc<FixedListRegistry>,
4645 in_use: Vec<&'static str>,
4646 gossip: HashMap<PackageName, deps_core::GossipFindings>,
4647 ) -> Option<PackageVersions> {
4648 let registry: Arc<dyn Registry> = registry;
4649 let mut in_use_map = HashMap::new();
4650 if !in_use.is_empty() {
4651 in_use_map.insert(
4652 PackageName::new("pkg"),
4653 in_use.into_iter().map(ConcreteVersion::from).collect(),
4654 );
4655 }
4656 let result = fetch_latest_versions_parallel(
4657 registry,
4658 with_registry_source(vec![PackageName::new("pkg")]),
4659 &in_use_map,
4660 None,
4661 deps_core::freshness::FreshnessSettings::default(),
4662 5,
4663 10,
4664 &SelectionContext::none(),
4665 Some(&gossip),
4666 )
4667 .await;
4668 result.versions.get(&PackageName::new("pkg")).cloned()
4669 }
4670
4671 #[tokio::test]
4677 async fn active_cooldown_on_the_latest_version_excludes_it() {
4678 let mut gossip = HashMap::new();
4679 gossip.insert(
4680 PackageName::new("pkg"),
4681 stub_gossip_findings("2.0.0", Some(active_cooldown())),
4682 );
4683
4684 let versions = fetch_pkg(
4685 Arc::new(FixedListRegistry::new(vec!["2.0.0", "1.0.0"])),
4686 vec!["1.0.0"],
4687 gossip,
4688 )
4689 .await
4690 .expect("pkg must resolve");
4691
4692 assert_eq!(versions.latest.as_str(), "1.0.0");
4693 assert_eq!(
4694 versions
4695 .gossip_excluded_version
4696 .as_ref()
4697 .map(ConcreteVersion::as_str),
4698 Some("2.0.0")
4699 );
4700 }
4701
4702 #[tokio::test]
4704 async fn no_finding_for_package_is_a_no_op() {
4705 let versions = fetch_pkg(
4706 Arc::new(FixedListRegistry::new(vec!["2.0.0", "1.0.0"])),
4707 vec![],
4708 HashMap::new(),
4709 )
4710 .await
4711 .expect("pkg must resolve");
4712
4713 assert_eq!(versions.latest.as_str(), "2.0.0");
4714 assert!(versions.gossip_excluded_version.is_none());
4715 }
4716
4717 #[tokio::test]
4721 async fn finding_without_a_cooldown_is_a_no_op() {
4722 let mut gossip = HashMap::new();
4723 gossip.insert(PackageName::new("pkg"), stub_gossip_findings("2.0.0", None));
4724
4725 let versions = fetch_pkg(
4726 Arc::new(FixedListRegistry::new(vec!["2.0.0", "1.0.0"])),
4727 vec![],
4728 gossip,
4729 )
4730 .await
4731 .expect("pkg must resolve");
4732
4733 assert_eq!(versions.latest.as_str(), "2.0.0");
4734 assert!(versions.gossip_excluded_version.is_none());
4735 }
4736
4737 #[tokio::test]
4740 async fn expired_cooldown_is_a_no_op() {
4741 let mut gossip = HashMap::new();
4742 gossip.insert(
4743 PackageName::new("pkg"),
4744 stub_gossip_findings("2.0.0", Some(expired_cooldown())),
4745 );
4746
4747 let versions = fetch_pkg(
4748 Arc::new(FixedListRegistry::new(vec!["2.0.0", "1.0.0"])),
4749 vec![],
4750 gossip,
4751 )
4752 .await
4753 .expect("pkg must resolve");
4754
4755 assert_eq!(versions.latest.as_str(), "2.0.0");
4756 assert!(versions.gossip_excluded_version.is_none());
4757 }
4758
4759 #[tokio::test]
4763 async fn version_string_mismatch_is_a_no_op() {
4764 let mut gossip = HashMap::new();
4765 gossip.insert(
4766 PackageName::new("pkg"),
4767 stub_gossip_findings("3.0.0", Some(active_cooldown())),
4768 );
4769
4770 let versions = fetch_pkg(
4771 Arc::new(FixedListRegistry::new(vec!["2.0.0", "1.0.0"])),
4772 vec![],
4773 gossip,
4774 )
4775 .await
4776 .expect("pkg must resolve");
4777
4778 assert_eq!(versions.latest.as_str(), "2.0.0");
4779 assert!(versions.gossip_excluded_version.is_none());
4780 }
4781
4782 #[tokio::test]
4789 async fn in_use_version_itself_flagged_neutralizes_the_exclusion() {
4790 let mut gossip = HashMap::new();
4791 gossip.insert(
4792 PackageName::new("pkg"),
4793 stub_gossip_findings("2.0.0", Some(active_cooldown())),
4794 );
4795
4796 let versions = fetch_pkg(
4797 Arc::new(FixedListRegistry::new(vec!["2.0.0", "1.0.0"])),
4798 vec!["2.0.0"],
4799 gossip,
4800 )
4801 .await
4802 .expect("pkg must resolve");
4803
4804 assert_eq!(
4805 versions.latest.as_str(),
4806 "2.0.0",
4807 "must never regress below the already-declared/in-use version"
4808 );
4809 assert!(
4810 versions.gossip_excluded_version.is_none(),
4811 "the floor neutralized the exclusion — nothing was actually held back"
4812 );
4813 }
4814
4815 #[tokio::test]
4821 async fn safe_intermediate_release_above_the_floor_is_picked() {
4822 let mut gossip = HashMap::new();
4823 gossip.insert(
4824 PackageName::new("pkg"),
4825 stub_gossip_findings("3.0.0", Some(active_cooldown())),
4826 );
4827
4828 let versions = fetch_pkg(
4829 Arc::new(FixedListRegistry::new(vec!["3.0.0", "2.0.0", "1.0.0"])),
4830 vec!["1.0.0"],
4831 gossip,
4832 )
4833 .await
4834 .expect("pkg must resolve");
4835
4836 assert_eq!(
4837 versions.latest.as_str(),
4838 "2.0.0",
4839 "the safe intermediate release must win, not the flagged 3.0.0 nor a regression to the in-use 1.0.0"
4840 );
4841 assert_eq!(
4842 versions
4843 .gossip_excluded_version
4844 .as_ref()
4845 .map(ConcreteVersion::as_str),
4846 Some("3.0.0")
4847 );
4848 }
4849
4850 #[tokio::test]
4870 async fn no_in_use_version_at_all_is_a_deliberate_no_op() {
4871 let mut gossip = HashMap::new();
4872 gossip.insert(
4873 PackageName::new("pkg"),
4874 stub_gossip_findings("2.0.0", Some(active_cooldown())),
4875 );
4876
4877 let registry = Arc::new(FixedListRegistry::with_fallback(vec!["2.0.0"], "2.0.0"));
4878 let versions = fetch_pkg(Arc::clone(®istry), vec![], gossip)
4879 .await
4880 .expect("pkg must resolve from the unfiltered list-based pick");
4881
4882 assert_eq!(
4883 versions.latest.as_str(),
4884 "2.0.0",
4885 "no floor exists, so GOSSIP must not exclude anything this run"
4886 );
4887 assert!(
4888 versions.gossip_excluded_version.is_none(),
4889 "nothing was actually excluded — no attribution"
4890 );
4891 assert_eq!(
4892 registry.fallback_call_count(),
4893 0,
4894 "the network fallback must never be invoked as a consequence of GOSSIP's own \
4895 filtering when the unfiltered list-based pick already succeeded"
4896 );
4897 }
4898
4899 #[tokio::test]
4907 async fn floor_exists_but_ecosystem_selection_rejects_the_remainder_is_a_no_op() {
4908 let mut gossip = HashMap::new();
4909 gossip.insert(
4910 PackageName::new("pkg"),
4911 stub_gossip_findings("1.0.0", Some(active_cooldown())),
4912 );
4913
4914 let registry = Arc::new(FixedListRegistry::with_prerelease_rejection(vec![
4915 "1.0.0",
4916 "1.0.0-rc.1",
4917 ]));
4918 let versions = fetch_pkg(Arc::clone(®istry), vec!["1.0.0-rc.1"], gossip)
4919 .await
4920 .expect("pkg must resolve via the recovered unfiltered pick");
4921
4922 assert_eq!(
4923 versions.latest.as_str(),
4924 "1.0.0",
4925 "the ecosystem rejected the filtered remainder (an all-prerelease set), so the \
4926 unfiltered pick must be used instead"
4927 );
4928 assert!(
4929 versions.gossip_excluded_version.is_none(),
4930 "nothing was actually excluded from the final pick — no attribution"
4931 );
4932 assert_eq!(
4933 registry.fallback_call_count(),
4934 0,
4935 "the network fallback must never be invoked as a consequence of GOSSIP's own \
4936 filtering — only when the unfiltered pick itself finds nothing, which it did not"
4937 );
4938 }
4939
4940 #[tokio::test]
4949 async fn filtered_pick_below_the_floor_is_rejected_in_favor_of_the_unfiltered_pick() {
4950 let mut gossip = HashMap::new();
4951 gossip.insert(
4952 PackageName::new("pkg"),
4953 stub_gossip_findings("1.0.0", Some(active_cooldown())),
4954 );
4955
4956 let registry = Arc::new(FixedListRegistry::with_prerelease_rejection(vec![
4957 "1.0.0",
4958 "1.0.0-rc.1",
4959 "0.9.0",
4960 ]));
4961 let versions = fetch_pkg(Arc::clone(®istry), vec!["1.0.0-rc.1"], gossip)
4962 .await
4963 .expect("pkg must resolve via the recovered unfiltered pick");
4964
4965 assert_eq!(
4966 versions.latest.as_str(),
4967 "1.0.0",
4968 "must never regress to 0.9.0 — a downgrade below the floor (1.0.0-rc.1) — even \
4969 though 0.9.0 is a legitimate, ecosystem-selectable filtered pick"
4970 );
4971 assert!(
4972 versions.gossip_excluded_version.is_none(),
4973 "no attribution — the recovered pick is identical to the unfiltered one"
4974 );
4975 assert_eq!(
4976 registry.fallback_call_count(),
4977 0,
4978 "the network fallback must never be invoked as a consequence of GOSSIP's own filtering"
4979 );
4980 }
4981 }
4982
4983 mod cooldown_fallback_tests {
4987 use super::*;
4988 use deps_core::test_util::MockVersion;
4989 use deps_core::{PublishTime, Registry, Version};
4990 use std::any::Any;
4991
4992 struct FixedRegistry(Vec<MockVersion>);
4996
4997 impl Registry for FixedRegistry {
4998 fn get_versions<'a>(
4999 &'a self,
5000 _name: &'a PackageName,
5001 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5002 {
5003 let versions: Vec<Box<dyn Version>> = self
5004 .0
5005 .iter()
5006 .cloned()
5007 .map(|v| Box::new(v) as Box<dyn Version>)
5008 .collect();
5009 Box::pin(async move { Ok(versions) })
5010 }
5011
5012 fn get_latest_matching<'a>(
5013 &'a self,
5014 _name: &'a PackageName,
5015 _req: &'a VersionReq,
5016 _selection_context: &'a SelectionContext,
5017 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5018 {
5019 Box::pin(async move { Ok(None) })
5020 }
5021
5022 fn search_raw<'a>(
5023 &'a self,
5024 _query: &'a str,
5025 _limit: usize,
5026 ) -> deps_core::ecosystem::BoxFuture<
5027 'a,
5028 deps_core::Result<Vec<Box<dyn deps_core::Metadata>>>,
5029 > {
5030 Box::pin(async move { Ok(vec![]) })
5031 }
5032
5033 fn select_latest_matching(
5034 &self,
5035 versions: &[Box<dyn Version>],
5036 _req: &VersionReq,
5037 _selection_context: &SelectionContext,
5038 ) -> Option<usize> {
5039 versions
5040 .iter()
5041 .position(|v| !v.removal_status().blocks_resolution() && !v.is_prerelease())
5042 }
5043
5044 fn as_any(&self) -> &dyn Any {
5045 self
5046 }
5047 }
5048
5049 async fn fetch_pkg(
5050 versions: Vec<MockVersion>,
5051 in_use: Vec<&'static str>,
5052 cooldown_secs: u64,
5053 ) -> Option<PackageVersions> {
5054 fetch_pkg_with_registry(Arc::new(FixedRegistry(versions)), in_use, cooldown_secs).await
5055 }
5056
5057 async fn fetch_pkg_with_registry(
5060 registry: Arc<dyn Registry>,
5061 in_use: Vec<&'static str>,
5062 cooldown_secs: u64,
5063 ) -> Option<PackageVersions> {
5064 let mut in_use_map = HashMap::new();
5065 if !in_use.is_empty() {
5066 in_use_map.insert(
5067 PackageName::new("pkg"),
5068 in_use.into_iter().map(ConcreteVersion::from).collect(),
5069 );
5070 }
5071 let result = fetch_latest_versions_parallel(
5072 registry,
5073 with_registry_source(vec![PackageName::new("pkg")]),
5074 &in_use_map,
5075 None,
5076 deps_core::freshness::FreshnessSettings::Enabled {
5077 cooldown: deps_core::CooldownWindow::from_secs(cooldown_secs),
5078 },
5079 5,
5080 10,
5081 &SelectionContext::none(),
5082 None,
5083 )
5084 .await;
5085 result.versions.get(&PackageName::new("pkg")).cloned()
5086 }
5087
5088 struct NoListPickRegistry(Vec<MockVersion>);
5102
5103 impl Registry for NoListPickRegistry {
5104 fn get_versions<'a>(
5105 &'a self,
5106 _name: &'a PackageName,
5107 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5108 {
5109 let versions: Vec<Box<dyn Version>> = self
5110 .0
5111 .iter()
5112 .cloned()
5113 .map(|v| Box::new(v) as Box<dyn Version>)
5114 .collect();
5115 Box::pin(async move { Ok(versions) })
5116 }
5117
5118 fn get_latest_matching<'a>(
5119 &'a self,
5120 _name: &'a PackageName,
5121 _req: &'a VersionReq,
5122 _selection_context: &'a SelectionContext,
5123 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5124 {
5125 let resolved = self.0.first().cloned();
5126 Box::pin(async move { Ok(resolved.map(|v| Box::new(v) as Box<dyn Version>)) })
5127 }
5128
5129 fn search_raw<'a>(
5130 &'a self,
5131 _query: &'a str,
5132 _limit: usize,
5133 ) -> deps_core::ecosystem::BoxFuture<
5134 'a,
5135 deps_core::Result<Vec<Box<dyn deps_core::Metadata>>>,
5136 > {
5137 Box::pin(async move { Ok(vec![]) })
5138 }
5139
5140 fn select_latest_matching(
5141 &self,
5142 versions: &[Box<dyn Version>],
5143 _req: &VersionReq,
5144 _selection_context: &SelectionContext,
5145 ) -> Option<usize> {
5146 if versions
5147 .iter()
5148 .any(|v| v.as_any().downcast_ref::<MockVersion>().is_some())
5149 {
5150 return None;
5151 }
5152 versions
5153 .iter()
5154 .position(|v| !v.removal_status().blocks_resolution() && !v.is_prerelease())
5155 }
5156
5157 fn as_any(&self) -> &dyn Any {
5158 self
5159 }
5160 }
5161
5162 #[tokio::test]
5166 async fn yanked_top_ranked_cleared_candidate_yields_no_fallback() {
5167 let now = PublishTime::now();
5168 let cooldown_secs = 3 * 24 * 60 * 60;
5169 let recent = PublishTime::from_unix_secs(now.as_unix_secs() - 60); let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60); let versions = vec![
5173 MockVersion::new("1.2.0").with_published_at(recent),
5174 MockVersion::new("1.1.0")
5175 .with_published_at(old)
5176 .yanked(true),
5177 MockVersion::new("1.0.0")
5178 .with_published_at(old)
5179 .yanked(true),
5180 ];
5181
5182 let package_versions = fetch_pkg(versions, vec!["1.0.0"], cooldown_secs)
5183 .await
5184 .expect("pkg must resolve");
5185
5186 assert!(
5187 package_versions.cooldown_fallback.is_none(),
5188 "a yanked top-ranked cooldown-cleared candidate must not be recovered by \
5189 retrying further down the list: {:?}",
5190 package_versions.cooldown_fallback
5191 );
5192 }
5193
5194 #[tokio::test]
5197 async fn cleared_safe_candidate_above_the_floor_is_the_fallback() {
5198 let now = PublishTime::now();
5199 let cooldown_secs = 3 * 24 * 60 * 60;
5200 let recent = PublishTime::from_unix_secs(now.as_unix_secs() - 60);
5201 let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60);
5202
5203 let versions = vec![
5204 MockVersion::new("1.2.0").with_published_at(recent),
5205 MockVersion::new("1.1.0").with_published_at(old),
5206 MockVersion::new("1.0.0").with_published_at(old),
5207 ];
5208
5209 let package_versions = fetch_pkg(versions, vec!["1.0.0"], cooldown_secs)
5210 .await
5211 .expect("pkg must resolve");
5212
5213 let fallback = package_versions
5214 .cooldown_fallback
5215 .expect("a safe, cooldown-cleared, above-floor candidate must be recovered");
5216 assert_eq!(fallback.version.as_str(), "1.1.0");
5217 }
5218
5219 #[tokio::test]
5226 async fn permissive_range_repro_1564_still_computes_the_cleared_fallback() {
5227 let now = PublishTime::now();
5228 let cooldown_secs = 3 * 24 * 60 * 60;
5229 let recent = PublishTime::from_unix_secs(now.as_unix_secs() - 60); let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60); let versions = vec![
5233 MockVersion::new("0.23.0-rc.1")
5234 .with_published_at(recent)
5235 .with_prerelease(true),
5236 MockVersion::new("0.22.8").with_published_at(recent),
5237 MockVersion::new("0.22.7").with_published_at(old),
5238 MockVersion::new("0.22.6").with_published_at(old), ];
5240
5241 let package_versions = fetch_pkg(versions, vec!["0.22.6"], cooldown_secs)
5242 .await
5243 .expect("pkg must resolve");
5244
5245 let fallback = package_versions
5246 .cooldown_fallback
5247 .expect("a safe, cooldown-cleared, above-floor candidate must be recovered");
5248 assert_eq!(fallback.version.as_str(), "0.22.7");
5249 }
5250
5251 #[tokio::test]
5259 async fn prerelease_top_ranked_by_date_is_skipped_for_the_next_cooled_stable_release() {
5260 let now = PublishTime::now();
5261 let cooldown_secs = 3 * 24 * 60 * 60;
5262 let recent = PublishTime::from_unix_secs(now.as_unix_secs() - 60);
5263 let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60);
5264 let older = PublishTime::from_unix_secs(now.as_unix_secs() - 31 * 24 * 60 * 60);
5265
5266 let versions = vec![
5267 MockVersion::new("2.0.0").with_published_at(recent), MockVersion::new("2.0.0-rc.1")
5269 .with_published_at(old)
5270 .with_prerelease(true), MockVersion::new("1.9.0").with_published_at(older), MockVersion::new("1.8.0").with_published_at(older), ];
5274
5275 let package_versions = fetch_pkg(versions, vec!["1.8.0"], cooldown_secs)
5276 .await
5277 .expect("pkg must resolve");
5278
5279 let fallback = package_versions.cooldown_fallback.expect(
5280 "the prerelease must be skipped in favor of the next cooled stable release",
5281 );
5282 assert_eq!(fallback.version.as_str(), "1.9.0");
5283 }
5284
5285 #[tokio::test]
5293 async fn no_in_use_version_yields_a_fallback_when_a_safe_cleared_candidate_exists() {
5294 let now = PublishTime::now();
5295 let cooldown_secs = 3 * 24 * 60 * 60;
5296 let recent = PublishTime::from_unix_secs(now.as_unix_secs() - 60);
5297 let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60);
5298
5299 let versions = vec![
5300 MockVersion::new("1.2.0").with_published_at(recent),
5301 MockVersion::new("1.1.0").with_published_at(old),
5302 ];
5303
5304 let package_versions = fetch_pkg(versions, Vec::new(), cooldown_secs)
5305 .await
5306 .expect("pkg must resolve");
5307
5308 let fallback = package_versions
5309 .cooldown_fallback
5310 .expect("Absent floor must no longer suppress a safe, cleared candidate");
5311 assert_eq!(fallback.version.as_str(), "1.1.0");
5312 }
5313
5314 #[tokio::test]
5320 async fn partial_in_use_version_match_yields_no_fallback() {
5321 let now = PublishTime::now();
5322 let cooldown_secs = 3 * 24 * 60 * 60;
5323 let recent = PublishTime::from_unix_secs(now.as_unix_secs() - 60);
5324 let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60);
5325
5326 let versions = vec![
5327 MockVersion::new("1.2.0").with_published_at(recent),
5328 MockVersion::new("1.1.0").with_published_at(old),
5329 ];
5330
5331 let package_versions = fetch_pkg(
5332 versions,
5333 vec!["1.1.0", "not-a-resolvable-pseudo-version"],
5334 cooldown_secs,
5335 )
5336 .await
5337 .expect("pkg must resolve");
5338
5339 assert!(
5340 package_versions.cooldown_fallback.is_none(),
5341 "a partial in-use-version match (one locatable, one not) must fail closed, not \
5342 silently floor at the locatable entry: {:?}",
5343 package_versions.cooldown_fallback
5344 );
5345 }
5346
5347 #[tokio::test]
5357 async fn non_normalized_in_use_pin_yields_no_fallback() {
5358 let now = PublishTime::now();
5359 let cooldown_secs = 3 * 24 * 60 * 60;
5360 let recent = PublishTime::from_unix_secs(now.as_unix_secs() - 60);
5361 let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60);
5362
5363 let versions = vec![
5364 MockVersion::new("1.2.0").with_published_at(recent),
5365 MockVersion::new("1.1.0").with_published_at(old),
5366 MockVersion::new("1.0.0").with_published_at(old),
5367 ];
5368
5369 let package_versions = fetch_pkg(versions, vec!["1.0"], cooldown_secs)
5373 .await
5374 .expect("pkg must resolve");
5375
5376 assert!(
5377 package_versions.cooldown_fallback.is_none(),
5378 "a non-normalized in-use pin must fail closed (Unlocatable), never silently \
5379 treated as Absent or matched loosely: {:?}",
5380 package_versions.cooldown_fallback
5381 );
5382 }
5383
5384 #[tokio::test]
5390 async fn unknown_list_based_pick_still_runs_the_full_fallback_search() {
5391 let now = PublishTime::now();
5392 let cooldown_secs = 3 * 24 * 60 * 60;
5393 let recent = PublishTime::from_unix_secs(now.as_unix_secs() - 60); let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60); let versions = vec![
5397 MockVersion::new("1.2.0").with_published_at(recent),
5398 MockVersion::new("1.1.0").with_published_at(old),
5399 MockVersion::new("1.0.0").with_published_at(old),
5400 ];
5401
5402 let package_versions = fetch_pkg_with_registry(
5403 Arc::new(NoListPickRegistry(versions)),
5404 vec!["1.0.0"],
5405 cooldown_secs,
5406 )
5407 .await
5408 .expect("pkg must resolve via the get_latest_matching network fallback");
5409
5410 assert_eq!(
5411 package_versions.latest.as_str(),
5412 "1.2.0",
5413 "latest must come from the get_latest_matching fallback, not the list-based pick"
5414 );
5415 let fallback = package_versions.cooldown_fallback.expect(
5416 "an unknown list-based pick must still run the full search and find the \
5417 cooldown-cleared, above-floor candidate",
5418 );
5419 assert_eq!(fallback.version.as_str(), "1.1.0");
5420 }
5421
5422 #[tokio::test]
5427 async fn known_cleared_unfiltered_pick_yields_no_fallback() {
5428 let now = PublishTime::now();
5429 let cooldown_secs = 3 * 24 * 60 * 60;
5430 let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60); let versions = vec![
5435 MockVersion::new("1.2.0").with_published_at(old),
5436 MockVersion::new("1.1.0").with_published_at(old),
5437 ];
5438
5439 let package_versions = fetch_pkg(versions, vec!["1.1.0"], cooldown_secs)
5440 .await
5441 .expect("pkg must resolve");
5442
5443 assert!(
5444 package_versions.cooldown_fallback.is_none(),
5445 "a known, already-cleared unfiltered pick must skip the fallback scan: {:?}",
5446 package_versions.cooldown_fallback
5447 );
5448 }
5449
5450 #[tokio::test]
5456 async fn read_time_disposition_agrees_with_a_cleared_fetch_time_gate() {
5457 let now = PublishTime::now();
5458 let cooldown_secs = 3 * 24 * 60 * 60;
5459 let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60);
5460
5461 let versions = vec![
5462 MockVersion::new("1.2.0").with_published_at(old),
5463 MockVersion::new("1.1.0").with_published_at(old),
5464 ];
5465
5466 let package_versions = fetch_pkg(versions, vec!["1.1.0"], cooldown_secs)
5467 .await
5468 .expect("pkg must resolve");
5469 assert!(package_versions.cooldown_fallback.is_none());
5470
5471 let disposition = deps_core::lsp_helpers::cooldown_disposition(
5472 &package_versions,
5473 &PackageName::new("pkg"),
5474 deps_core::freshness::FreshnessSettings::Enabled {
5475 cooldown: deps_core::CooldownWindow::from_secs(cooldown_secs),
5476 },
5477 None,
5478 now,
5479 );
5480 assert_eq!(
5481 disposition,
5482 deps_core::lsp_helpers::CooldownDisposition::Cleared,
5483 "read time must agree with the fetch-time gate under unchanged inputs: {disposition:?}"
5484 );
5485 }
5486
5487 #[tokio::test]
5492 async fn narrowed_cooldown_window_between_fetch_and_read_yields_a_safe_skip_not_a_write() {
5493 let now = PublishTime::now();
5494 let fetch_cooldown_secs = 3 * 24 * 60 * 60;
5495 let one_day_ago = PublishTime::from_unix_secs(now.as_unix_secs() - 24 * 60 * 60);
5498
5499 let versions = vec![
5500 MockVersion::new("1.2.0").with_published_at(one_day_ago),
5501 MockVersion::new("1.1.0").with_published_at(one_day_ago),
5502 ];
5503
5504 let package_versions = fetch_pkg(versions, vec!["1.1.0"], fetch_cooldown_secs)
5505 .await
5506 .expect("pkg must resolve");
5507 assert!(
5508 package_versions.cooldown_fallback.is_none(),
5509 "the fetch-time gate saw latest as Cleared under the wider window, so no \
5510 fallback was ever computed or stored"
5511 );
5512
5513 let narrowed_cooldown_secs = 2 * 24 * 60 * 60;
5514 let disposition = deps_core::lsp_helpers::cooldown_disposition(
5515 &package_versions,
5516 &PackageName::new("pkg"),
5517 deps_core::freshness::FreshnessSettings::Enabled {
5518 cooldown: deps_core::CooldownWindow::from_secs(narrowed_cooldown_secs),
5519 },
5520 None,
5521 now,
5522 );
5523 match disposition {
5524 deps_core::lsp_helpers::CooldownDisposition::Blocked { fallback, .. } => {
5525 assert!(
5526 fallback.is_none(),
5527 "a narrowed window must never surface a fallback the fetch-time gate \
5528 never computed — that would be an unsafe write, not a stricter skip"
5529 );
5530 }
5531 other => panic!("expected a stricter read-time Blocked skip, got {other:?}"),
5532 }
5533 }
5534
5535 #[tokio::test]
5540 async fn read_time_disposition_agrees_with_the_network_fallback_branch() {
5541 let now = PublishTime::now();
5542 let cooldown_secs = 3 * 24 * 60 * 60;
5543 let recent = PublishTime::from_unix_secs(now.as_unix_secs() - 60);
5544 let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60);
5545
5546 let versions = vec![
5547 MockVersion::new("1.2.0").with_published_at(recent),
5548 MockVersion::new("1.1.0").with_published_at(old),
5549 MockVersion::new("1.0.0").with_published_at(old),
5550 ];
5551
5552 let package_versions = fetch_pkg_with_registry(
5553 Arc::new(NoListPickRegistry(versions)),
5554 vec!["1.0.0"],
5555 cooldown_secs,
5556 )
5557 .await
5558 .expect("pkg must resolve via the get_latest_matching network fallback");
5559
5560 let disposition = deps_core::lsp_helpers::cooldown_disposition(
5561 &package_versions,
5562 &PackageName::new("pkg"),
5563 deps_core::freshness::FreshnessSettings::Enabled {
5564 cooldown: deps_core::CooldownWindow::from_secs(cooldown_secs),
5565 },
5566 None,
5567 now,
5568 );
5569 match disposition {
5570 deps_core::lsp_helpers::CooldownDisposition::Blocked {
5571 fallback: Some(fallback),
5572 ..
5573 } => {
5574 assert_eq!(fallback.version.as_str(), "1.1.0");
5575 }
5576 other => panic!(
5577 "expected read time to agree with the fetch-time-stored fallback, got {other:?}"
5578 ),
5579 }
5580 }
5581
5582 #[tokio::test]
5592 async fn read_time_disposition_agrees_with_the_gossip_substituted_latest_branch() {
5593 let now = PublishTime::now();
5594 let cooldown_secs = 3 * 24 * 60 * 60;
5595 let recent = PublishTime::from_unix_secs(now.as_unix_secs() - 60);
5596 let old = PublishTime::from_unix_secs(now.as_unix_secs() - 30 * 24 * 60 * 60);
5597
5598 let versions = vec![
5602 MockVersion::new("3.0.0").with_published_at(recent),
5603 MockVersion::new("2.0.0").with_published_at(recent),
5604 MockVersion::new("1.5.0").with_published_at(old),
5605 MockVersion::new("1.0.0").with_published_at(old),
5606 ];
5607 let mut gossip = HashMap::new();
5608 gossip.insert(
5609 PackageName::new("pkg"),
5610 deps_core::test_util::stub_gossip_findings(
5611 "3.0.0",
5612 Some(deps_core::GossipCooldown::new(
5613 PublishTime::from_unix_secs(i64::MAX / 2),
5614 deps_core::GossipRiskLevel::High,
5615 )),
5616 ),
5617 );
5618
5619 let registry: Arc<dyn Registry> = Arc::new(FixedRegistry(versions));
5620 let mut in_use_map = HashMap::new();
5621 in_use_map.insert(
5622 PackageName::new("pkg"),
5623 vec![ConcreteVersion::from("1.0.0")],
5624 );
5625 let result = fetch_latest_versions_parallel(
5626 registry,
5627 with_registry_source(vec![PackageName::new("pkg")]),
5628 &in_use_map,
5629 None,
5630 deps_core::freshness::FreshnessSettings::Enabled {
5631 cooldown: deps_core::CooldownWindow::from_secs(cooldown_secs),
5632 },
5633 5,
5634 10,
5635 &SelectionContext::none(),
5636 Some(&gossip),
5637 )
5638 .await;
5639 let package_versions = result
5640 .versions
5641 .get(&PackageName::new("pkg"))
5642 .cloned()
5643 .expect("pkg must resolve");
5644
5645 assert_eq!(
5646 package_versions.latest.as_str(),
5647 "2.0.0",
5648 "the GOSSIP floor-protected filter must substitute the filtered pick as latest"
5649 );
5650 assert_eq!(
5651 package_versions
5652 .gossip_excluded_version
5653 .as_ref()
5654 .map(ConcreteVersion::as_str),
5655 Some("3.0.0")
5656 );
5657 let fetch_time_fallback = package_versions
5658 .cooldown_fallback
5659 .as_ref()
5660 .expect("the full scan must have run (SC-012's short-circuit cannot fire here)");
5661 assert_eq!(fetch_time_fallback.version.as_str(), "1.5.0");
5662
5663 let disposition = deps_core::lsp_helpers::cooldown_disposition(
5664 &package_versions,
5665 &PackageName::new("pkg"),
5666 deps_core::freshness::FreshnessSettings::Enabled {
5667 cooldown: deps_core::CooldownWindow::from_secs(cooldown_secs),
5668 },
5669 Some(&gossip),
5670 now,
5671 );
5672 match disposition {
5673 deps_core::lsp_helpers::CooldownDisposition::Blocked {
5674 fallback: Some(fallback),
5675 ..
5676 } => {
5677 assert_eq!(
5678 fallback.version.as_str(),
5679 "1.5.0",
5680 "read time, evaluated against the substituted latest, must agree with \
5681 the fetch-time-computed fallback"
5682 );
5683 }
5684 other => panic!(
5685 "expected read time to agree with the fetch-time-stored fallback for the \
5686 substituted latest, got {other:?}"
5687 ),
5688 }
5689 }
5690 }
5691}