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::VersionReq;
20use std::collections::{HashMap, HashSet};
21use std::sync::Arc;
22use std::time::Duration;
23
24pub type DepSources = Vec<(PackageName, deps_core::parser::DependencySource)>;
27
28pub fn dedup_dependencies_by_source(
94 parse_result: &dyn deps_core::ParseResult,
95 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
96) -> (
97 HashMap<PackageName, deps_core::parser::DependencySource>,
98 HashSet<PackageName>,
99) {
100 use std::collections::hash_map::Entry;
101
102 let mut by_name: HashMap<PackageName, deps_core::parser::DependencySource> = HashMap::new();
103 let mut collided: HashSet<PackageName> = HashSet::new();
104
105 for dep in parse_result
106 .dependencies()
107 .into_iter()
108 .filter(|dep| formatter.can_resolve_source(&dep.source()))
109 {
110 let name = dep.name().clone();
111 let source = dep.source();
112 match by_name.entry(name.clone()) {
113 Entry::Vacant(entry) => {
114 entry.insert(source);
115 }
116 Entry::Occupied(entry) => {
117 if *entry.get() != source && collided.insert(name.clone()) {
118 tracing::warn!(
119 package = %name,
120 source_a = ?entry.get(),
121 source_b = ?source,
122 "dependency declared against two different resolved registries; \
123 skipping version resolution for all occurrences"
124 );
125 }
126 }
127 }
128 }
129
130 for name in &collided {
131 by_name.remove(name);
132 }
133 (by_name, collided)
134}
135
136#[cfg(feature = "composer")]
154pub fn composer_minimum_stability(parse_result: &dyn deps_core::ParseResult) -> Option<String> {
155 parse_result
156 .as_any()
157 .downcast_ref::<crate::setup::ComposerParseResult>()
158 .and_then(|r| r.minimum_stability.clone())
159}
160
161#[cfg(not(feature = "composer"))]
164pub fn composer_minimum_stability(_parse_result: &dyn deps_core::ParseResult) -> Option<String> {
165 None
166}
167
168#[non_exhaustive]
170pub struct FetchResult {
171 pub versions: HashMap<PackageName, PackageVersions>,
173 pub yanked_versions: HashMap<PackageName, (ConcreteVersion, RemovalStatus)>,
182 pub deprecations: HashMap<PackageName, Deprecation>,
187 pub fetch_failed: HashMap<PackageName, FetchFailure>,
193 pub no_comparable_versions: HashSet<PackageName>,
200 pub failed_count: usize,
206 pub first_error: Option<String>,
208 pub licenses: HashMap<PackageName, Vec<String>>,
223}
224
225impl FetchResult {
226 #[must_use]
259 #[allow(clippy::too_many_arguments)]
260 pub fn new(
261 versions: HashMap<PackageName, PackageVersions>,
262 yanked_versions: HashMap<PackageName, (ConcreteVersion, RemovalStatus)>,
263 deprecations: HashMap<PackageName, Deprecation>,
264 fetch_failed: HashMap<PackageName, FetchFailure>,
265 no_comparable_versions: HashSet<PackageName>,
266 failed_count: usize,
267 first_error: Option<String>,
268 licenses: HashMap<PackageName, Vec<String>>,
269 ) -> Self {
270 Self {
271 versions,
272 yanked_versions,
273 deprecations,
274 fetch_failed,
275 no_comparable_versions,
276 failed_count,
277 first_error,
278 licenses,
279 }
280 }
281}
282
283#[allow(
411 clippy::too_many_arguments,
412 reason = "internal (non-pub) call-site-controlled fetch tuning + ecosystem-context \
413 parameters; grouping into a config struct would only move, not reduce, the \
414 per-call-site churn across this module's ~15 production and test call sites"
415)]
416pub async fn fetch_latest_versions_parallel(
417 registry: Arc<dyn Registry>,
418 package_sources: DepSources,
419 in_use: &HashMap<PackageName, Vec<String>>,
420 progress_sender: Option<ProgressSender>,
421 freshness: deps_core::freshness::FreshnessSettings,
422 timeout_secs: u64,
423 max_concurrent: usize,
424 minimum_stability: Option<&str>,
425) -> FetchResult {
426 use futures::stream::{self, StreamExt};
427 use std::time::Duration;
428
429 let fetched = Arc::new(std::sync::atomic::AtomicUsize::new(0));
430 let failed = Arc::new(std::sync::atomic::AtomicUsize::new(0));
431 let first_error: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
432 let timeout = Duration::from_secs(timeout_secs);
444 let wildcard_req = deps_core::VersionReq::new("*");
445 let check_yanked = registry.reports_yanked();
446
447 let results: Vec<_> = stream::iter(package_sources)
448 .map(|(name, source)| {
449 let registry = Arc::clone(®istry);
450 let fetched = Arc::clone(&fetched);
451 let failed = Arc::clone(&failed);
452 let first_error = Arc::clone(&first_error);
453 let progress_sender = progress_sender.clone();
454 let wildcard_req = &wildcard_req;
455 let in_use_versions = in_use.get(&name).cloned().unwrap_or_default();
456 async move {
457 fetch_and_classify_package(
458 registry.as_ref(),
459 name,
460 source,
461 in_use_versions,
462 wildcard_req,
463 freshness,
464 timeout,
465 minimum_stability,
466 check_yanked,
467 &fetched,
468 &failed,
469 &first_error,
470 progress_sender.as_ref(),
471 )
472 .await
473 }
474 })
475 .buffer_unordered(max_concurrent.max(1))
479 .collect()
480 .await;
481
482 let mut versions = HashMap::with_capacity(results.len());
483 let mut yanked_versions = HashMap::new();
484 let mut fetch_failed = HashMap::new();
485 let mut deprecations = HashMap::new();
486 let mut no_comparable_versions = HashSet::new();
487 let mut licenses = HashMap::new();
488 let mut priority_error: Option<String> = None;
492 for (version, yanked, failed_name, deprecation, no_comparable_versions_name, license) in results
493 {
494 if let Some((name, v)) = version {
495 versions.insert(name, v);
496 }
497 if let Some((name, v, status)) = yanked {
498 yanked_versions.insert(name, (v, status));
499 }
500 if let Some((name, failure, message)) = failed_name {
501 fetch_failed.insert(name, failure);
502 if priority_error.is_none() {
503 priority_error = Some(message);
504 }
505 }
506 if let Some((name, d)) = deprecation {
507 deprecations.insert(name, d);
508 }
509 if let Some(name) = no_comparable_versions_name {
510 no_comparable_versions.insert(name);
511 }
512 if let Some((name, license)) = license {
513 licenses.insert(name, license);
514 }
515 }
516
517 let error_message =
522 priority_error.or_else(|| first_error.lock().unwrap_or_else(|p| p.into_inner()).take());
523
524 FetchResult {
525 versions,
526 yanked_versions,
527 fetch_failed,
528 deprecations,
529 no_comparable_versions,
530 failed_count: failed.load(std::sync::atomic::Ordering::Relaxed),
531 first_error: error_message,
532 licenses,
533 }
534}
535
536type PackageFetchOutcome = (
550 Option<(PackageName, PackageVersions)>,
551 Option<(PackageName, ConcreteVersion, RemovalStatus)>,
552 Option<(PackageName, FetchFailure, String)>,
553 Option<(PackageName, Deprecation)>,
554 Option<PackageName>,
555 Option<(PackageName, Vec<String>)>,
556);
557
558#[allow(
565 clippy::too_many_arguments,
566 reason = "mirrors the per-package async closure this was extracted from — every \
567 parameter is either call-site fetch tuning already threaded through \
568 fetch_latest_versions_parallel or a counter/sender shared across the \
569 whole stream; grouping into a struct would only move, not reduce, churn"
570)]
571async fn fetch_and_classify_package(
572 registry: &dyn Registry,
573 name: PackageName,
574 source: deps_core::parser::DependencySource,
575 in_use_versions: Vec<String>,
576 wildcard_req: &VersionReq,
577 freshness: deps_core::freshness::FreshnessSettings,
578 timeout: Duration,
579 minimum_stability: Option<&str>,
580 check_yanked: bool,
581 fetched: &std::sync::atomic::AtomicUsize,
582 failed: &std::sync::atomic::AtomicUsize,
583 first_error: &std::sync::Mutex<Option<String>>,
584 progress_sender: Option<&ProgressSender>,
585) -> PackageFetchOutcome {
586 let result = tokio::time::timeout(
592 timeout,
593 registry.get_versions_from(&name, &source, freshness),
594 )
595 .await;
596
597 let mut yanked: Option<(PackageName, ConcreteVersion, RemovalStatus)> = None;
598 let mut failed_name: Option<(PackageName, FetchFailure, String)> = None;
599 let mut deprecation: Option<(PackageName, Deprecation)> = None;
600 let mut license: Option<(PackageName, Vec<String>)> = None;
601 let mut no_comparable_versions = false;
605 let version = match result {
606 Ok(Ok(versions)) => {
607 let available: Arc<[ConcreteVersion]> = versions
608 .iter()
609 .map(|v| v.version_string().clone())
610 .collect();
611 let yanked_list: Arc<[(ConcreteVersion, RemovalStatus)]> = if check_yanked {
618 versions
619 .iter()
620 .filter_map(|v| {
621 let status = v.removal_status();
622 status
623 .is_flagged()
624 .then(|| (v.version_string().clone(), status))
625 })
626 .collect()
627 } else {
628 Arc::from([])
629 };
630 let resolved = if let Some(v) = registry
635 .select_latest_matching_with_context(&versions, wildcard_req, minimum_stability)
636 .and_then(|idx| versions.get(idx))
637 {
638 let latest = v.version_string().clone();
639 tracing::debug!(package = %name, version = %latest, "fetched");
640 Some((
641 latest,
642 v.removal_status(),
643 v.published_at(),
644 v.deprecation().cloned(),
645 v.license().to_vec(),
646 ))
647 } else {
648 let fallback = tokio::time::timeout(
653 timeout,
654 registry.get_latest_matching_from(
655 &name,
656 &source,
657 wildcard_req,
658 minimum_stability,
659 ),
660 )
661 .await;
662 match fallback {
663 Ok(Ok(Some(v))) => {
664 let latest = v.version_string().clone();
665 tracing::debug!(
666 package = %name,
667 version = %latest,
668 "fetched via get_latest_matching fallback"
669 );
670 Some((
671 latest,
672 v.removal_status(),
673 v.published_at(),
674 v.deprecation().cloned(),
675 v.license().to_vec(),
676 ))
677 }
678 Ok(Ok(None)) => {
679 tracing::debug!(package = %name, "no version found");
680 no_comparable_versions = true;
685 None
686 }
687 Ok(Err(e)) => {
688 tracing::warn!(
689 package = %name,
690 error = %e,
691 "fetch fallback failed"
692 );
693 failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
694 let mut fe = first_error.lock().unwrap_or_else(|p| p.into_inner());
695 if fe.is_none() {
696 *fe = Some(e.to_string());
697 }
698 drop(fe);
699 if !e.is_not_found() {
704 failed_name = Some((name.clone(), e.fetch_failure(), e.to_string()));
705 }
706 None
707 }
708 Err(_) => {
709 tracing::warn!(
710 package = %name,
711 "fetch fallback timed out ({}s)",
712 timeout.as_secs()
713 );
714 failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
715 failed_name = Some((
716 name.clone(),
717 FetchFailure::Transient,
718 format!(
719 "{name}: registry request timed out after {}s",
720 timeout.as_secs()
721 ),
722 ));
723 None
724 }
725 }
726 };
727
728 if check_yanked {
729 if let Some((latest, status, _, _, _)) = &resolved
733 && status.is_flagged()
734 {
735 yanked = Some((name.clone(), latest.clone(), *status));
736 }
737
738 if let Some((iv, status)) = in_use_versions.iter().find_map(|iv| {
751 versions
752 .iter()
753 .find(|v| {
754 v.version_string() == iv.as_str() && v.removal_status().is_flagged()
755 })
756 .map(|v| (iv, v.removal_status()))
757 }) {
758 yanked = Some((name.clone(), iv.as_str().into(), status));
759 }
760 }
761
762 if let Some((_, _, _, dep_info, _)) = &resolved
767 && let Some(dep_info) = dep_info
768 {
769 deprecation = Some((name.clone(), dep_info.clone()));
770 }
771
772 license = resolved
776 .as_ref()
777 .map(|(_, _, _, _, lic)| lic)
778 .filter(|lic| !lic.is_empty())
779 .map(|lic| (name.clone(), lic.clone()));
780
781 resolved.map(|(latest, _, published_at, _, _)| {
782 let mut versions = PackageVersions::new(latest, available).with_yanked(yanked_list);
783 if let Some(published_at) = published_at {
784 versions = versions.with_published_at(published_at);
785 }
786 (name.clone(), versions)
787 })
788 }
789 Ok(Err(e)) => {
790 if e.is_offline() {
795 tracing::debug!(package = %name, "fetch skipped: offline");
796 } else {
797 tracing::warn!(package = %name, error = %e, "fetch failed");
798 }
799 failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
800 let mut fe = first_error.lock().unwrap_or_else(|p| p.into_inner());
801 if fe.is_none() {
802 *fe = Some(e.to_string());
803 }
804 drop(fe);
805 if !e.is_not_found() {
809 failed_name = Some((name.clone(), e.fetch_failure(), e.to_string()));
810 }
811 None
812 }
813 Err(_) => {
814 tracing::warn!(package = %name, "fetch timed out ({}s)", timeout.as_secs());
815 failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
816 failed_name = Some((
817 name.clone(),
818 FetchFailure::Transient,
819 format!(
820 "{name}: registry request timed out after {}s",
821 timeout.as_secs()
822 ),
823 ));
824 None
825 }
826 };
827
828 let count = fetched.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
829 if let Some(sender) = progress_sender {
830 sender.send(count);
831 }
832
833 let no_comparable_versions_name = no_comparable_versions.then(|| name.clone());
834 (
835 version,
836 yanked,
837 failed_name,
838 deprecation,
839 no_comparable_versions_name,
840 license,
841 )
842}
843
844pub fn apply_fetch_outcomes(
902 outcomes: &mut deps_core::lsp_helpers::DependencyOutcomes,
903 yanked_versions: HashMap<PackageName, (ConcreteVersion, RemovalStatus)>,
904 fetch_failed: HashMap<PackageName, FetchFailure>,
905 collided_names: HashSet<PackageName>,
906 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
907) {
908 for (name, version) in yanked_versions {
909 outcomes.set_yanked(formatter.normalize_package_name(&name), version);
910 }
911 for (name, failure) in fetch_failed {
912 outcomes.set_fetch_failure(formatter.normalize_package_name(&name), failure);
913 }
914 for name in collided_names {
915 outcomes.set_fetch_failure_if_absent(
916 formatter.normalize_package_name(&name),
917 FetchFailure::NotAttempted,
918 );
919 }
920}
921
922#[cfg(test)]
923mod tests {
924 use super::*;
925 use deps_core::parser::DependencySource;
926
927 fn with_registry_source(names: Vec<PackageName>) -> Vec<(PackageName, DependencySource)> {
932 names
933 .into_iter()
934 .map(|name| (name, DependencySource::Registry))
935 .collect()
936 }
937
938 #[cfg(feature = "cargo")]
939 mod apply_fetch_outcomes_tests {
940 use super::*;
941 use crate::setup::CargoFormatter;
942 use deps_core::lsp_helpers::DependencyOutcomes;
943
944 #[test]
945 fn apply_fetch_outcomes_sets_yanked_re_keyed_to_normalized_name() {
946 let mut outcomes = DependencyOutcomes::new();
947 let mut yanked_versions = HashMap::new();
948 yanked_versions.insert(
949 PackageName::new("time"),
950 ("0.1.43".into(), RemovalStatus::Yanked),
951 );
952
953 apply_fetch_outcomes(
954 &mut outcomes,
955 yanked_versions,
956 HashMap::new(),
957 HashSet::new(),
958 &CargoFormatter,
959 );
960
961 assert_eq!(
962 outcomes.yanked("time"),
963 Some(&("0.1.43".into(), RemovalStatus::Yanked))
964 );
965 }
966
967 #[test]
971 fn apply_fetch_outcomes_collided_name_does_not_clobber_existing_fetch_failure() {
972 let mut outcomes = DependencyOutcomes::new();
973 let mut fetch_failed = HashMap::new();
974 fetch_failed.insert(PackageName::new("serde"), FetchFailure::Transient);
975 let mut collided_names = HashSet::new();
976 collided_names.insert(PackageName::new("serde"));
977
978 apply_fetch_outcomes(
979 &mut outcomes,
980 HashMap::new(),
981 fetch_failed,
982 collided_names,
983 &CargoFormatter,
984 );
985
986 assert_eq!(
987 outcomes.fetch_failure("serde"),
988 Some(&FetchFailure::Transient),
989 "a genuine fetch failure must survive a collided name normalizing to the same key"
990 );
991 }
992
993 #[test]
996 fn apply_fetch_outcomes_collided_name_alone_is_recorded_as_not_attempted() {
997 let mut outcomes = DependencyOutcomes::new();
998 let mut collided_names = HashSet::new();
999 collided_names.insert(PackageName::new("serde"));
1000
1001 apply_fetch_outcomes(
1002 &mut outcomes,
1003 HashMap::new(),
1004 HashMap::new(),
1005 collided_names,
1006 &CargoFormatter,
1007 );
1008
1009 assert_eq!(
1010 outcomes.fetch_failure("serde"),
1011 Some(&FetchFailure::NotAttempted)
1012 );
1013 }
1014 }
1015
1016 mod dedup_by_source_collision_tests {
1017 use super::*;
1018 use deps_core::Dependency;
1019 use deps_core::lsp_helpers::{
1020 DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
1021 RequirementResolution, SourcePolicy,
1022 };
1023 use deps_core::position::{Position, Range};
1024 use std::any::Any;
1025
1026 struct AlternateAwareFormatter;
1030 impl PackageNaming for AlternateAwareFormatter {}
1031
1032 impl PackageRendering for AlternateAwareFormatter {
1033 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
1034 version.to_string()
1035 }
1036
1037 fn package_url(&self, name: &PackageName) -> String {
1038 format!("https://example.com/{name}")
1039 }
1040 }
1041
1042 impl RequirementResolution for AlternateAwareFormatter {}
1043
1044 impl DiagnosticMessages for AlternateAwareFormatter {}
1045
1046 impl DiagnosticPolicy for AlternateAwareFormatter {}
1047
1048 impl SourcePolicy for AlternateAwareFormatter {
1049 fn can_resolve_source(&self, source: &DependencySource) -> bool {
1050 matches!(
1051 source,
1052 DependencySource::Registry | DependencySource::AlternateRegistry { .. }
1053 )
1054 }
1055 }
1056
1057 impl OsvNaming for AlternateAwareFormatter {}
1058
1059 struct MockDep {
1060 name: PackageName,
1061 source: DependencySource,
1062 addr_tag: u32,
1063 }
1064
1065 impl Dependency for MockDep {
1066 fn name(&self) -> &PackageName {
1067 &self.name
1068 }
1069 fn name_range(&self) -> Range {
1070 Range::new(
1071 Position::new(0, self.addr_tag),
1072 Position::new(0, self.addr_tag + 1),
1073 )
1074 }
1075 fn version_requirement(&self) -> Option<&VersionReq> {
1076 None
1077 }
1078 fn version_range(&self) -> Option<Range> {
1079 None
1080 }
1081 fn source(&self) -> DependencySource {
1082 self.source.clone()
1083 }
1084 fn as_any(&self) -> &dyn Any {
1085 self
1086 }
1087 }
1088
1089 struct MockParseResult {
1090 deps: Vec<MockDep>,
1091 }
1092
1093 impl deps_core::ParseResult for MockParseResult {
1094 fn dependencies(&self) -> Vec<&dyn Dependency> {
1095 self.deps.iter().map(|d| d as &dyn Dependency).collect()
1096 }
1097 fn workspace_root(&self) -> Option<&std::path::Path> {
1098 None
1099 }
1100 fn uri(&self) -> &url::Url {
1101 static URI: std::sync::OnceLock<url::Url> = std::sync::OnceLock::new();
1102 URI.get_or_init(|| deps_core::test_util::test_uri("/test/Cargo.toml"))
1103 }
1104 fn as_any(&self) -> &dyn Any {
1105 self
1106 }
1107 }
1108
1109 #[test]
1110 fn test_two_different_resolvable_sources_collide_and_are_dropped() {
1111 let parse_result = MockParseResult {
1112 deps: vec![
1113 MockDep {
1114 name: PackageName::new("shared-name"),
1115 source: DependencySource::Registry,
1116 addr_tag: 0,
1117 },
1118 MockDep {
1119 name: PackageName::new("shared-name"),
1120 source: DependencySource::AlternateRegistry {
1121 index: "https://index.mycorp.dev".into(),
1122 mirrors_crates_io: false,
1123 },
1124 addr_tag: 1,
1125 },
1126 ],
1127 };
1128
1129 let (sources, collided) =
1130 dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
1131
1132 assert!(
1133 !sources.contains_key(&PackageName::new("shared-name")),
1134 "a colliding name must not be fetched under either source"
1135 );
1136 assert!(
1137 collided.contains(&PackageName::new("shared-name")),
1138 "the collision must be recorded so the caller can mark it fetch_failed"
1139 );
1140 }
1141
1142 #[test]
1143 fn test_identical_sources_do_not_collide() {
1144 let parse_result = MockParseResult {
1145 deps: vec![
1146 MockDep {
1147 name: PackageName::new("shared-name"),
1148 source: DependencySource::Registry,
1149 addr_tag: 0,
1150 },
1151 MockDep {
1152 name: PackageName::new("shared-name"),
1153 source: DependencySource::Registry,
1154 addr_tag: 1,
1155 },
1156 ],
1157 };
1158
1159 let (sources, collided) =
1160 dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
1161
1162 assert!(collided.is_empty());
1163 assert_eq!(
1164 sources.get(&PackageName::new("shared-name")),
1165 Some(&DependencySource::Registry)
1166 );
1167 }
1168
1169 #[test]
1174 fn test_non_resolvable_source_is_dropped_not_fetched() {
1175 let parse_result = MockParseResult {
1176 deps: vec![MockDep {
1177 name: PackageName::new("local-fork"),
1178 source: DependencySource::Path {
1179 path: "../local-fork".into(),
1180 },
1181 addr_tag: 0,
1182 }],
1183 };
1184
1185 let (sources, collided) =
1186 dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
1187
1188 assert!(sources.is_empty());
1189 assert!(collided.is_empty());
1190 }
1191
1192 #[test]
1200 fn test_collision_warning_redacts_credentials_in_alternate_registry_debug_output() {
1201 let parse_result = MockParseResult {
1202 deps: vec![
1203 MockDep {
1204 name: PackageName::new("shared-name"),
1205 source: DependencySource::AlternateRegistry {
1206 index: "https://index-a.mycorp.dev/api?api_key=SECRET_A".into(),
1207 mirrors_crates_io: false,
1208 },
1209 addr_tag: 0,
1210 },
1211 MockDep {
1212 name: PackageName::new("shared-name"),
1213 source: DependencySource::AlternateRegistry {
1214 index: "https://index-b.mycorp.dev/api?api_key=SECRET_B".into(),
1215 mirrors_crates_io: false,
1216 },
1217 addr_tag: 1,
1218 },
1219 ],
1220 };
1221
1222 let log = deps_core::test_util::capture_tracing_output(|| {
1223 let (sources, collided) =
1224 dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
1225 assert!(!sources.contains_key(&PackageName::new("shared-name")));
1226 assert!(collided.contains(&PackageName::new("shared-name")));
1227 });
1228
1229 assert!(
1230 log.contains("two different resolved registries"),
1231 "expected the collision WARN to fire: {log:?}"
1232 );
1233 assert!(
1234 !log.contains("SECRET_A") && !log.contains("SECRET_B"),
1235 "tracing output leaked a query-string credential: {log:?}"
1236 );
1237 assert!(
1238 log.contains("index-a.mycorp.dev") && log.contains("index-b.mycorp.dev"),
1239 "host should survive redaction: {log:?}"
1240 );
1241 }
1242 }
1243
1244 #[tokio::test]
1245 async fn test_fetch_latest_versions_parallel_with_timeout() {
1246 use deps_core::{Metadata, Registry, Version};
1247 use std::any::Any;
1248 use std::time::Duration;
1249
1250 struct TimeoutRegistry;
1251
1252 impl Registry for TimeoutRegistry {
1253 fn get_versions<'a>(
1254 &'a self,
1255 _name: &'a deps_core::PackageName,
1256 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1257 {
1258 Box::pin(async move {
1259 tokio::time::sleep(Duration::from_secs(10)).await;
1260 Ok(vec![])
1261 })
1262 }
1263
1264 fn get_latest_matching<'a>(
1265 &'a self,
1266 _name: &'a deps_core::PackageName,
1267 _req: &'a deps_core::VersionReq,
1268 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1269 {
1270 Box::pin(async move {
1271 tokio::time::sleep(Duration::from_secs(10)).await;
1272 Ok(None)
1273 })
1274 }
1275
1276 fn search<'a>(
1277 &'a self,
1278 _query: &'a str,
1279 _limit: usize,
1280 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1281 {
1282 Box::pin(async move { Ok(vec![]) })
1283 }
1284
1285 fn as_any(&self) -> &dyn Any {
1286 self
1287 }
1288 }
1289
1290 let registry: Arc<dyn Registry> = Arc::new(TimeoutRegistry);
1291 let packages = vec![PackageName::new("slow-package")];
1292
1293 let result = fetch_latest_versions_parallel(
1294 registry,
1295 with_registry_source(packages),
1296 &HashMap::new(),
1297 None,
1298 deps_core::freshness::FreshnessSettings::default(),
1299 1,
1300 10,
1301 None,
1302 )
1303 .await;
1304
1305 assert!(result.versions.is_empty(), "Slow package should timeout");
1306 assert_eq!(result.failed_count, 1, "Should track 1 failed package");
1307 assert_eq!(
1310 result.fetch_failed,
1311 HashMap::from([(PackageName::new("slow-package"), FetchFailure::Transient)]),
1312 "timed-out package must be recorded in fetch_failed"
1313 );
1314 }
1315
1316 #[tokio::test]
1317 async fn test_fetch_latest_versions_parallel_fast_packages_not_blocked() {
1318 use deps_core::{Metadata, Registry, Version};
1319 use std::any::Any;
1320 use std::time::Duration;
1321
1322 struct MixedRegistry;
1323
1324 impl Registry for MixedRegistry {
1325 fn get_versions<'a>(
1326 &'a self,
1327 name: &'a deps_core::PackageName,
1328 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1329 {
1330 Box::pin(async move {
1331 if name == "slow-package" {
1332 tokio::time::sleep(Duration::from_secs(10)).await;
1333 }
1334 Ok(vec![])
1335 })
1336 }
1337
1338 fn get_latest_matching<'a>(
1339 &'a self,
1340 name: &'a deps_core::PackageName,
1341 _req: &'a deps_core::VersionReq,
1342 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1343 {
1344 Box::pin(async move {
1345 if name == "slow-package" {
1346 tokio::time::sleep(Duration::from_secs(10)).await;
1347 }
1348 Ok(None)
1349 })
1350 }
1351
1352 fn search<'a>(
1353 &'a self,
1354 _query: &'a str,
1355 _limit: usize,
1356 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1357 {
1358 Box::pin(async move { Ok(vec![]) })
1359 }
1360
1361 fn as_any(&self) -> &dyn Any {
1362 self
1363 }
1364 }
1365
1366 let registry: Arc<dyn Registry> = Arc::new(MixedRegistry);
1367 let packages = vec![
1368 PackageName::new("slow-package"),
1369 PackageName::new("fast-package"),
1370 ];
1371
1372 let start = std::time::Instant::now();
1373 let result = fetch_latest_versions_parallel(
1374 registry,
1375 with_registry_source(packages),
1376 &HashMap::new(),
1377 None,
1378 deps_core::freshness::FreshnessSettings::default(),
1379 1,
1380 10,
1381 None,
1382 )
1383 .await;
1384 let elapsed = start.elapsed();
1385
1386 assert!(
1387 elapsed < Duration::from_secs(3),
1388 "Should not wait for slow package: {:?}",
1389 elapsed
1390 );
1391
1392 assert!(
1393 result.versions.is_empty(),
1394 "No versions returned (test registry returns empty)"
1395 );
1396 assert_eq!(
1397 result.failed_count, 1,
1398 "Slow package should be marked as failed"
1399 );
1400 }
1401
1402 #[tokio::test]
1403 async fn test_fetch_latest_versions_parallel_concurrency_limit() {
1404 use deps_core::{Metadata, Registry, Version};
1405 use std::any::Any;
1406 use std::sync::atomic::{AtomicUsize, Ordering};
1407 use std::time::Duration;
1408
1409 struct ConcurrencyTrackingRegistry {
1410 current: Arc<AtomicUsize>,
1411 max_seen: Arc<AtomicUsize>,
1412 }
1413
1414 impl Registry for ConcurrencyTrackingRegistry {
1415 fn get_versions<'a>(
1416 &'a self,
1417 _name: &'a deps_core::PackageName,
1418 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1419 {
1420 Box::pin(async move {
1421 let current = self.current.fetch_add(1, Ordering::SeqCst) + 1;
1422 self.max_seen.fetch_max(current, Ordering::SeqCst);
1423 tokio::time::sleep(Duration::from_millis(50)).await;
1424 self.current.fetch_sub(1, Ordering::SeqCst);
1425
1426 Ok(vec![])
1427 })
1428 }
1429
1430 fn get_latest_matching<'a>(
1431 &'a self,
1432 _name: &'a deps_core::PackageName,
1433 _req: &'a deps_core::VersionReq,
1434 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1435 {
1436 Box::pin(async move {
1437 let current = self.current.fetch_add(1, Ordering::SeqCst) + 1;
1438 self.max_seen.fetch_max(current, Ordering::SeqCst);
1439 tokio::time::sleep(Duration::from_millis(50)).await;
1440 self.current.fetch_sub(1, Ordering::SeqCst);
1441
1442 Ok(None)
1443 })
1444 }
1445
1446 fn search<'a>(
1447 &'a self,
1448 _query: &'a str,
1449 _limit: usize,
1450 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1451 {
1452 Box::pin(async move { Ok(vec![]) })
1453 }
1454
1455 fn as_any(&self) -> &dyn Any {
1456 self
1457 }
1458 }
1459
1460 let current = Arc::new(AtomicUsize::new(0));
1461 let max_seen = Arc::new(AtomicUsize::new(0));
1462
1463 let registry: Arc<dyn Registry> = Arc::new(ConcurrencyTrackingRegistry {
1464 current: Arc::clone(¤t),
1465 max_seen: Arc::clone(&max_seen),
1466 });
1467
1468 let packages: Vec<PackageName> = (0..50)
1469 .map(|i| PackageName::new(format!("package-{}", i)))
1470 .collect();
1471
1472 fetch_latest_versions_parallel(
1473 registry,
1474 with_registry_source(packages),
1475 &HashMap::new(),
1476 None,
1477 deps_core::freshness::FreshnessSettings::default(),
1478 5,
1479 20,
1480 None,
1481 )
1482 .await;
1483
1484 let max = max_seen.load(Ordering::SeqCst);
1486 assert!(
1487 max <= 22,
1488 "Concurrency limit violated: {} concurrent requests (limit: 20)",
1489 max
1490 );
1491 }
1492
1493 #[tokio::test]
1499 async fn test_fetch_latest_versions_parallel_zero_max_concurrent_still_completes() {
1500 use deps_core::{Metadata, Registry, Version};
1501 use std::any::Any;
1502
1503 struct InstantRegistry;
1504
1505 impl Registry for InstantRegistry {
1506 fn get_versions<'a>(
1507 &'a self,
1508 _name: &'a deps_core::PackageName,
1509 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1510 {
1511 Box::pin(async move { Ok(vec![]) })
1512 }
1513
1514 fn get_latest_matching<'a>(
1515 &'a self,
1516 _name: &'a deps_core::PackageName,
1517 _req: &'a deps_core::VersionReq,
1518 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1519 {
1520 Box::pin(async move { Ok(None) })
1521 }
1522
1523 fn search<'a>(
1524 &'a self,
1525 _query: &'a str,
1526 _limit: usize,
1527 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1528 {
1529 Box::pin(async move { Ok(vec![]) })
1530 }
1531
1532 fn as_any(&self) -> &dyn Any {
1533 self
1534 }
1535 }
1536
1537 let registry: Arc<dyn Registry> = Arc::new(InstantRegistry);
1538 let packages = vec![PackageName::new("some-package")];
1539
1540 let result = tokio::time::timeout(
1541 std::time::Duration::from_secs(5),
1542 fetch_latest_versions_parallel(
1543 registry,
1544 with_registry_source(packages),
1545 &HashMap::new(),
1546 None,
1547 deps_core::freshness::FreshnessSettings::default(),
1548 5,
1549 0,
1550 None,
1551 ),
1552 )
1553 .await
1554 .expect("fetch with max_concurrent=0 must not hang forever");
1555
1556 assert!(
1557 result
1558 .no_comparable_versions
1559 .contains(&PackageName::new("some-package")),
1560 "fetch must still run to completion when max_concurrent is 0"
1561 );
1562 }
1563
1564 #[tokio::test]
1565 async fn test_fetch_partial_success_with_mixed_outcomes() {
1566 use deps_core::{Metadata, Registry, Version};
1567 use std::any::Any;
1568 use std::time::Duration;
1569
1570 #[derive(Debug)]
1571 struct MockVersion {
1572 version: ConcreteVersion,
1573 }
1574
1575 impl Version for MockVersion {
1576 fn version_string(&self) -> &ConcreteVersion {
1577 &self.version
1578 }
1579
1580 fn is_prerelease(&self) -> bool {
1581 false
1582 }
1583
1584 fn as_any(&self) -> &dyn Any {
1585 self
1586 }
1587 }
1588
1589 struct MixedOutcomeRegistry;
1590
1591 impl Registry for MixedOutcomeRegistry {
1592 fn get_versions<'a>(
1593 &'a self,
1594 name: &'a deps_core::PackageName,
1595 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1596 {
1597 Box::pin(async move {
1598 match name.as_str() {
1599 "package-fast" => Ok(vec![Box::new(MockVersion {
1600 version: "1.0.0".into(),
1601 }) as Box<dyn Version>]),
1602 "package-slow" => {
1603 tokio::time::sleep(Duration::from_secs(10)).await;
1604 Ok(vec![])
1605 }
1606 "package-error" => Err(deps_core::error::DepsError::CacheError(
1607 "Mock registry error".to_string(),
1608 )),
1609 _ => Ok(vec![]),
1610 }
1611 })
1612 }
1613
1614 fn get_latest_matching<'a>(
1615 &'a self,
1616 name: &'a deps_core::PackageName,
1617 _req: &'a deps_core::VersionReq,
1618 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1619 {
1620 Box::pin(async move {
1621 match name.as_str() {
1622 "package-fast" => Ok(Some(Box::new(MockVersion {
1623 version: "1.0.0".into(),
1624 }) as Box<dyn Version>)),
1625 "package-slow" => {
1626 tokio::time::sleep(Duration::from_secs(10)).await;
1627 Ok(None)
1628 }
1629 "package-error" => Err(deps_core::error::DepsError::CacheError(
1630 "Mock registry error".to_string(),
1631 )),
1632 _ => Ok(None),
1633 }
1634 })
1635 }
1636
1637 fn search<'a>(
1638 &'a self,
1639 _query: &'a str,
1640 _limit: usize,
1641 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1642 {
1643 Box::pin(async move { Ok(vec![]) })
1644 }
1645
1646 fn select_latest_matching(
1647 &self,
1648 versions: &[Box<dyn Version>],
1649 _req: &deps_core::VersionReq,
1650 ) -> Option<usize> {
1651 if versions.is_empty() { None } else { Some(0) }
1655 }
1656
1657 fn as_any(&self) -> &dyn Any {
1658 self
1659 }
1660 }
1661
1662 let registry: Arc<dyn Registry> = Arc::new(MixedOutcomeRegistry);
1663 let packages = vec![
1664 PackageName::new("package-fast"),
1665 PackageName::new("package-slow"),
1666 PackageName::new("package-error"),
1667 ];
1668
1669 let result = fetch_latest_versions_parallel(
1670 registry,
1671 with_registry_source(packages),
1672 &HashMap::new(),
1673 None,
1674 deps_core::freshness::FreshnessSettings::default(),
1675 1,
1676 10,
1677 None,
1678 )
1679 .await;
1680
1681 assert_eq!(
1682 result.versions.len(),
1683 1,
1684 "Should have exactly 1 successful package"
1685 );
1686 assert_eq!(
1687 result
1688 .versions
1689 .get("package-fast")
1690 .map(|v| v.latest.as_str()),
1691 Some("1.0.0"),
1692 "Fast package should have correct version"
1693 );
1694 assert!(
1695 !result.versions.contains_key("package-slow"),
1696 "Slow package should not be in results (timeout)"
1697 );
1698 assert!(
1699 !result.versions.contains_key("package-error"),
1700 "Error package should not be in results"
1701 );
1702 }
1703
1704 #[tokio::test]
1709 async fn test_fetch_latest_versions_parallel_carries_yanked_flag_into_cache() {
1710 use deps_core::{Metadata, Registry, Version};
1711 use std::any::Any;
1712
1713 #[derive(Debug)]
1714 struct MockVersion {
1715 version: ConcreteVersion,
1716 yanked: bool,
1717 }
1718
1719 impl Version for MockVersion {
1720 fn version_string(&self) -> &ConcreteVersion {
1721 &self.version
1722 }
1723 fn removal_status(&self) -> deps_core::RemovalStatus {
1724 deps_core::RemovalStatus::from_yanked(self.yanked)
1725 }
1726 fn as_any(&self) -> &dyn Any {
1727 self
1728 }
1729 }
1730
1731 struct YankedRegistry;
1732
1733 impl Registry for YankedRegistry {
1734 fn get_versions<'a>(
1735 &'a self,
1736 _name: &'a deps_core::PackageName,
1737 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1738 {
1739 Box::pin(async move {
1740 Ok(vec![
1741 Box::new(MockVersion {
1742 version: "1.0.214".into(),
1743 yanked: false,
1744 }) as Box<dyn Version>,
1745 Box::new(MockVersion {
1746 version: "1.0.213".into(),
1747 yanked: true,
1748 }) as Box<dyn Version>,
1749 ])
1750 })
1751 }
1752
1753 fn get_latest_matching<'a>(
1754 &'a self,
1755 _name: &'a deps_core::PackageName,
1756 _req: &'a deps_core::VersionReq,
1757 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1758 {
1759 Box::pin(async move { Ok(None) })
1760 }
1761
1762 fn search<'a>(
1763 &'a self,
1764 _query: &'a str,
1765 _limit: usize,
1766 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1767 {
1768 Box::pin(async move { Ok(vec![]) })
1769 }
1770
1771 fn select_latest_matching(
1772 &self,
1773 versions: &[Box<dyn Version>],
1774 _req: &deps_core::VersionReq,
1775 ) -> Option<usize> {
1776 versions
1777 .iter()
1778 .position(|v| !v.removal_status().blocks_resolution())
1779 }
1780
1781 fn as_any(&self) -> &dyn Any {
1782 self
1783 }
1784 }
1785
1786 let registry: Arc<dyn Registry> = Arc::new(YankedRegistry);
1787 let packages = vec![PackageName::new("serde")];
1788
1789 let result = fetch_latest_versions_parallel(
1790 registry,
1791 with_registry_source(packages),
1792 &HashMap::new(),
1793 None,
1794 deps_core::freshness::FreshnessSettings::default(),
1795 10,
1796 10,
1797 None,
1798 )
1799 .await;
1800
1801 let serde = result
1802 .versions
1803 .get("serde")
1804 .expect("serde should be fetched");
1805 assert_eq!(serde.latest, "1.0.214", "latest must skip the yanked entry");
1806 assert_eq!(
1807 &*serde.available,
1808 &[
1809 ConcreteVersion::new("1.0.214"),
1810 ConcreteVersion::new("1.0.213")
1811 ],
1812 "available must remain unfiltered"
1813 );
1814 assert_eq!(
1815 &*serde.yanked,
1816 &[(
1817 ConcreteVersion::new("1.0.213"),
1818 deps_core::RemovalStatus::Yanked
1819 )],
1820 "yanked must carry only the entries reported as yanked, paired with their status"
1821 );
1822 }
1823
1824 #[tokio::test]
1831 async fn test_fetch_latest_versions_parallel_carries_published_at_for_latest_only() {
1832 use deps_core::freshness::PublishTime;
1833 use deps_core::{Metadata, Registry, Version};
1834 use std::any::Any;
1835
1836 #[derive(Debug)]
1837 struct MockVersion {
1838 version: ConcreteVersion,
1839 yanked: bool,
1840 published_at: Option<PublishTime>,
1841 }
1842
1843 impl Version for MockVersion {
1844 fn version_string(&self) -> &ConcreteVersion {
1845 &self.version
1846 }
1847 fn removal_status(&self) -> deps_core::RemovalStatus {
1848 deps_core::RemovalStatus::from_yanked(self.yanked)
1849 }
1850 fn published_at(&self) -> Option<PublishTime> {
1851 self.published_at
1852 }
1853 fn as_any(&self) -> &dyn Any {
1854 self
1855 }
1856 }
1857
1858 struct DatedRegistry;
1859
1860 impl Registry for DatedRegistry {
1861 fn get_versions<'a>(
1862 &'a self,
1863 _name: &'a deps_core::PackageName,
1864 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1865 {
1866 Box::pin(async move {
1867 Ok(vec![
1868 Box::new(MockVersion {
1869 version: "1.0.214".into(),
1870 yanked: false,
1871 published_at: Some(PublishTime::from_unix_secs(2_000)),
1872 }) as Box<dyn Version>,
1873 Box::new(MockVersion {
1874 version: "1.0.213".into(),
1875 yanked: true,
1876 published_at: Some(PublishTime::from_unix_secs(1_000)),
1879 }) as Box<dyn Version>,
1880 ])
1881 })
1882 }
1883
1884 fn get_latest_matching<'a>(
1885 &'a self,
1886 _name: &'a deps_core::PackageName,
1887 _req: &'a deps_core::VersionReq,
1888 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1889 {
1890 Box::pin(async move { Ok(None) })
1891 }
1892
1893 fn search<'a>(
1894 &'a self,
1895 _query: &'a str,
1896 _limit: usize,
1897 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1898 {
1899 Box::pin(async move { Ok(vec![]) })
1900 }
1901
1902 fn select_latest_matching(
1903 &self,
1904 versions: &[Box<dyn Version>],
1905 _req: &deps_core::VersionReq,
1906 ) -> Option<usize> {
1907 versions
1908 .iter()
1909 .position(|v| !v.removal_status().blocks_resolution())
1910 }
1911
1912 fn as_any(&self) -> &dyn Any {
1913 self
1914 }
1915 }
1916
1917 let registry: Arc<dyn Registry> = Arc::new(DatedRegistry);
1918 let packages = vec![PackageName::new("serde")];
1919
1920 let result = fetch_latest_versions_parallel(
1921 registry,
1922 with_registry_source(packages),
1923 &HashMap::new(),
1924 None,
1925 deps_core::freshness::FreshnessSettings::default(),
1926 10,
1927 10,
1928 None,
1929 )
1930 .await;
1931
1932 let serde = result
1933 .versions
1934 .get("serde")
1935 .expect("serde should be fetched");
1936 assert_eq!(serde.latest, "1.0.214");
1937 assert_eq!(
1938 serde.published_at,
1939 Some(PublishTime::from_unix_secs(2_000)),
1940 "published_at must be 1.0.214's own timestamp, not the yanked 1.0.213 entry's"
1941 );
1942 }
1943
1944 #[tokio::test]
1954 async fn test_fetch_latest_versions_parallel_carries_license_into_fetch_result() {
1955 use deps_core::{Metadata, Registry, Version};
1956 use std::any::Any;
1957
1958 #[derive(Debug)]
1959 struct MockVersion {
1960 version: ConcreteVersion,
1961 license: Vec<String>,
1962 }
1963
1964 impl Version for MockVersion {
1965 fn version_string(&self) -> &ConcreteVersion {
1966 &self.version
1967 }
1968 fn as_any(&self) -> &dyn Any {
1969 self
1970 }
1971 fn license(&self) -> &[String] {
1972 &self.license
1973 }
1974 }
1975
1976 struct LicensedRegistry;
1977
1978 impl Registry for LicensedRegistry {
1979 fn get_versions<'a>(
1980 &'a self,
1981 name: &'a PackageName,
1982 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1983 {
1984 let license = if name.as_str() == "licensed-pkg" {
1985 vec!["MIT".to_string()]
1986 } else {
1987 vec![]
1988 };
1989 Box::pin(async move {
1990 Ok(vec![Box::new(MockVersion {
1991 version: "1.0.0".into(),
1992 license,
1993 }) as Box<dyn Version>])
1994 })
1995 }
1996
1997 fn get_latest_matching<'a>(
1998 &'a self,
1999 _name: &'a PackageName,
2000 _req: &'a deps_core::VersionReq,
2001 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2002 {
2003 Box::pin(async move { Ok(None) })
2004 }
2005
2006 fn search<'a>(
2007 &'a self,
2008 _query: &'a str,
2009 _limit: usize,
2010 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2011 {
2012 Box::pin(async move { Ok(vec![]) })
2013 }
2014
2015 fn select_latest_matching(
2016 &self,
2017 versions: &[Box<dyn Version>],
2018 _req: &deps_core::VersionReq,
2019 ) -> Option<usize> {
2020 (!versions.is_empty()).then_some(0)
2021 }
2022
2023 fn as_any(&self) -> &dyn Any {
2024 self
2025 }
2026 }
2027
2028 let registry: Arc<dyn Registry> = Arc::new(LicensedRegistry);
2029 let packages = vec![
2030 PackageName::new("licensed-pkg"),
2031 PackageName::new("unlicensed-pkg"),
2032 ];
2033
2034 let result = fetch_latest_versions_parallel(
2035 registry,
2036 with_registry_source(packages),
2037 &HashMap::new(),
2038 None,
2039 deps_core::freshness::FreshnessSettings::default(),
2040 10,
2041 10,
2042 None,
2043 )
2044 .await;
2045
2046 assert_eq!(
2047 result.licenses.get(&PackageName::new("licensed-pkg")),
2048 Some(&vec!["MIT".to_string()])
2049 );
2050 assert!(
2051 !result
2052 .licenses
2053 .contains_key(&PackageName::new("unlicensed-pkg")),
2054 "an empty Version::license() must produce no entry, not an empty-vec one"
2055 );
2056 }
2057
2058 #[tokio::test]
2064 async fn test_fetch_latest_versions_parallel_uses_get_versions_with_for_freshness() {
2065 use deps_core::freshness::{FreshnessSettings, PublishTime};
2066 use deps_core::{Metadata, Registry, Version};
2067 use std::any::Any;
2068
2069 #[derive(Debug)]
2070 struct MockVersion {
2071 version: ConcreteVersion,
2072 published_at: Option<PublishTime>,
2073 }
2074
2075 impl Version for MockVersion {
2076 fn version_string(&self) -> &ConcreteVersion {
2077 &self.version
2078 }
2079 fn published_at(&self) -> Option<PublishTime> {
2080 self.published_at
2081 }
2082 fn as_any(&self) -> &dyn Any {
2083 self
2084 }
2085 }
2086
2087 struct FreshnessAwareRegistry;
2088
2089 impl Registry for FreshnessAwareRegistry {
2090 fn get_versions<'a>(
2091 &'a self,
2092 _name: &'a deps_core::PackageName,
2093 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2094 {
2095 Box::pin(async move {
2098 Ok(vec![Box::new(MockVersion {
2099 version: "1.0.0".into(),
2100 published_at: None,
2101 }) as Box<dyn Version>])
2102 })
2103 }
2104
2105 fn get_versions_with<'a>(
2106 &'a self,
2107 _name: &'a deps_core::PackageName,
2108 freshness: FreshnessSettings,
2109 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2110 {
2111 Box::pin(async move {
2112 Ok(vec![Box::new(MockVersion {
2113 version: "1.0.0".into(),
2114 published_at: freshness
2115 .enabled
2116 .then(|| PublishTime::from_unix_secs(5_000)),
2117 }) as Box<dyn Version>])
2118 })
2119 }
2120
2121 fn get_latest_matching<'a>(
2122 &'a self,
2123 _name: &'a deps_core::PackageName,
2124 _req: &'a deps_core::VersionReq,
2125 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2126 {
2127 Box::pin(async move { Ok(None) })
2128 }
2129
2130 fn search<'a>(
2131 &'a self,
2132 _query: &'a str,
2133 _limit: usize,
2134 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2135 {
2136 Box::pin(async move { Ok(vec![]) })
2137 }
2138
2139 fn select_latest_matching(
2140 &self,
2141 versions: &[Box<dyn Version>],
2142 _req: &deps_core::VersionReq,
2143 ) -> Option<usize> {
2144 if versions.is_empty() { None } else { Some(0) }
2145 }
2146
2147 fn as_any(&self) -> &dyn Any {
2148 self
2149 }
2150 }
2151
2152 let registry: Arc<dyn Registry> = Arc::new(FreshnessAwareRegistry);
2153 let packages = vec![PackageName::new("widget")];
2154
2155 let result = fetch_latest_versions_parallel(
2156 registry,
2157 with_registry_source(packages),
2158 &HashMap::new(),
2159 None,
2160 FreshnessSettings::default(),
2161 10,
2162 10,
2163 None,
2164 )
2165 .await;
2166
2167 let widget = result
2168 .versions
2169 .get("widget")
2170 .expect("widget should be fetched");
2171 assert_eq!(
2172 widget.published_at,
2173 Some(PublishTime::from_unix_secs(5_000)),
2174 "published_at must come from get_versions_with, not the freshness-blind \
2175 get_versions (#339)"
2176 );
2177 }
2178
2179 #[tokio::test]
2185 async fn test_fetch_latest_versions_parallel_threads_minimum_stability_into_select_latest_matching_with_context()
2186 {
2187 use deps_core::{Metadata, Registry, Version};
2188 use std::any::Any;
2189 use std::sync::Mutex;
2190
2191 #[derive(Debug)]
2192 struct MockVersion {
2193 version: ConcreteVersion,
2194 }
2195
2196 impl Version for MockVersion {
2197 fn version_string(&self) -> &ConcreteVersion {
2198 &self.version
2199 }
2200 fn as_any(&self) -> &dyn Any {
2201 self
2202 }
2203 }
2204
2205 struct ContextAwareRegistry {
2206 seen_minimum_stability: Mutex<Vec<Option<String>>>,
2209 }
2210
2211 impl Registry for ContextAwareRegistry {
2212 fn get_versions<'a>(
2213 &'a self,
2214 _name: &'a deps_core::PackageName,
2215 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2216 {
2217 Box::pin(async move {
2218 Ok(vec![Box::new(MockVersion {
2219 version: "1.0.0".into(),
2220 }) as Box<dyn Version>])
2221 })
2222 }
2223
2224 fn get_latest_matching<'a>(
2225 &'a self,
2226 _name: &'a deps_core::PackageName,
2227 _req: &'a deps_core::VersionReq,
2228 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2229 {
2230 Box::pin(async move { Ok(None) })
2231 }
2232
2233 fn search<'a>(
2234 &'a self,
2235 _query: &'a str,
2236 _limit: usize,
2237 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2238 {
2239 Box::pin(async move { Ok(vec![]) })
2240 }
2241
2242 fn select_latest_matching_with_context(
2247 &self,
2248 versions: &[Box<dyn Version>],
2249 _req: &deps_core::VersionReq,
2250 minimum_stability: Option<&str>,
2251 ) -> Option<usize> {
2252 self.seen_minimum_stability
2253 .lock()
2254 .unwrap_or_else(|p| p.into_inner())
2255 .push(minimum_stability.map(str::to_string));
2256 if versions.is_empty() { None } else { Some(0) }
2257 }
2258
2259 fn as_any(&self) -> &dyn Any {
2260 self
2261 }
2262 }
2263
2264 let registry = Arc::new(ContextAwareRegistry {
2265 seen_minimum_stability: Mutex::new(Vec::new()),
2266 });
2267 let packages = vec![PackageName::new("vendor/pkg")];
2268
2269 let result = fetch_latest_versions_parallel(
2270 Arc::clone(®istry) as Arc<dyn Registry>,
2271 with_registry_source(packages),
2272 &HashMap::new(),
2273 None,
2274 deps_core::freshness::FreshnessSettings::default(),
2275 10,
2276 10,
2277 Some("beta"),
2278 )
2279 .await;
2280
2281 assert_eq!(
2282 *registry
2283 .seen_minimum_stability
2284 .lock()
2285 .unwrap_or_else(|p| p.into_inner()),
2286 vec![Some("beta".to_string())],
2287 "select_latest_matching_with_context must receive the caller's minimum_stability"
2288 );
2289 assert!(
2290 result.versions.contains_key("vendor/pkg"),
2291 "the pick must still succeed via the _with_context path"
2292 );
2293 }
2294
2295 #[tokio::test]
2299 async fn test_fetch_latest_versions_parallel_threads_minimum_stability_into_get_latest_matching_with_context()
2300 {
2301 use deps_core::{Metadata, Registry, Version};
2302 use std::any::Any;
2303 use std::sync::Mutex;
2304
2305 #[derive(Debug)]
2306 struct MockVersion {
2307 version: ConcreteVersion,
2308 }
2309
2310 impl Version for MockVersion {
2311 fn version_string(&self) -> &ConcreteVersion {
2312 &self.version
2313 }
2314 fn as_any(&self) -> &dyn Any {
2315 self
2316 }
2317 }
2318
2319 struct FallbackContextAwareRegistry {
2320 seen_minimum_stability: Mutex<Vec<Option<String>>>,
2323 }
2324
2325 impl Registry for FallbackContextAwareRegistry {
2326 fn get_versions<'a>(
2327 &'a self,
2328 _name: &'a deps_core::PackageName,
2329 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2330 {
2331 Box::pin(async move { Ok(vec![]) })
2334 }
2335
2336 fn get_latest_matching<'a>(
2337 &'a self,
2338 _name: &'a deps_core::PackageName,
2339 _req: &'a deps_core::VersionReq,
2340 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2341 {
2342 Box::pin(async move { Ok(None) })
2343 }
2344
2345 fn get_latest_matching_with_context<'a>(
2346 &'a self,
2347 _name: &'a deps_core::PackageName,
2348 _req: &'a deps_core::VersionReq,
2349 minimum_stability: Option<&'a str>,
2350 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2351 {
2352 self.seen_minimum_stability
2353 .lock()
2354 .unwrap_or_else(|p| p.into_inner())
2355 .push(minimum_stability.map(str::to_string));
2356 Box::pin(async move {
2357 Ok(Some(Box::new(MockVersion {
2358 version: "2.0.0-beta1".into(),
2359 }) as Box<dyn Version>))
2360 })
2361 }
2362
2363 fn search<'a>(
2364 &'a self,
2365 _query: &'a str,
2366 _limit: usize,
2367 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2368 {
2369 Box::pin(async move { Ok(vec![]) })
2370 }
2371
2372 fn as_any(&self) -> &dyn Any {
2373 self
2374 }
2375 }
2376
2377 let registry = Arc::new(FallbackContextAwareRegistry {
2378 seen_minimum_stability: Mutex::new(Vec::new()),
2379 });
2380 let packages = vec![PackageName::new("vendor/pkg")];
2381
2382 let result = fetch_latest_versions_parallel(
2383 Arc::clone(®istry) as Arc<dyn Registry>,
2384 with_registry_source(packages),
2385 &HashMap::new(),
2386 None,
2387 deps_core::freshness::FreshnessSettings::default(),
2388 10,
2389 10,
2390 Some("beta"),
2391 )
2392 .await;
2393
2394 assert_eq!(
2395 *registry
2396 .seen_minimum_stability
2397 .lock()
2398 .unwrap_or_else(|p| p.into_inner()),
2399 vec![Some("beta".to_string())],
2400 "get_latest_matching_with_context must receive the caller's minimum_stability"
2401 );
2402 let widget = result
2403 .versions
2404 .get("vendor/pkg")
2405 .expect("fallback pick should succeed");
2406 assert_eq!(widget.latest, "2.0.0-beta1");
2407 }
2408
2409 #[tokio::test]
2415 async fn test_fetch_falls_back_to_get_latest_matching_when_list_based_pick_finds_nothing() {
2416 use deps_core::{Metadata, Registry, Version};
2417 use std::any::Any;
2418
2419 #[derive(Debug)]
2420 struct MockVersion {
2421 version: ConcreteVersion,
2422 }
2423
2424 impl Version for MockVersion {
2425 fn version_string(&self) -> &ConcreteVersion {
2426 &self.version
2427 }
2428 fn as_any(&self) -> &dyn Any {
2429 self
2430 }
2431 }
2432
2433 struct UntaggedModuleRegistry;
2439
2440 impl Registry for UntaggedModuleRegistry {
2441 fn get_versions<'a>(
2442 &'a self,
2443 _name: &'a deps_core::PackageName,
2444 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2445 {
2446 Box::pin(async move { Ok(vec![]) })
2447 }
2448
2449 fn get_latest_matching<'a>(
2450 &'a self,
2451 _name: &'a deps_core::PackageName,
2452 _req: &'a deps_core::VersionReq,
2453 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2454 {
2455 Box::pin(async move {
2456 Ok(Some(Box::new(MockVersion {
2457 version: "v0.0.0-20191109021931-daa7c04131f5".into(),
2458 }) as Box<dyn Version>))
2459 })
2460 }
2461
2462 fn search<'a>(
2463 &'a self,
2464 _query: &'a str,
2465 _limit: usize,
2466 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2467 {
2468 Box::pin(async move { Ok(vec![]) })
2469 }
2470
2471 fn as_any(&self) -> &dyn Any {
2472 self
2473 }
2474 }
2475
2476 let registry: Arc<dyn Registry> = Arc::new(UntaggedModuleRegistry);
2477 let packages = vec![PackageName::new("golang.org/x/exp")];
2478
2479 let result = fetch_latest_versions_parallel(
2480 registry,
2481 with_registry_source(packages),
2482 &HashMap::new(),
2483 None,
2484 deps_core::freshness::FreshnessSettings::default(),
2485 5,
2486 10,
2487 None,
2488 )
2489 .await;
2490
2491 assert_eq!(
2492 result
2493 .versions
2494 .get("golang.org/x/exp")
2495 .map(|v| v.latest.as_str()),
2496 Some("v0.0.0-20191109021931-daa7c04131f5"),
2497 "must fall back to get_latest_matching instead of reporting no version found"
2498 );
2499 }
2500
2501 #[tokio::test]
2502 async fn test_fetch_registry_error_handled() {
2503 use deps_core::{Metadata, Registry, Version};
2504 use std::any::Any;
2505
2506 struct ErrorRegistry;
2507
2508 impl Registry for ErrorRegistry {
2509 fn get_versions<'a>(
2510 &'a self,
2511 name: &'a deps_core::PackageName,
2512 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2513 {
2514 Box::pin(async move {
2515 Err(deps_core::error::DepsError::CacheError(format!(
2516 "Failed to fetch package: {}",
2517 name
2518 )))
2519 })
2520 }
2521
2522 fn get_latest_matching<'a>(
2523 &'a self,
2524 name: &'a deps_core::PackageName,
2525 _req: &'a deps_core::VersionReq,
2526 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2527 {
2528 Box::pin(async move {
2529 Err(deps_core::error::DepsError::CacheError(format!(
2530 "Failed to fetch package: {}",
2531 name
2532 )))
2533 })
2534 }
2535
2536 fn search<'a>(
2537 &'a self,
2538 _query: &'a str,
2539 _limit: usize,
2540 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2541 {
2542 Box::pin(async move { Ok(vec![]) })
2543 }
2544
2545 fn as_any(&self) -> &dyn Any {
2546 self
2547 }
2548 }
2549
2550 let registry: Arc<dyn Registry> = Arc::new(ErrorRegistry);
2551 let packages = vec![
2552 PackageName::new("package-1"),
2553 PackageName::new("package-2"),
2554 PackageName::new("package-3"),
2555 ];
2556
2557 let result = fetch_latest_versions_parallel(
2558 registry,
2559 with_registry_source(packages),
2560 &HashMap::new(),
2561 None,
2562 deps_core::freshness::FreshnessSettings::default(),
2563 5,
2564 10,
2565 None,
2566 )
2567 .await;
2568
2569 assert!(
2570 result.versions.is_empty(),
2571 "All packages with errors should be omitted from results"
2572 );
2573 assert_eq!(
2574 result.failed_count, 3,
2575 "All 3 packages should be marked as failed"
2576 );
2577 assert_eq!(
2581 result.fetch_failed,
2582 HashMap::from([
2583 (PackageName::new("package-1"), FetchFailure::Transient),
2584 (PackageName::new("package-2"), FetchFailure::Transient),
2585 (PackageName::new("package-3"), FetchFailure::Transient),
2586 ]),
2587 "every errored package must be recorded in fetch_failed"
2588 );
2589 }
2590
2591 #[tokio::test]
2592 async fn test_fetch_not_found_is_not_recorded_as_fetch_failed() {
2593 use deps_core::{Metadata, Registry, Version};
2601 use std::any::Any;
2602
2603 struct NotFoundRegistry;
2604
2605 impl Registry for NotFoundRegistry {
2606 fn get_versions<'a>(
2607 &'a self,
2608 name: &'a deps_core::PackageName,
2609 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2610 {
2611 Box::pin(async move {
2612 Err(deps_core::error::DepsError::PackageNotFound {
2613 package: name.to_string(),
2614 registry: "mock",
2615 })
2616 })
2617 }
2618
2619 fn get_latest_matching<'a>(
2620 &'a self,
2621 name: &'a deps_core::PackageName,
2622 _req: &'a deps_core::VersionReq,
2623 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2624 {
2625 Box::pin(async move {
2626 Err(deps_core::error::DepsError::PackageNotFound {
2627 package: name.to_string(),
2628 registry: "mock",
2629 })
2630 })
2631 }
2632
2633 fn search<'a>(
2634 &'a self,
2635 _query: &'a str,
2636 _limit: usize,
2637 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2638 {
2639 Box::pin(async move { Ok(vec![]) })
2640 }
2641
2642 fn as_any(&self) -> &dyn Any {
2643 self
2644 }
2645 }
2646
2647 let registry: Arc<dyn Registry> = Arc::new(NotFoundRegistry);
2648 let packages = vec![PackageName::new("typo-pkg")];
2649
2650 let result = fetch_latest_versions_parallel(
2651 registry,
2652 with_registry_source(packages),
2653 &HashMap::new(),
2654 None,
2655 deps_core::freshness::FreshnessSettings::default(),
2656 5,
2657 10,
2658 None,
2659 )
2660 .await;
2661
2662 assert!(result.versions.is_empty());
2663 assert!(
2664 result.fetch_failed.is_empty(),
2665 "a genuine not-found must not be recorded in fetch_failed, or \
2666 generate_diagnostics_from_cache would report it as a registry \
2667 error instead of Unknown package"
2668 );
2669 }
2670
2671 #[tokio::test]
2679 async fn test_fetch_success_with_zero_versions_is_recorded_as_no_comparable_versions() {
2680 use deps_core::{Metadata, Registry, Version};
2681 use std::any::Any;
2682
2683 struct EmptyButRealRegistry;
2684
2685 impl Registry for EmptyButRealRegistry {
2686 fn get_versions<'a>(
2687 &'a self,
2688 _name: &'a deps_core::PackageName,
2689 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2690 {
2691 Box::pin(async move { Ok(vec![]) })
2692 }
2693
2694 fn get_latest_matching<'a>(
2695 &'a self,
2696 _name: &'a deps_core::PackageName,
2697 _req: &'a deps_core::VersionReq,
2698 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2699 {
2700 Box::pin(async move { Ok(None) })
2701 }
2702
2703 fn search<'a>(
2704 &'a self,
2705 _query: &'a str,
2706 _limit: usize,
2707 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2708 {
2709 Box::pin(async move { Ok(vec![]) })
2710 }
2711
2712 fn as_any(&self) -> &dyn Any {
2713 self
2714 }
2715 }
2716
2717 let registry: Arc<dyn Registry> = Arc::new(EmptyButRealRegistry);
2718 let packages = vec![PackageName::new("dtolnay/rust-toolchain")];
2719
2720 let result = fetch_latest_versions_parallel(
2721 registry,
2722 with_registry_source(packages),
2723 &HashMap::new(),
2724 None,
2725 deps_core::freshness::FreshnessSettings::default(),
2726 5,
2727 10,
2728 None,
2729 )
2730 .await;
2731
2732 assert!(result.versions.is_empty());
2733 assert!(
2734 result.fetch_failed.is_empty(),
2735 "a genuine empty-but-successful fetch must not be recorded as a fetch \
2736 failure, or generate_diagnostics_from_cache would report a registry \
2737 error instead of nothing"
2738 );
2739 assert!(
2740 result
2741 .no_comparable_versions
2742 .contains(&PackageName::new("dtolnay/rust-toolchain")),
2743 "a package whose fetch succeeded with zero comparable versions must be \
2744 recorded in no_comparable_versions, or R5 would misreport it as Unknown \
2745 package; got: {:?}",
2746 result.no_comparable_versions
2747 );
2748 }
2749
2750 #[tokio::test]
2751 async fn test_fetch_http_404_is_not_recorded_as_fetch_failed() {
2752 use deps_core::{Metadata, Registry, Version};
2757 use std::any::Any;
2758
2759 struct Http404Registry;
2760
2761 impl Registry for Http404Registry {
2762 fn get_versions<'a>(
2763 &'a self,
2764 name: &'a deps_core::PackageName,
2765 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2766 {
2767 Box::pin(async move {
2768 Err(deps_core::error::DepsError::HttpStatus {
2769 url: format!("https://example.com/{name}").into(),
2770 status: 404,
2771 })
2772 })
2773 }
2774
2775 fn get_latest_matching<'a>(
2776 &'a self,
2777 name: &'a deps_core::PackageName,
2778 _req: &'a deps_core::VersionReq,
2779 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2780 {
2781 Box::pin(async move {
2782 Err(deps_core::error::DepsError::HttpStatus {
2783 url: format!("https://example.com/{name}").into(),
2784 status: 404,
2785 })
2786 })
2787 }
2788
2789 fn search<'a>(
2790 &'a self,
2791 _query: &'a str,
2792 _limit: usize,
2793 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2794 {
2795 Box::pin(async move { Ok(vec![]) })
2796 }
2797
2798 fn as_any(&self) -> &dyn Any {
2799 self
2800 }
2801 }
2802
2803 let registry: Arc<dyn Registry> = Arc::new(Http404Registry);
2804 let packages = vec![PackageName::new("typo-pkg")];
2805
2806 let result = fetch_latest_versions_parallel(
2807 registry,
2808 with_registry_source(packages),
2809 &HashMap::new(),
2810 None,
2811 deps_core::freshness::FreshnessSettings::default(),
2812 5,
2813 10,
2814 None,
2815 )
2816 .await;
2817
2818 assert!(result.versions.is_empty());
2819 assert!(
2820 result.fetch_failed.is_empty(),
2821 "a bare HTTP 404 must not be recorded in fetch_failed either"
2822 );
2823 }
2824
2825 #[tokio::test]
2826 async fn test_fetch_fallback_error_recorded_as_fetch_failed_unless_not_found() {
2827 use deps_core::{Metadata, Registry, Version};
2834 use std::any::Any;
2835
2836 struct FallbackErrorRegistry;
2837
2838 impl Registry for FallbackErrorRegistry {
2839 fn get_versions<'a>(
2840 &'a self,
2841 _name: &'a deps_core::PackageName,
2842 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2843 {
2844 Box::pin(async move { Ok(vec![]) })
2845 }
2846
2847 fn get_latest_matching<'a>(
2848 &'a self,
2849 name: &'a deps_core::PackageName,
2850 _req: &'a deps_core::VersionReq,
2851 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2852 {
2853 let name = name.clone();
2854 Box::pin(async move {
2855 if name.as_str() == "not-found" {
2856 Err(deps_core::error::DepsError::PackageNotFound {
2857 package: name.to_string(),
2858 registry: "mock",
2859 })
2860 } else {
2861 Err(deps_core::error::DepsError::CacheError(
2862 "mock fallback failure".to_string(),
2863 ))
2864 }
2865 })
2866 }
2867
2868 fn search<'a>(
2869 &'a self,
2870 _query: &'a str,
2871 _limit: usize,
2872 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2873 {
2874 Box::pin(async move { Ok(vec![]) })
2875 }
2876
2877 fn as_any(&self) -> &dyn Any {
2878 self
2879 }
2880 }
2881
2882 let registry: Arc<dyn Registry> = Arc::new(FallbackErrorRegistry);
2883 let packages = vec![PackageName::new("flaky"), PackageName::new("not-found")];
2884
2885 let result = fetch_latest_versions_parallel(
2886 registry,
2887 with_registry_source(packages),
2888 &HashMap::new(),
2889 None,
2890 deps_core::freshness::FreshnessSettings::default(),
2891 5,
2892 10,
2893 None,
2894 )
2895 .await;
2896
2897 assert!(result.versions.is_empty());
2898 assert_eq!(
2899 result.fetch_failed,
2900 HashMap::from([(PackageName::new("flaky"), FetchFailure::Transient)]),
2901 "the fallback's own non-not-found error must be recorded in fetch_failed, \
2902 but its not-found error must not"
2903 );
2904 assert_eq!(
2905 result.failed_count, 2,
2906 "both fallback failures count toward failed_count regardless of cause (S2)"
2907 );
2908 }
2909
2910 #[tokio::test]
2911 async fn test_fetch_fallback_timeout_recorded_as_fetch_failed() {
2912 use deps_core::{Metadata, Registry, Version};
2916 use std::any::Any;
2917 use std::time::Duration;
2918
2919 struct FallbackTimeoutRegistry;
2920
2921 impl Registry for FallbackTimeoutRegistry {
2922 fn get_versions<'a>(
2923 &'a self,
2924 _name: &'a deps_core::PackageName,
2925 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2926 {
2927 Box::pin(async move { Ok(vec![]) })
2928 }
2929
2930 fn get_latest_matching<'a>(
2931 &'a self,
2932 _name: &'a deps_core::PackageName,
2933 _req: &'a deps_core::VersionReq,
2934 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2935 {
2936 Box::pin(async move {
2937 tokio::time::sleep(Duration::from_secs(10)).await;
2938 Ok(None)
2939 })
2940 }
2941
2942 fn search<'a>(
2943 &'a self,
2944 _query: &'a str,
2945 _limit: usize,
2946 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2947 {
2948 Box::pin(async move { Ok(vec![]) })
2949 }
2950
2951 fn as_any(&self) -> &dyn Any {
2952 self
2953 }
2954 }
2955
2956 let registry: Arc<dyn Registry> = Arc::new(FallbackTimeoutRegistry);
2957 let packages = vec![PackageName::new("slow-fallback")];
2958
2959 let result = fetch_latest_versions_parallel(
2960 registry,
2961 with_registry_source(packages),
2962 &HashMap::new(),
2963 None,
2964 deps_core::freshness::FreshnessSettings::default(),
2965 1,
2966 10,
2967 None,
2968 )
2969 .await;
2970
2971 assert!(result.versions.is_empty());
2972 assert_eq!(
2973 result.fetch_failed,
2974 HashMap::from([(PackageName::new("slow-fallback"), FetchFailure::Transient)])
2975 );
2976 assert_eq!(result.failed_count, 1);
2977 }
2978
2979 #[tokio::test]
2980 async fn test_first_error_prefers_actionable_error_over_not_found_regardless_of_race_order() {
2981 use deps_core::{Metadata, Registry, Version};
2986 use std::any::Any;
2987 use std::time::Duration;
2988
2989 struct MixedErrorRegistry;
2990
2991 impl Registry for MixedErrorRegistry {
2992 fn get_versions<'a>(
2993 &'a self,
2994 name: &'a deps_core::PackageName,
2995 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2996 {
2997 Box::pin(async move {
2998 if name.as_str() == "typo-pkg" {
2999 Err(deps_core::error::DepsError::PackageNotFound {
3000 package: name.to_string(),
3001 registry: "mock",
3002 })
3003 } else {
3004 tokio::time::sleep(Duration::from_millis(50)).await;
3005 Err(deps_core::error::DepsError::CacheError(
3006 "rate limit exceeded".to_string(),
3007 ))
3008 }
3009 })
3010 }
3011
3012 fn get_latest_matching<'a>(
3013 &'a self,
3014 _name: &'a deps_core::PackageName,
3015 _req: &'a deps_core::VersionReq,
3016 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3017 {
3018 Box::pin(async move { Ok(None) })
3019 }
3020
3021 fn search<'a>(
3022 &'a self,
3023 _query: &'a str,
3024 _limit: usize,
3025 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3026 {
3027 Box::pin(async move { Ok(vec![]) })
3028 }
3029
3030 fn as_any(&self) -> &dyn Any {
3031 self
3032 }
3033 }
3034
3035 let registry: Arc<dyn Registry> = Arc::new(MixedErrorRegistry);
3036 let packages = vec![
3037 PackageName::new("typo-pkg"),
3038 PackageName::new("rate-limited"),
3039 ];
3040
3041 let result = fetch_latest_versions_parallel(
3042 registry,
3043 with_registry_source(packages),
3044 &HashMap::new(),
3045 None,
3046 deps_core::freshness::FreshnessSettings::default(),
3047 5,
3048 10,
3049 None,
3050 )
3051 .await;
3052
3053 let err = result
3054 .first_error
3055 .expect("an actionable failure occurred and must be reported");
3056 assert!(
3057 err.contains("rate limit exceeded"),
3058 "the actionable error must win the toast over the faster-finishing not-found, \
3059 got: {err}"
3060 );
3061 assert!(
3062 !err.contains("not found"),
3063 "a not-found error must never outrank an actionable error, got: {err}"
3064 );
3065 }
3066
3067 #[tokio::test]
3068 async fn test_first_error_falls_back_to_not_found_when_no_actionable_error_occurred() {
3069 use deps_core::{Metadata, Registry, Version};
3074 use std::any::Any;
3075
3076 struct AllNotFoundRegistry;
3077
3078 impl Registry for AllNotFoundRegistry {
3079 fn get_versions<'a>(
3080 &'a self,
3081 name: &'a deps_core::PackageName,
3082 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3083 {
3084 Box::pin(async move {
3085 Err(deps_core::error::DepsError::PackageNotFound {
3086 package: name.to_string(),
3087 registry: "mock",
3088 })
3089 })
3090 }
3091
3092 fn get_latest_matching<'a>(
3093 &'a self,
3094 _name: &'a deps_core::PackageName,
3095 _req: &'a deps_core::VersionReq,
3096 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3097 {
3098 Box::pin(async move { Ok(None) })
3099 }
3100
3101 fn search<'a>(
3102 &'a self,
3103 _query: &'a str,
3104 _limit: usize,
3105 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3106 {
3107 Box::pin(async move { Ok(vec![]) })
3108 }
3109
3110 fn as_any(&self) -> &dyn Any {
3111 self
3112 }
3113 }
3114
3115 let registry: Arc<dyn Registry> = Arc::new(AllNotFoundRegistry);
3116 let packages = vec![
3117 PackageName::new("typo-pkg-1"),
3118 PackageName::new("typo-pkg-2"),
3119 ];
3120
3121 let result = fetch_latest_versions_parallel(
3122 registry,
3123 with_registry_source(packages),
3124 &HashMap::new(),
3125 None,
3126 deps_core::freshness::FreshnessSettings::default(),
3127 5,
3128 10,
3129 None,
3130 )
3131 .await;
3132
3133 assert!(
3134 result.fetch_failed.is_empty(),
3135 "not-found errors must never be recorded in fetch_failed"
3136 );
3137 let err = result
3138 .first_error
3139 .expect("a not-found-only batch must still fall back to reporting one via first_error");
3140 assert!(err.contains("not found"), "got: {err}");
3141 }
3142
3143 #[tokio::test]
3144 async fn test_timeout_only_batch_reports_first_error_alongside_failed_count() {
3145 use deps_core::{Metadata, Registry, Version};
3150 use std::any::Any;
3151 use std::time::Duration;
3152
3153 struct AlwaysTimesOutRegistry;
3154
3155 impl Registry for AlwaysTimesOutRegistry {
3156 fn get_versions<'a>(
3157 &'a self,
3158 _name: &'a deps_core::PackageName,
3159 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3160 {
3161 Box::pin(async move {
3162 tokio::time::sleep(Duration::from_secs(10)).await;
3163 Ok(vec![])
3164 })
3165 }
3166
3167 fn get_latest_matching<'a>(
3168 &'a self,
3169 _name: &'a deps_core::PackageName,
3170 _req: &'a deps_core::VersionReq,
3171 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3172 {
3173 Box::pin(async move {
3174 tokio::time::sleep(Duration::from_secs(10)).await;
3175 Ok(None)
3176 })
3177 }
3178
3179 fn search<'a>(
3180 &'a self,
3181 _query: &'a str,
3182 _limit: usize,
3183 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3184 {
3185 Box::pin(async move { Ok(vec![]) })
3186 }
3187
3188 fn as_any(&self) -> &dyn Any {
3189 self
3190 }
3191 }
3192
3193 let registry: Arc<dyn Registry> = Arc::new(AlwaysTimesOutRegistry);
3194 let packages = vec![
3195 PackageName::new("slow-1"),
3196 PackageName::new("slow-2"),
3197 PackageName::new("slow-3"),
3198 ];
3199
3200 let result = fetch_latest_versions_parallel(
3201 registry,
3202 with_registry_source(packages),
3203 &HashMap::new(),
3204 None,
3205 deps_core::freshness::FreshnessSettings::default(),
3206 1,
3207 10,
3208 None,
3209 )
3210 .await;
3211
3212 assert_eq!(
3213 result.failed_count, 3,
3214 "all 3 packages must count toward failed_count"
3215 );
3216 let err = result
3217 .first_error
3218 .expect("a timeout is actionable and must populate first_error, not just failed_count");
3219 assert!(
3220 err.contains("timed out"),
3221 "first_error must be the actionable timeout message, got: {err}"
3222 );
3223 }
3224
3225 #[cfg(feature = "composer")]
3227 mod composer_tests {
3228 use super::*;
3229
3230 #[tokio::test]
3235 async fn test_composer_minimum_stability_extracts_from_real_parse_result() {
3236 let json = r#"{
3237 "minimum-stability": "beta",
3238 "require": {
3239 "symfony/console": "^6.0"
3240 }
3241}"#;
3242 let uri = deps_core::test_util::test_uri("/test/composer.json");
3243 let parse_result = crate::setup::parse_composer_json(json, &uri).unwrap();
3244
3245 assert_eq!(
3246 composer_minimum_stability(&parse_result as &dyn deps_core::ParseResult),
3247 Some("beta".to_string())
3248 );
3249 }
3250
3251 #[tokio::test]
3254 async fn test_composer_minimum_stability_none_when_absent() {
3255 let json = r#"{"require": {"symfony/console": "^6.0"}}"#;
3256 let uri = deps_core::test_util::test_uri("/test/composer.json");
3257 let parse_result = crate::setup::parse_composer_json(json, &uri).unwrap();
3258
3259 assert_eq!(
3260 composer_minimum_stability(&parse_result as &dyn deps_core::ParseResult),
3261 None
3262 );
3263 }
3264
3265 #[test]
3269 fn test_composer_minimum_stability_none_for_non_composer_parse_result() {
3270 struct OtherParseResult;
3271 impl deps_core::ParseResult for OtherParseResult {
3272 fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
3273 vec![]
3274 }
3275 fn workspace_root(&self) -> Option<&std::path::Path> {
3276 None
3277 }
3278 fn uri(&self) -> &url::Url {
3279 unimplemented!("not exercised by this test")
3280 }
3281 fn as_any(&self) -> &dyn std::any::Any {
3282 self
3283 }
3284 }
3285
3286 assert_eq!(
3287 composer_minimum_stability(&OtherParseResult as &dyn deps_core::ParseResult),
3288 None
3289 );
3290 }
3291 }
3292 mod yanked_check_tests {
3293 use super::*;
3294 use deps_core::{Metadata, Version};
3295 use std::any::Any;
3296 use std::sync::atomic::{AtomicUsize, Ordering};
3297
3298 #[derive(Debug, Clone)]
3299 struct MockYankVersion {
3300 version: ConcreteVersion,
3301 yanked: bool,
3302 }
3303
3304 impl Version for MockYankVersion {
3305 fn version_string(&self) -> &ConcreteVersion {
3306 &self.version
3307 }
3308 fn removal_status(&self) -> deps_core::RemovalStatus {
3309 deps_core::RemovalStatus::from_yanked(self.yanked)
3310 }
3311 fn as_any(&self) -> &dyn Any {
3312 self
3313 }
3314 }
3315
3316 enum FetchOutcome {
3319 Versions(Vec<(&'static str, bool)>),
3320 Error,
3321 Timeout,
3322 }
3323
3324 struct MockRegistry {
3334 reports_yanked: bool,
3335 versions: HashMap<&'static str, FetchOutcome>,
3336 latest_fallback: HashMap<&'static str, (&'static str, bool)>,
3337 fetch_calls: Arc<AtomicUsize>,
3338 }
3339
3340 impl Registry for MockRegistry {
3341 fn get_versions<'a>(
3342 &'a self,
3343 name: &'a PackageName,
3344 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3345 {
3346 self.fetch_calls.fetch_add(1, Ordering::Relaxed);
3347 let outcome = self.versions.get(name.as_str());
3348 Box::pin(async move {
3349 match outcome {
3350 Some(FetchOutcome::Versions(vs)) => Ok(vs
3351 .iter()
3352 .map(|(v, y)| {
3353 Box::new(MockYankVersion {
3354 version: (*v).into(),
3355 yanked: *y,
3356 }) as Box<dyn Version>
3357 })
3358 .collect()),
3359 Some(FetchOutcome::Error) => Err(deps_core::error::DepsError::CacheError(
3360 "mock fetch error".to_string(),
3361 )),
3362 Some(FetchOutcome::Timeout) => {
3363 tokio::time::sleep(std::time::Duration::from_secs(10)).await;
3364 Ok(vec![])
3365 }
3366 None => Ok(vec![]),
3367 }
3368 })
3369 }
3370
3371 fn select_latest_matching(
3372 &self,
3373 versions: &[Box<dyn Version>],
3374 _req: &VersionReq,
3375 ) -> Option<usize> {
3376 versions
3377 .iter()
3378 .position(|v| !v.removal_status().blocks_resolution())
3379 }
3380
3381 fn get_latest_matching<'a>(
3382 &'a self,
3383 name: &'a PackageName,
3384 _req: &'a VersionReq,
3385 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3386 {
3387 let outcome = self.latest_fallback.get(name.as_str()).copied();
3388 Box::pin(async move {
3389 Ok(outcome.map(|(v, y)| {
3390 Box::new(MockYankVersion {
3391 version: v.into(),
3392 yanked: y,
3393 }) as Box<dyn Version>
3394 }))
3395 })
3396 }
3397
3398 fn search<'a>(
3399 &'a self,
3400 _query: &'a str,
3401 _limit: usize,
3402 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3403 {
3404 Box::pin(async move { Ok(vec![]) })
3405 }
3406
3407 fn reports_yanked(&self) -> bool {
3408 self.reports_yanked
3409 }
3410
3411 fn as_any(&self) -> &dyn Any {
3412 self
3413 }
3414 }
3415
3416 #[tokio::test]
3417 async fn reports_yanked_false_never_recorded() {
3418 let fetch_calls = Arc::new(AtomicUsize::new(0));
3422 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3423 reports_yanked: false,
3424 versions: HashMap::from([(
3425 "pkg",
3426 FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", true)]),
3427 )]),
3428 latest_fallback: HashMap::new(),
3429 fetch_calls: Arc::clone(&fetch_calls),
3430 });
3431 let mut in_use = HashMap::new();
3432 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3433
3434 let result = fetch_latest_versions_parallel(
3435 registry,
3436 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3437 &in_use,
3438 None,
3439 deps_core::freshness::FreshnessSettings::default(),
3440 5,
3441 10,
3442 None,
3443 )
3444 .await;
3445
3446 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3447 assert!(result.yanked_versions.is_empty());
3448 }
3449
3450 #[tokio::test]
3451 async fn in_use_equal_to_latest_not_yanked() {
3452 let fetch_calls = Arc::new(AtomicUsize::new(0));
3453 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3454 reports_yanked: true,
3455 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", false)]))]),
3456 latest_fallback: HashMap::new(),
3457 fetch_calls: Arc::clone(&fetch_calls),
3458 });
3459 let mut in_use = HashMap::new();
3460 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3461
3462 let result = fetch_latest_versions_parallel(
3463 registry,
3464 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3465 &in_use,
3466 None,
3467 deps_core::freshness::FreshnessSettings::default(),
3468 5,
3469 10,
3470 None,
3471 )
3472 .await;
3473
3474 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3475 assert!(result.yanked_versions.is_empty());
3476 }
3477
3478 #[tokio::test]
3479 async fn no_known_in_use_version_skips_the_check() {
3480 let fetch_calls = Arc::new(AtomicUsize::new(0));
3481 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3482 reports_yanked: true,
3483 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("2.0.0", false)]))]),
3484 latest_fallback: HashMap::new(),
3485 fetch_calls: Arc::clone(&fetch_calls),
3486 });
3487
3488 let result = fetch_latest_versions_parallel(
3489 registry,
3490 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3491 &HashMap::new(),
3492 None,
3493 deps_core::freshness::FreshnessSettings::default(),
3494 5,
3495 10,
3496 None,
3497 )
3498 .await;
3499
3500 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3501 assert!(result.yanked_versions.is_empty());
3502 }
3503
3504 #[tokio::test]
3505 async fn in_use_differs_and_yanked_is_recorded() {
3506 let fetch_calls = Arc::new(AtomicUsize::new(0));
3510 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3511 reports_yanked: true,
3512 versions: HashMap::from([(
3513 "pkg",
3514 FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", true)]),
3515 )]),
3516 latest_fallback: HashMap::new(),
3517 fetch_calls: Arc::clone(&fetch_calls),
3518 });
3519 let mut in_use = HashMap::new();
3520 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3521
3522 let result = fetch_latest_versions_parallel(
3523 registry,
3524 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3525 &in_use,
3526 None,
3527 deps_core::freshness::FreshnessSettings::default(),
3528 5,
3529 10,
3530 None,
3531 )
3532 .await;
3533
3534 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3535 assert_eq!(
3536 result.yanked_versions.get(&PackageName::new("pkg")),
3537 Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
3538 );
3539 }
3540
3541 #[tokio::test]
3542 async fn in_use_differs_and_not_yanked_is_not_recorded() {
3543 let fetch_calls = Arc::new(AtomicUsize::new(0));
3544 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3545 reports_yanked: true,
3546 versions: HashMap::from([(
3547 "pkg",
3548 FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", false)]),
3549 )]),
3550 latest_fallback: HashMap::new(),
3551 fetch_calls: Arc::clone(&fetch_calls),
3552 });
3553 let mut in_use = HashMap::new();
3554 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3555
3556 let result = fetch_latest_versions_parallel(
3557 registry,
3558 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3559 &in_use,
3560 None,
3561 deps_core::freshness::FreshnessSettings::default(),
3562 5,
3563 10,
3564 None,
3565 )
3566 .await;
3567
3568 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3569 assert!(result.yanked_versions.is_empty());
3570 }
3571
3572 #[tokio::test]
3573 async fn every_version_yanked_still_checks_in_use() {
3574 let fetch_calls = Arc::new(AtomicUsize::new(0));
3581 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3582 reports_yanked: true,
3583 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
3584 latest_fallback: HashMap::new(),
3585 fetch_calls: Arc::clone(&fetch_calls),
3586 });
3587 let mut in_use = HashMap::new();
3588 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3589
3590 let result = fetch_latest_versions_parallel(
3591 registry,
3592 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3593 &in_use,
3594 None,
3595 deps_core::freshness::FreshnessSettings::default(),
3596 5,
3597 10,
3598 None,
3599 )
3600 .await;
3601
3602 assert_eq!(
3603 result.yanked_versions.get(&PackageName::new("pkg")),
3604 Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
3605 );
3606 assert!(result.versions.is_empty());
3607 }
3608
3609 #[tokio::test]
3610 async fn latest_pick_needs_fallback_in_use_yanked_still_found() {
3611 let fetch_calls = Arc::new(AtomicUsize::new(0));
3617 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3618 reports_yanked: true,
3619 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
3620 latest_fallback: HashMap::from([("pkg", ("2.0.0", false))]),
3621 fetch_calls: Arc::clone(&fetch_calls),
3622 });
3623 let mut in_use = HashMap::new();
3624 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3625
3626 let result = fetch_latest_versions_parallel(
3627 registry,
3628 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3629 &in_use,
3630 None,
3631 deps_core::freshness::FreshnessSettings::default(),
3632 5,
3633 10,
3634 None,
3635 )
3636 .await;
3637
3638 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3639 assert_eq!(
3640 result
3641 .versions
3642 .get(&PackageName::new("pkg"))
3643 .map(|v| v.latest.as_str()),
3644 Some("2.0.0")
3645 );
3646 assert_eq!(
3647 result.yanked_versions.get(&PackageName::new("pkg")),
3648 Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
3649 );
3650 }
3651
3652 #[tokio::test]
3653 async fn in_use_checks_every_occurrence_of_a_duplicate_name() {
3654 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3660 reports_yanked: true,
3661 versions: HashMap::from([(
3662 "pkg",
3663 FetchOutcome::Versions(vec![
3664 ("3.0.0", false),
3665 ("2.0.0", true),
3666 ("1.0.0", false),
3667 ]),
3668 )]),
3669 latest_fallback: HashMap::new(),
3670 fetch_calls: Arc::new(AtomicUsize::new(0)),
3671 });
3672 let mut in_use = HashMap::new();
3673 in_use.insert(
3674 PackageName::new("pkg"),
3675 vec!["1.0.0".to_string(), "2.0.0".to_string()],
3676 );
3677
3678 let result = fetch_latest_versions_parallel(
3679 registry,
3680 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3681 &in_use,
3682 None,
3683 deps_core::freshness::FreshnessSettings::default(),
3684 5,
3685 10,
3686 None,
3687 )
3688 .await;
3689
3690 assert_eq!(
3691 result.yanked_versions.get(&PackageName::new("pkg")),
3692 Some(&(ConcreteVersion::new("2.0.0"), RemovalStatus::Yanked)),
3693 "the yanked occurrence must be found even though a name-keyed \
3694 single-value map could have kept only the non-yanked \"1.0.0\" pin"
3695 );
3696 }
3697
3698 #[tokio::test]
3699 async fn latest_is_yanked_recorded_as_defense_in_depth() {
3700 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3706 reports_yanked: true,
3707 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
3708 latest_fallback: HashMap::from([("pkg", ("1.0.0", true))]),
3709 fetch_calls: Arc::new(AtomicUsize::new(0)),
3710 });
3711
3712 let result = fetch_latest_versions_parallel(
3713 registry,
3714 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3715 &HashMap::new(),
3716 None,
3717 deps_core::freshness::FreshnessSettings::default(),
3718 5,
3719 10,
3720 None,
3721 )
3722 .await;
3723
3724 assert_eq!(
3725 result.yanked_versions.get(&PackageName::new("pkg")),
3726 Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
3727 );
3728 }
3729
3730 #[tokio::test]
3731 async fn latest_is_yanked_not_recorded_when_reports_yanked_false() {
3732 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3740 reports_yanked: false,
3741 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
3742 latest_fallback: HashMap::from([("pkg", ("1.0.0", true))]),
3743 fetch_calls: Arc::new(AtomicUsize::new(0)),
3744 });
3745
3746 let result = fetch_latest_versions_parallel(
3747 registry,
3748 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3749 &HashMap::new(),
3750 None,
3751 deps_core::freshness::FreshnessSettings::default(),
3752 5,
3753 10,
3754 None,
3755 )
3756 .await;
3757
3758 assert!(
3759 result.yanked_versions.is_empty(),
3760 "a `reports_yanked() == false` registry's `removal_status()` must never be \
3761 trusted, even on the zero-cost row-1 path"
3762 );
3763 assert!(
3764 result
3765 .versions
3766 .get(&PackageName::new("pkg"))
3767 .expect("pkg was fetched")
3768 .yanked
3769 .is_empty(),
3770 "`PackageVersions::yanked` must stay empty for a `reports_yanked() == false` \
3771 registry, even though the fetched version is itself flagged"
3772 );
3773 }
3774
3775 #[tokio::test]
3776 async fn primary_fetch_error_counts_as_failed_no_yanked_data() {
3777 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3783 reports_yanked: true,
3784 versions: HashMap::from([("pkg", FetchOutcome::Error)]),
3785 latest_fallback: HashMap::new(),
3786 fetch_calls: Arc::new(AtomicUsize::new(0)),
3787 });
3788 let mut in_use = HashMap::new();
3789 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3790
3791 let result = fetch_latest_versions_parallel(
3792 registry,
3793 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3794 &in_use,
3795 None,
3796 deps_core::freshness::FreshnessSettings::default(),
3797 5,
3798 10,
3799 None,
3800 )
3801 .await;
3802
3803 assert!(result.yanked_versions.is_empty());
3804 assert_eq!(result.failed_count, 1);
3805 assert!(result.versions.is_empty());
3806 }
3807
3808 #[tokio::test]
3809 async fn primary_fetch_timeout_counts_as_failed_no_yanked_data() {
3810 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3812 reports_yanked: true,
3813 versions: HashMap::from([("pkg", FetchOutcome::Timeout)]),
3814 latest_fallback: HashMap::new(),
3815 fetch_calls: Arc::new(AtomicUsize::new(0)),
3816 });
3817 let mut in_use = HashMap::new();
3818 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3819
3820 let result = fetch_latest_versions_parallel(
3821 registry,
3822 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3823 &in_use,
3824 None,
3825 deps_core::freshness::FreshnessSettings::default(),
3826 1,
3827 10,
3828 None,
3829 )
3830 .await;
3831
3832 assert!(result.yanked_versions.is_empty());
3833 assert_eq!(result.failed_count, 1);
3834 assert!(result.versions.is_empty());
3835 }
3836 }
3837
3838 mod deprecation_derivation_tests {
3844 use super::*;
3845 use deps_core::{Metadata, Version};
3846 use std::any::Any;
3847
3848 struct MockDeprecatedVersion {
3849 version: ConcreteVersion,
3850 deprecation: Option<Deprecation>,
3851 }
3852
3853 impl Version for MockDeprecatedVersion {
3854 fn version_string(&self) -> &ConcreteVersion {
3855 &self.version
3856 }
3857 fn removal_status(&self) -> RemovalStatus {
3858 RemovalStatus::from_advisory(self.deprecation.is_some())
3859 }
3860 fn deprecation(&self) -> Option<&Deprecation> {
3861 self.deprecation.as_ref()
3862 }
3863 fn as_any(&self) -> &dyn Any {
3864 self
3865 }
3866 }
3867
3868 struct SingleVersionRegistry {
3870 deprecation: Option<Deprecation>,
3871 }
3872
3873 impl Registry for SingleVersionRegistry {
3874 fn get_versions<'a>(
3875 &'a self,
3876 _name: &'a PackageName,
3877 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3878 {
3879 let deprecation = self.deprecation.clone();
3880 Box::pin(async move {
3881 Ok(vec![Box::new(MockDeprecatedVersion {
3882 version: "1.0.0".into(),
3883 deprecation,
3884 }) as Box<dyn Version>])
3885 })
3886 }
3887
3888 fn select_latest_matching(
3889 &self,
3890 versions: &[Box<dyn Version>],
3891 _req: &VersionReq,
3892 ) -> Option<usize> {
3893 (!versions.is_empty()).then_some(0)
3894 }
3895
3896 fn get_latest_matching<'a>(
3897 &'a self,
3898 _name: &'a PackageName,
3899 _req: &'a VersionReq,
3900 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3901 {
3902 Box::pin(async move { Ok(None) })
3903 }
3904
3905 fn search<'a>(
3906 &'a self,
3907 _query: &'a str,
3908 _limit: usize,
3909 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3910 {
3911 Box::pin(async move { Ok(vec![]) })
3912 }
3913
3914 fn as_any(&self) -> &dyn Any {
3915 self
3916 }
3917 }
3918
3919 #[tokio::test]
3920 async fn fetch_result_carries_deprecation_from_resolved_pick() {
3921 let registry: Arc<dyn Registry> = Arc::new(SingleVersionRegistry {
3922 deprecation: Some(Deprecation {
3923 reason: Some("archived".to_string()),
3924 replacement: Some("other/pkg".to_string()),
3925 }),
3926 });
3927
3928 let result = fetch_latest_versions_parallel(
3929 registry,
3930 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3931 &HashMap::new(),
3932 None,
3933 deps_core::freshness::FreshnessSettings::default(),
3934 5,
3935 10,
3936 None,
3937 )
3938 .await;
3939
3940 assert_eq!(
3941 result.deprecations.get(&PackageName::new("pkg")),
3942 Some(&Deprecation {
3943 reason: Some("archived".to_string()),
3944 replacement: Some("other/pkg".to_string()),
3945 })
3946 );
3947 }
3948
3949 #[tokio::test]
3950 async fn fetch_result_has_no_deprecation_when_resolved_pick_is_clean() {
3951 let registry: Arc<dyn Registry> = Arc::new(SingleVersionRegistry { deprecation: None });
3952
3953 let result = fetch_latest_versions_parallel(
3954 registry,
3955 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3956 &HashMap::new(),
3957 None,
3958 deps_core::freshness::FreshnessSettings::default(),
3959 5,
3960 10,
3961 None,
3962 )
3963 .await;
3964
3965 assert!(result.deprecations.is_empty());
3966 }
3967 }
3968}