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.for_tracing(),
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.for_tracing(), 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.for_tracing(),
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.for_tracing(), "no version found");
680 no_comparable_versions = true;
685 None
686 }
687 Ok(Err(e)) => {
688 tracing::warn!(
689 package = %name.for_tracing(),
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.for_tracing(),
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 "{}: registry request timed out after {}s",
720 name.for_tracing(),
721 timeout.as_secs()
722 ),
723 ));
724 None
725 }
726 }
727 };
728
729 if check_yanked {
730 if let Some((latest, status, _, _, _)) = &resolved
734 && status.is_flagged()
735 {
736 yanked = Some((name.clone(), latest.clone(), *status));
737 }
738
739 if let Some((iv, status)) = in_use_versions.iter().find_map(|iv| {
752 versions
753 .iter()
754 .find(|v| {
755 v.version_string() == iv.as_str() && v.removal_status().is_flagged()
756 })
757 .map(|v| (iv, v.removal_status()))
758 }) {
759 yanked = Some((name.clone(), iv.as_str().into(), status));
760 }
761 }
762
763 if let Some((_, _, _, dep_info, _)) = &resolved
768 && let Some(dep_info) = dep_info
769 {
770 deprecation = Some((name.clone(), dep_info.clone()));
771 }
772
773 license = resolved
777 .as_ref()
778 .map(|(_, _, _, _, lic)| lic)
779 .filter(|lic| !lic.is_empty())
780 .map(|lic| (name.clone(), lic.clone()));
781
782 resolved.map(|(latest, _, published_at, _, _)| {
783 let mut versions = PackageVersions::new(latest, available).with_yanked(yanked_list);
784 if let Some(published_at) = published_at {
785 versions = versions.with_published_at(published_at);
786 }
787 (name.clone(), versions)
788 })
789 }
790 Ok(Err(e)) => {
791 if e.is_offline() {
796 tracing::debug!(package = %name.for_tracing(), "fetch skipped: offline");
797 } else {
798 tracing::warn!(package = %name.for_tracing(), error = %e, "fetch failed");
799 }
800 failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
801 let mut fe = first_error.lock().unwrap_or_else(|p| p.into_inner());
802 if fe.is_none() {
803 *fe = Some(e.to_string());
804 }
805 drop(fe);
806 if !e.is_not_found() {
810 failed_name = Some((name.clone(), e.fetch_failure(), e.to_string()));
811 }
812 None
813 }
814 Err(_) => {
815 tracing::warn!(package = %name.for_tracing(), "fetch timed out ({}s)", timeout.as_secs());
816 failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
817 failed_name = Some((
818 name.clone(),
819 FetchFailure::Transient,
820 format!(
821 "{}: registry request timed out after {}s",
822 name.for_tracing(),
823 timeout.as_secs()
824 ),
825 ));
826 None
827 }
828 };
829
830 let count = fetched.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
831 if let Some(sender) = progress_sender {
832 sender.send(count);
833 }
834
835 let no_comparable_versions_name = no_comparable_versions.then(|| name.clone());
836 (
837 version,
838 yanked,
839 failed_name,
840 deprecation,
841 no_comparable_versions_name,
842 license,
843 )
844}
845
846pub fn apply_fetch_outcomes(
904 outcomes: &mut deps_core::lsp_helpers::DependencyOutcomes,
905 yanked_versions: HashMap<PackageName, (ConcreteVersion, RemovalStatus)>,
906 fetch_failed: HashMap<PackageName, FetchFailure>,
907 collided_names: HashSet<PackageName>,
908 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
909) {
910 for (name, version) in yanked_versions {
911 outcomes.set_yanked(formatter.normalize_package_name(&name), version);
912 }
913 for (name, failure) in fetch_failed {
914 outcomes.set_fetch_failure(formatter.normalize_package_name(&name), failure);
915 }
916 for name in collided_names {
917 outcomes.set_fetch_failure_if_absent(
918 formatter.normalize_package_name(&name),
919 FetchFailure::NotAttempted,
920 );
921 }
922}
923
924#[cfg(test)]
925mod tests {
926 use super::*;
927 use deps_core::parser::DependencySource;
928
929 fn with_registry_source(names: Vec<PackageName>) -> Vec<(PackageName, DependencySource)> {
934 names
935 .into_iter()
936 .map(|name| (name, DependencySource::Registry))
937 .collect()
938 }
939
940 #[cfg(feature = "cargo")]
941 mod apply_fetch_outcomes_tests {
942 use super::*;
943 use crate::setup::CargoFormatter;
944 use deps_core::lsp_helpers::DependencyOutcomes;
945
946 #[test]
947 fn apply_fetch_outcomes_sets_yanked_re_keyed_to_normalized_name() {
948 let mut outcomes = DependencyOutcomes::new();
949 let mut yanked_versions = HashMap::new();
950 yanked_versions.insert(
951 PackageName::new("time"),
952 ("0.1.43".into(), RemovalStatus::Yanked),
953 );
954
955 apply_fetch_outcomes(
956 &mut outcomes,
957 yanked_versions,
958 HashMap::new(),
959 HashSet::new(),
960 &CargoFormatter,
961 );
962
963 assert_eq!(
964 outcomes.yanked("time"),
965 Some(&("0.1.43".into(), RemovalStatus::Yanked))
966 );
967 }
968
969 #[test]
973 fn apply_fetch_outcomes_collided_name_does_not_clobber_existing_fetch_failure() {
974 let mut outcomes = DependencyOutcomes::new();
975 let mut fetch_failed = HashMap::new();
976 fetch_failed.insert(PackageName::new("serde"), FetchFailure::Transient);
977 let mut collided_names = HashSet::new();
978 collided_names.insert(PackageName::new("serde"));
979
980 apply_fetch_outcomes(
981 &mut outcomes,
982 HashMap::new(),
983 fetch_failed,
984 collided_names,
985 &CargoFormatter,
986 );
987
988 assert_eq!(
989 outcomes.fetch_failure("serde"),
990 Some(&FetchFailure::Transient),
991 "a genuine fetch failure must survive a collided name normalizing to the same key"
992 );
993 }
994
995 #[test]
998 fn apply_fetch_outcomes_collided_name_alone_is_recorded_as_not_attempted() {
999 let mut outcomes = DependencyOutcomes::new();
1000 let mut collided_names = HashSet::new();
1001 collided_names.insert(PackageName::new("serde"));
1002
1003 apply_fetch_outcomes(
1004 &mut outcomes,
1005 HashMap::new(),
1006 HashMap::new(),
1007 collided_names,
1008 &CargoFormatter,
1009 );
1010
1011 assert_eq!(
1012 outcomes.fetch_failure("serde"),
1013 Some(&FetchFailure::NotAttempted)
1014 );
1015 }
1016 }
1017
1018 mod dedup_by_source_collision_tests {
1019 use super::*;
1020 use deps_core::Dependency;
1021 use deps_core::lsp_helpers::{
1022 DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
1023 RequirementResolution, SourcePolicy,
1024 };
1025 use deps_core::position::{Position, Range};
1026 use std::any::Any;
1027
1028 struct AlternateAwareFormatter;
1032 impl PackageNaming for AlternateAwareFormatter {}
1033
1034 impl PackageRendering for AlternateAwareFormatter {
1035 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
1036 version.to_string()
1037 }
1038
1039 fn package_url(&self, name: &PackageName) -> String {
1040 format!("https://example.com/{}", name.as_str())
1041 }
1042 }
1043
1044 impl RequirementResolution for AlternateAwareFormatter {}
1045
1046 impl DiagnosticMessages for AlternateAwareFormatter {}
1047
1048 impl DiagnosticPolicy for AlternateAwareFormatter {}
1049
1050 impl SourcePolicy for AlternateAwareFormatter {
1051 fn can_resolve_source(&self, source: &DependencySource) -> bool {
1052 matches!(
1053 source,
1054 DependencySource::Registry | DependencySource::AlternateRegistry { .. }
1055 )
1056 }
1057 }
1058
1059 impl OsvNaming for AlternateAwareFormatter {}
1060
1061 struct MockDep {
1062 name: PackageName,
1063 source: DependencySource,
1064 addr_tag: u32,
1065 }
1066
1067 impl Dependency for MockDep {
1068 fn name(&self) -> &PackageName {
1069 &self.name
1070 }
1071 fn name_range(&self) -> Range {
1072 Range::new(
1073 Position::new(0, self.addr_tag),
1074 Position::new(0, self.addr_tag + 1),
1075 )
1076 }
1077 fn version_requirement(&self) -> Option<&VersionReq> {
1078 None
1079 }
1080 fn version_range(&self) -> Option<Range> {
1081 None
1082 }
1083 fn source(&self) -> DependencySource {
1084 self.source.clone()
1085 }
1086 fn as_any(&self) -> &dyn Any {
1087 self
1088 }
1089 }
1090
1091 struct MockParseResult {
1092 deps: Vec<MockDep>,
1093 }
1094
1095 impl deps_core::ParseResult for MockParseResult {
1096 fn dependencies(&self) -> Vec<&dyn Dependency> {
1097 self.deps.iter().map(|d| d as &dyn Dependency).collect()
1098 }
1099 fn workspace_root(&self) -> Option<&std::path::Path> {
1100 None
1101 }
1102 fn uri(&self) -> &url::Url {
1103 static URI: std::sync::OnceLock<url::Url> = std::sync::OnceLock::new();
1104 URI.get_or_init(|| deps_core::test_util::test_uri("/test/Cargo.toml"))
1105 }
1106 fn as_any(&self) -> &dyn Any {
1107 self
1108 }
1109 }
1110
1111 #[test]
1112 fn test_two_different_resolvable_sources_collide_and_are_dropped() {
1113 let parse_result = MockParseResult {
1114 deps: vec![
1115 MockDep {
1116 name: PackageName::new("shared-name"),
1117 source: DependencySource::Registry,
1118 addr_tag: 0,
1119 },
1120 MockDep {
1121 name: PackageName::new("shared-name"),
1122 source: DependencySource::AlternateRegistry {
1123 index: "https://index.mycorp.dev".into(),
1124 mirrors_crates_io: false,
1125 },
1126 addr_tag: 1,
1127 },
1128 ],
1129 };
1130
1131 let (sources, collided) =
1132 dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
1133
1134 assert!(
1135 !sources.contains_key(&PackageName::new("shared-name")),
1136 "a colliding name must not be fetched under either source"
1137 );
1138 assert!(
1139 collided.contains(&PackageName::new("shared-name")),
1140 "the collision must be recorded so the caller can mark it fetch_failed"
1141 );
1142 }
1143
1144 #[test]
1145 fn test_identical_sources_do_not_collide() {
1146 let parse_result = MockParseResult {
1147 deps: vec![
1148 MockDep {
1149 name: PackageName::new("shared-name"),
1150 source: DependencySource::Registry,
1151 addr_tag: 0,
1152 },
1153 MockDep {
1154 name: PackageName::new("shared-name"),
1155 source: DependencySource::Registry,
1156 addr_tag: 1,
1157 },
1158 ],
1159 };
1160
1161 let (sources, collided) =
1162 dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
1163
1164 assert!(collided.is_empty());
1165 assert_eq!(
1166 sources.get(&PackageName::new("shared-name")),
1167 Some(&DependencySource::Registry)
1168 );
1169 }
1170
1171 #[test]
1176 fn test_non_resolvable_source_is_dropped_not_fetched() {
1177 let parse_result = MockParseResult {
1178 deps: vec![MockDep {
1179 name: PackageName::new("local-fork"),
1180 source: DependencySource::Path {
1181 path: "../local-fork".into(),
1182 },
1183 addr_tag: 0,
1184 }],
1185 };
1186
1187 let (sources, collided) =
1188 dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
1189
1190 assert!(sources.is_empty());
1191 assert!(collided.is_empty());
1192 }
1193
1194 #[test]
1202 fn test_collision_warning_redacts_credentials_in_alternate_registry_debug_output() {
1203 let parse_result = MockParseResult {
1204 deps: vec![
1205 MockDep {
1206 name: PackageName::new("shared-name"),
1207 source: DependencySource::AlternateRegistry {
1208 index: "https://index-a.mycorp.dev/api?api_key=SECRET_A".into(),
1209 mirrors_crates_io: false,
1210 },
1211 addr_tag: 0,
1212 },
1213 MockDep {
1214 name: PackageName::new("shared-name"),
1215 source: DependencySource::AlternateRegistry {
1216 index: "https://index-b.mycorp.dev/api?api_key=SECRET_B".into(),
1217 mirrors_crates_io: false,
1218 },
1219 addr_tag: 1,
1220 },
1221 ],
1222 };
1223
1224 let log = deps_core::test_util::capture_tracing_output(|| {
1225 let (sources, collided) =
1226 dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
1227 assert!(!sources.contains_key(&PackageName::new("shared-name")));
1228 assert!(collided.contains(&PackageName::new("shared-name")));
1229 });
1230
1231 assert!(
1232 log.contains("two different resolved registries"),
1233 "expected the collision WARN to fire: {log:?}"
1234 );
1235 assert!(
1236 !log.contains("SECRET_A") && !log.contains("SECRET_B"),
1237 "tracing output leaked a query-string credential: {log:?}"
1238 );
1239 assert!(
1240 log.contains("index-a.mycorp.dev") && log.contains("index-b.mycorp.dev"),
1241 "host should survive redaction: {log:?}"
1242 );
1243 }
1244 }
1245
1246 #[tokio::test]
1247 async fn test_fetch_latest_versions_parallel_with_timeout() {
1248 use deps_core::{Metadata, Registry, Version};
1249 use std::any::Any;
1250 use std::time::Duration;
1251
1252 struct TimeoutRegistry;
1253
1254 impl Registry for TimeoutRegistry {
1255 fn get_versions<'a>(
1256 &'a self,
1257 _name: &'a deps_core::PackageName,
1258 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1259 {
1260 Box::pin(async move {
1261 tokio::time::sleep(Duration::from_secs(10)).await;
1262 Ok(vec![])
1263 })
1264 }
1265
1266 fn get_latest_matching<'a>(
1267 &'a self,
1268 _name: &'a deps_core::PackageName,
1269 _req: &'a deps_core::VersionReq,
1270 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1271 {
1272 Box::pin(async move {
1273 tokio::time::sleep(Duration::from_secs(10)).await;
1274 Ok(None)
1275 })
1276 }
1277
1278 fn search_raw<'a>(
1279 &'a self,
1280 _query: &'a str,
1281 _limit: usize,
1282 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1283 {
1284 Box::pin(async move { Ok(vec![]) })
1285 }
1286
1287 fn as_any(&self) -> &dyn Any {
1288 self
1289 }
1290 }
1291
1292 let registry: Arc<dyn Registry> = Arc::new(TimeoutRegistry);
1293 let packages = vec![PackageName::new("slow-package")];
1294
1295 let result = fetch_latest_versions_parallel(
1296 registry,
1297 with_registry_source(packages),
1298 &HashMap::new(),
1299 None,
1300 deps_core::freshness::FreshnessSettings::default(),
1301 1,
1302 10,
1303 None,
1304 )
1305 .await;
1306
1307 assert!(result.versions.is_empty(), "Slow package should timeout");
1308 assert_eq!(result.failed_count, 1, "Should track 1 failed package");
1309 assert_eq!(
1312 result.fetch_failed,
1313 HashMap::from([(PackageName::new("slow-package"), FetchFailure::Transient)]),
1314 "timed-out package must be recorded in fetch_failed"
1315 );
1316 }
1317
1318 #[tokio::test]
1319 async fn test_fetch_latest_versions_parallel_fast_packages_not_blocked() {
1320 use deps_core::{Metadata, Registry, Version};
1321 use std::any::Any;
1322 use std::time::Duration;
1323
1324 struct MixedRegistry;
1325
1326 impl Registry for MixedRegistry {
1327 fn get_versions<'a>(
1328 &'a self,
1329 name: &'a deps_core::PackageName,
1330 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1331 {
1332 Box::pin(async move {
1333 if name == "slow-package" {
1334 tokio::time::sleep(Duration::from_secs(10)).await;
1335 }
1336 Ok(vec![])
1337 })
1338 }
1339
1340 fn get_latest_matching<'a>(
1341 &'a self,
1342 name: &'a deps_core::PackageName,
1343 _req: &'a deps_core::VersionReq,
1344 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1345 {
1346 Box::pin(async move {
1347 if name == "slow-package" {
1348 tokio::time::sleep(Duration::from_secs(10)).await;
1349 }
1350 Ok(None)
1351 })
1352 }
1353
1354 fn search_raw<'a>(
1355 &'a self,
1356 _query: &'a str,
1357 _limit: usize,
1358 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1359 {
1360 Box::pin(async move { Ok(vec![]) })
1361 }
1362
1363 fn as_any(&self) -> &dyn Any {
1364 self
1365 }
1366 }
1367
1368 let registry: Arc<dyn Registry> = Arc::new(MixedRegistry);
1369 let packages = vec![
1370 PackageName::new("slow-package"),
1371 PackageName::new("fast-package"),
1372 ];
1373
1374 let start = std::time::Instant::now();
1375 let result = fetch_latest_versions_parallel(
1376 registry,
1377 with_registry_source(packages),
1378 &HashMap::new(),
1379 None,
1380 deps_core::freshness::FreshnessSettings::default(),
1381 1,
1382 10,
1383 None,
1384 )
1385 .await;
1386 let elapsed = start.elapsed();
1387
1388 assert!(
1389 elapsed < Duration::from_secs(3),
1390 "Should not wait for slow package: {:?}",
1391 elapsed
1392 );
1393
1394 assert!(
1395 result.versions.is_empty(),
1396 "No versions returned (test registry returns empty)"
1397 );
1398 assert_eq!(
1399 result.failed_count, 1,
1400 "Slow package should be marked as failed"
1401 );
1402 }
1403
1404 #[tokio::test]
1405 async fn test_fetch_latest_versions_parallel_concurrency_limit() {
1406 use deps_core::{Metadata, Registry, Version};
1407 use std::any::Any;
1408 use std::sync::atomic::{AtomicUsize, Ordering};
1409 use std::time::Duration;
1410
1411 struct ConcurrencyTrackingRegistry {
1412 current: Arc<AtomicUsize>,
1413 max_seen: Arc<AtomicUsize>,
1414 }
1415
1416 impl Registry for ConcurrencyTrackingRegistry {
1417 fn get_versions<'a>(
1418 &'a self,
1419 _name: &'a deps_core::PackageName,
1420 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1421 {
1422 Box::pin(async move {
1423 let current = self.current.fetch_add(1, Ordering::SeqCst) + 1;
1424 self.max_seen.fetch_max(current, Ordering::SeqCst);
1425 tokio::time::sleep(Duration::from_millis(50)).await;
1426 self.current.fetch_sub(1, Ordering::SeqCst);
1427
1428 Ok(vec![])
1429 })
1430 }
1431
1432 fn get_latest_matching<'a>(
1433 &'a self,
1434 _name: &'a deps_core::PackageName,
1435 _req: &'a deps_core::VersionReq,
1436 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1437 {
1438 Box::pin(async move {
1439 let current = self.current.fetch_add(1, Ordering::SeqCst) + 1;
1440 self.max_seen.fetch_max(current, Ordering::SeqCst);
1441 tokio::time::sleep(Duration::from_millis(50)).await;
1442 self.current.fetch_sub(1, Ordering::SeqCst);
1443
1444 Ok(None)
1445 })
1446 }
1447
1448 fn search_raw<'a>(
1449 &'a self,
1450 _query: &'a str,
1451 _limit: usize,
1452 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1453 {
1454 Box::pin(async move { Ok(vec![]) })
1455 }
1456
1457 fn as_any(&self) -> &dyn Any {
1458 self
1459 }
1460 }
1461
1462 let current = Arc::new(AtomicUsize::new(0));
1463 let max_seen = Arc::new(AtomicUsize::new(0));
1464
1465 let registry: Arc<dyn Registry> = Arc::new(ConcurrencyTrackingRegistry {
1466 current: Arc::clone(¤t),
1467 max_seen: Arc::clone(&max_seen),
1468 });
1469
1470 let packages: Vec<PackageName> = (0..50)
1471 .map(|i| PackageName::new(format!("package-{}", i)))
1472 .collect();
1473
1474 fetch_latest_versions_parallel(
1475 registry,
1476 with_registry_source(packages),
1477 &HashMap::new(),
1478 None,
1479 deps_core::freshness::FreshnessSettings::default(),
1480 5,
1481 20,
1482 None,
1483 )
1484 .await;
1485
1486 let max = max_seen.load(Ordering::SeqCst);
1488 assert!(
1489 max <= 22,
1490 "Concurrency limit violated: {} concurrent requests (limit: 20)",
1491 max
1492 );
1493 }
1494
1495 #[tokio::test]
1501 async fn test_fetch_latest_versions_parallel_zero_max_concurrent_still_completes() {
1502 use deps_core::{Metadata, Registry, Version};
1503 use std::any::Any;
1504
1505 struct InstantRegistry;
1506
1507 impl Registry for InstantRegistry {
1508 fn get_versions<'a>(
1509 &'a self,
1510 _name: &'a deps_core::PackageName,
1511 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1512 {
1513 Box::pin(async move { Ok(vec![]) })
1514 }
1515
1516 fn get_latest_matching<'a>(
1517 &'a self,
1518 _name: &'a deps_core::PackageName,
1519 _req: &'a deps_core::VersionReq,
1520 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1521 {
1522 Box::pin(async move { Ok(None) })
1523 }
1524
1525 fn search_raw<'a>(
1526 &'a self,
1527 _query: &'a str,
1528 _limit: usize,
1529 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1530 {
1531 Box::pin(async move { Ok(vec![]) })
1532 }
1533
1534 fn as_any(&self) -> &dyn Any {
1535 self
1536 }
1537 }
1538
1539 let registry: Arc<dyn Registry> = Arc::new(InstantRegistry);
1540 let packages = vec![PackageName::new("some-package")];
1541
1542 let result = tokio::time::timeout(
1543 std::time::Duration::from_secs(5),
1544 fetch_latest_versions_parallel(
1545 registry,
1546 with_registry_source(packages),
1547 &HashMap::new(),
1548 None,
1549 deps_core::freshness::FreshnessSettings::default(),
1550 5,
1551 0,
1552 None,
1553 ),
1554 )
1555 .await
1556 .expect("fetch with max_concurrent=0 must not hang forever");
1557
1558 assert!(
1559 result
1560 .no_comparable_versions
1561 .contains(&PackageName::new("some-package")),
1562 "fetch must still run to completion when max_concurrent is 0"
1563 );
1564 }
1565
1566 #[tokio::test]
1567 async fn test_fetch_partial_success_with_mixed_outcomes() {
1568 use deps_core::{Metadata, Registry, Version};
1569 use std::any::Any;
1570 use std::time::Duration;
1571
1572 #[derive(Debug)]
1573 struct MockVersion {
1574 version: ConcreteVersion,
1575 }
1576
1577 impl Version for MockVersion {
1578 fn version_string(&self) -> &ConcreteVersion {
1579 &self.version
1580 }
1581
1582 fn is_prerelease(&self) -> bool {
1583 false
1584 }
1585
1586 fn as_any(&self) -> &dyn Any {
1587 self
1588 }
1589 }
1590
1591 struct MixedOutcomeRegistry;
1592
1593 impl Registry for MixedOutcomeRegistry {
1594 fn get_versions<'a>(
1595 &'a self,
1596 name: &'a deps_core::PackageName,
1597 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1598 {
1599 Box::pin(async move {
1600 match name.as_str() {
1601 "package-fast" => Ok(vec![Box::new(MockVersion {
1602 version: "1.0.0".into(),
1603 }) as Box<dyn Version>]),
1604 "package-slow" => {
1605 tokio::time::sleep(Duration::from_secs(10)).await;
1606 Ok(vec![])
1607 }
1608 "package-error" => Err(deps_core::error::DepsError::CacheError(
1609 "Mock registry error".to_string(),
1610 )),
1611 _ => Ok(vec![]),
1612 }
1613 })
1614 }
1615
1616 fn get_latest_matching<'a>(
1617 &'a self,
1618 name: &'a deps_core::PackageName,
1619 _req: &'a deps_core::VersionReq,
1620 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1621 {
1622 Box::pin(async move {
1623 match name.as_str() {
1624 "package-fast" => Ok(Some(Box::new(MockVersion {
1625 version: "1.0.0".into(),
1626 }) as Box<dyn Version>)),
1627 "package-slow" => {
1628 tokio::time::sleep(Duration::from_secs(10)).await;
1629 Ok(None)
1630 }
1631 "package-error" => Err(deps_core::error::DepsError::CacheError(
1632 "Mock registry error".to_string(),
1633 )),
1634 _ => Ok(None),
1635 }
1636 })
1637 }
1638
1639 fn search_raw<'a>(
1640 &'a self,
1641 _query: &'a str,
1642 _limit: usize,
1643 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1644 {
1645 Box::pin(async move { Ok(vec![]) })
1646 }
1647
1648 fn select_latest_matching(
1649 &self,
1650 versions: &[Box<dyn Version>],
1651 _req: &deps_core::VersionReq,
1652 ) -> Option<usize> {
1653 if versions.is_empty() { None } else { Some(0) }
1657 }
1658
1659 fn as_any(&self) -> &dyn Any {
1660 self
1661 }
1662 }
1663
1664 let registry: Arc<dyn Registry> = Arc::new(MixedOutcomeRegistry);
1665 let packages = vec![
1666 PackageName::new("package-fast"),
1667 PackageName::new("package-slow"),
1668 PackageName::new("package-error"),
1669 ];
1670
1671 let result = fetch_latest_versions_parallel(
1672 registry,
1673 with_registry_source(packages),
1674 &HashMap::new(),
1675 None,
1676 deps_core::freshness::FreshnessSettings::default(),
1677 1,
1678 10,
1679 None,
1680 )
1681 .await;
1682
1683 assert_eq!(
1684 result.versions.len(),
1685 1,
1686 "Should have exactly 1 successful package"
1687 );
1688 assert_eq!(
1689 result
1690 .versions
1691 .get("package-fast")
1692 .map(|v| v.latest.as_str()),
1693 Some("1.0.0"),
1694 "Fast package should have correct version"
1695 );
1696 assert!(
1697 !result.versions.contains_key("package-slow"),
1698 "Slow package should not be in results (timeout)"
1699 );
1700 assert!(
1701 !result.versions.contains_key("package-error"),
1702 "Error package should not be in results"
1703 );
1704 }
1705
1706 #[tokio::test]
1711 async fn test_fetch_latest_versions_parallel_carries_yanked_flag_into_cache() {
1712 use deps_core::{Metadata, Registry, Version};
1713 use std::any::Any;
1714
1715 #[derive(Debug)]
1716 struct MockVersion {
1717 version: ConcreteVersion,
1718 yanked: bool,
1719 }
1720
1721 impl Version for MockVersion {
1722 fn version_string(&self) -> &ConcreteVersion {
1723 &self.version
1724 }
1725 fn removal_status(&self) -> deps_core::RemovalStatus {
1726 deps_core::RemovalStatus::from_yanked(self.yanked)
1727 }
1728 fn as_any(&self) -> &dyn Any {
1729 self
1730 }
1731 }
1732
1733 struct YankedRegistry;
1734
1735 impl Registry for YankedRegistry {
1736 fn get_versions<'a>(
1737 &'a self,
1738 _name: &'a deps_core::PackageName,
1739 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1740 {
1741 Box::pin(async move {
1742 Ok(vec![
1743 Box::new(MockVersion {
1744 version: "1.0.214".into(),
1745 yanked: false,
1746 }) as Box<dyn Version>,
1747 Box::new(MockVersion {
1748 version: "1.0.213".into(),
1749 yanked: true,
1750 }) as Box<dyn Version>,
1751 ])
1752 })
1753 }
1754
1755 fn get_latest_matching<'a>(
1756 &'a self,
1757 _name: &'a deps_core::PackageName,
1758 _req: &'a deps_core::VersionReq,
1759 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1760 {
1761 Box::pin(async move { Ok(None) })
1762 }
1763
1764 fn search_raw<'a>(
1765 &'a self,
1766 _query: &'a str,
1767 _limit: usize,
1768 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1769 {
1770 Box::pin(async move { Ok(vec![]) })
1771 }
1772
1773 fn select_latest_matching(
1774 &self,
1775 versions: &[Box<dyn Version>],
1776 _req: &deps_core::VersionReq,
1777 ) -> Option<usize> {
1778 versions
1779 .iter()
1780 .position(|v| !v.removal_status().blocks_resolution())
1781 }
1782
1783 fn as_any(&self) -> &dyn Any {
1784 self
1785 }
1786 }
1787
1788 let registry: Arc<dyn Registry> = Arc::new(YankedRegistry);
1789 let packages = vec![PackageName::new("serde")];
1790
1791 let result = fetch_latest_versions_parallel(
1792 registry,
1793 with_registry_source(packages),
1794 &HashMap::new(),
1795 None,
1796 deps_core::freshness::FreshnessSettings::default(),
1797 10,
1798 10,
1799 None,
1800 )
1801 .await;
1802
1803 let serde = result
1804 .versions
1805 .get("serde")
1806 .expect("serde should be fetched");
1807 assert_eq!(serde.latest, "1.0.214", "latest must skip the yanked entry");
1808 assert_eq!(
1809 &*serde.available,
1810 &[
1811 ConcreteVersion::new("1.0.214"),
1812 ConcreteVersion::new("1.0.213")
1813 ],
1814 "available must remain unfiltered"
1815 );
1816 assert_eq!(
1817 &*serde.yanked,
1818 &[(
1819 ConcreteVersion::new("1.0.213"),
1820 deps_core::RemovalStatus::Yanked
1821 )],
1822 "yanked must carry only the entries reported as yanked, paired with their status"
1823 );
1824 }
1825
1826 #[tokio::test]
1833 async fn test_fetch_latest_versions_parallel_carries_published_at_for_latest_only() {
1834 use deps_core::freshness::PublishTime;
1835 use deps_core::{Metadata, Registry, Version};
1836 use std::any::Any;
1837
1838 #[derive(Debug)]
1839 struct MockVersion {
1840 version: ConcreteVersion,
1841 yanked: bool,
1842 published_at: Option<PublishTime>,
1843 }
1844
1845 impl Version for MockVersion {
1846 fn version_string(&self) -> &ConcreteVersion {
1847 &self.version
1848 }
1849 fn removal_status(&self) -> deps_core::RemovalStatus {
1850 deps_core::RemovalStatus::from_yanked(self.yanked)
1851 }
1852 fn published_at(&self) -> Option<PublishTime> {
1853 self.published_at
1854 }
1855 fn as_any(&self) -> &dyn Any {
1856 self
1857 }
1858 }
1859
1860 struct DatedRegistry;
1861
1862 impl Registry for DatedRegistry {
1863 fn get_versions<'a>(
1864 &'a self,
1865 _name: &'a deps_core::PackageName,
1866 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1867 {
1868 Box::pin(async move {
1869 Ok(vec![
1870 Box::new(MockVersion {
1871 version: "1.0.214".into(),
1872 yanked: false,
1873 published_at: Some(PublishTime::from_unix_secs(2_000)),
1874 }) as Box<dyn Version>,
1875 Box::new(MockVersion {
1876 version: "1.0.213".into(),
1877 yanked: true,
1878 published_at: Some(PublishTime::from_unix_secs(1_000)),
1881 }) as Box<dyn Version>,
1882 ])
1883 })
1884 }
1885
1886 fn get_latest_matching<'a>(
1887 &'a self,
1888 _name: &'a deps_core::PackageName,
1889 _req: &'a deps_core::VersionReq,
1890 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
1891 {
1892 Box::pin(async move { Ok(None) })
1893 }
1894
1895 fn search_raw<'a>(
1896 &'a self,
1897 _query: &'a str,
1898 _limit: usize,
1899 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
1900 {
1901 Box::pin(async move { Ok(vec![]) })
1902 }
1903
1904 fn select_latest_matching(
1905 &self,
1906 versions: &[Box<dyn Version>],
1907 _req: &deps_core::VersionReq,
1908 ) -> Option<usize> {
1909 versions
1910 .iter()
1911 .position(|v| !v.removal_status().blocks_resolution())
1912 }
1913
1914 fn as_any(&self) -> &dyn Any {
1915 self
1916 }
1917 }
1918
1919 let registry: Arc<dyn Registry> = Arc::new(DatedRegistry);
1920 let packages = vec![PackageName::new("serde")];
1921
1922 let result = fetch_latest_versions_parallel(
1923 registry,
1924 with_registry_source(packages),
1925 &HashMap::new(),
1926 None,
1927 deps_core::freshness::FreshnessSettings::default(),
1928 10,
1929 10,
1930 None,
1931 )
1932 .await;
1933
1934 let serde = result
1935 .versions
1936 .get("serde")
1937 .expect("serde should be fetched");
1938 assert_eq!(serde.latest, "1.0.214");
1939 assert_eq!(
1940 serde.published_at,
1941 Some(PublishTime::from_unix_secs(2_000)),
1942 "published_at must be 1.0.214's own timestamp, not the yanked 1.0.213 entry's"
1943 );
1944 }
1945
1946 #[tokio::test]
1956 async fn test_fetch_latest_versions_parallel_carries_license_into_fetch_result() {
1957 use deps_core::{Metadata, Registry, Version};
1958 use std::any::Any;
1959
1960 #[derive(Debug)]
1961 struct MockVersion {
1962 version: ConcreteVersion,
1963 license: Vec<String>,
1964 }
1965
1966 impl Version for MockVersion {
1967 fn version_string(&self) -> &ConcreteVersion {
1968 &self.version
1969 }
1970 fn as_any(&self) -> &dyn Any {
1971 self
1972 }
1973 fn license(&self) -> &[String] {
1974 &self.license
1975 }
1976 }
1977
1978 struct LicensedRegistry;
1979
1980 impl Registry for LicensedRegistry {
1981 fn get_versions<'a>(
1982 &'a self,
1983 name: &'a PackageName,
1984 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
1985 {
1986 let license = if name.as_str() == "licensed-pkg" {
1987 vec!["MIT".to_string()]
1988 } else {
1989 vec![]
1990 };
1991 Box::pin(async move {
1992 Ok(vec![Box::new(MockVersion {
1993 version: "1.0.0".into(),
1994 license,
1995 }) as Box<dyn Version>])
1996 })
1997 }
1998
1999 fn get_latest_matching<'a>(
2000 &'a self,
2001 _name: &'a PackageName,
2002 _req: &'a deps_core::VersionReq,
2003 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2004 {
2005 Box::pin(async move { Ok(None) })
2006 }
2007
2008 fn search_raw<'a>(
2009 &'a self,
2010 _query: &'a str,
2011 _limit: usize,
2012 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2013 {
2014 Box::pin(async move { Ok(vec![]) })
2015 }
2016
2017 fn select_latest_matching(
2018 &self,
2019 versions: &[Box<dyn Version>],
2020 _req: &deps_core::VersionReq,
2021 ) -> Option<usize> {
2022 (!versions.is_empty()).then_some(0)
2023 }
2024
2025 fn as_any(&self) -> &dyn Any {
2026 self
2027 }
2028 }
2029
2030 let registry: Arc<dyn Registry> = Arc::new(LicensedRegistry);
2031 let packages = vec![
2032 PackageName::new("licensed-pkg"),
2033 PackageName::new("unlicensed-pkg"),
2034 ];
2035
2036 let result = fetch_latest_versions_parallel(
2037 registry,
2038 with_registry_source(packages),
2039 &HashMap::new(),
2040 None,
2041 deps_core::freshness::FreshnessSettings::default(),
2042 10,
2043 10,
2044 None,
2045 )
2046 .await;
2047
2048 assert_eq!(
2049 result.licenses.get(&PackageName::new("licensed-pkg")),
2050 Some(&vec!["MIT".to_string()])
2051 );
2052 assert!(
2053 !result
2054 .licenses
2055 .contains_key(&PackageName::new("unlicensed-pkg")),
2056 "an empty Version::license() must produce no entry, not an empty-vec one"
2057 );
2058 }
2059
2060 #[tokio::test]
2066 async fn test_fetch_latest_versions_parallel_uses_get_versions_with_for_freshness() {
2067 use deps_core::freshness::{FreshnessSettings, PublishTime};
2068 use deps_core::{Metadata, Registry, Version};
2069 use std::any::Any;
2070
2071 #[derive(Debug)]
2072 struct MockVersion {
2073 version: ConcreteVersion,
2074 published_at: Option<PublishTime>,
2075 }
2076
2077 impl Version for MockVersion {
2078 fn version_string(&self) -> &ConcreteVersion {
2079 &self.version
2080 }
2081 fn published_at(&self) -> Option<PublishTime> {
2082 self.published_at
2083 }
2084 fn as_any(&self) -> &dyn Any {
2085 self
2086 }
2087 }
2088
2089 struct FreshnessAwareRegistry;
2090
2091 impl Registry for FreshnessAwareRegistry {
2092 fn get_versions<'a>(
2093 &'a self,
2094 _name: &'a deps_core::PackageName,
2095 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2096 {
2097 Box::pin(async move {
2100 Ok(vec![Box::new(MockVersion {
2101 version: "1.0.0".into(),
2102 published_at: None,
2103 }) as Box<dyn Version>])
2104 })
2105 }
2106
2107 fn get_versions_with<'a>(
2108 &'a self,
2109 _name: &'a deps_core::PackageName,
2110 freshness: FreshnessSettings,
2111 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2112 {
2113 Box::pin(async move {
2114 Ok(vec![Box::new(MockVersion {
2115 version: "1.0.0".into(),
2116 published_at: freshness
2117 .enabled
2118 .then(|| PublishTime::from_unix_secs(5_000)),
2119 }) as Box<dyn Version>])
2120 })
2121 }
2122
2123 fn get_latest_matching<'a>(
2124 &'a self,
2125 _name: &'a deps_core::PackageName,
2126 _req: &'a deps_core::VersionReq,
2127 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2128 {
2129 Box::pin(async move { Ok(None) })
2130 }
2131
2132 fn search_raw<'a>(
2133 &'a self,
2134 _query: &'a str,
2135 _limit: usize,
2136 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2137 {
2138 Box::pin(async move { Ok(vec![]) })
2139 }
2140
2141 fn select_latest_matching(
2142 &self,
2143 versions: &[Box<dyn Version>],
2144 _req: &deps_core::VersionReq,
2145 ) -> Option<usize> {
2146 if versions.is_empty() { None } else { Some(0) }
2147 }
2148
2149 fn as_any(&self) -> &dyn Any {
2150 self
2151 }
2152 }
2153
2154 let registry: Arc<dyn Registry> = Arc::new(FreshnessAwareRegistry);
2155 let packages = vec![PackageName::new("widget")];
2156
2157 let result = fetch_latest_versions_parallel(
2158 registry,
2159 with_registry_source(packages),
2160 &HashMap::new(),
2161 None,
2162 FreshnessSettings::default(),
2163 10,
2164 10,
2165 None,
2166 )
2167 .await;
2168
2169 let widget = result
2170 .versions
2171 .get("widget")
2172 .expect("widget should be fetched");
2173 assert_eq!(
2174 widget.published_at,
2175 Some(PublishTime::from_unix_secs(5_000)),
2176 "published_at must come from get_versions_with, not the freshness-blind \
2177 get_versions (#339)"
2178 );
2179 }
2180
2181 #[tokio::test]
2187 async fn test_fetch_latest_versions_parallel_threads_minimum_stability_into_select_latest_matching_with_context()
2188 {
2189 use deps_core::{Metadata, Registry, Version};
2190 use std::any::Any;
2191 use std::sync::Mutex;
2192
2193 #[derive(Debug)]
2194 struct MockVersion {
2195 version: ConcreteVersion,
2196 }
2197
2198 impl Version for MockVersion {
2199 fn version_string(&self) -> &ConcreteVersion {
2200 &self.version
2201 }
2202 fn as_any(&self) -> &dyn Any {
2203 self
2204 }
2205 }
2206
2207 struct ContextAwareRegistry {
2208 seen_minimum_stability: Mutex<Vec<Option<String>>>,
2211 }
2212
2213 impl Registry for ContextAwareRegistry {
2214 fn get_versions<'a>(
2215 &'a self,
2216 _name: &'a deps_core::PackageName,
2217 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2218 {
2219 Box::pin(async move {
2220 Ok(vec![Box::new(MockVersion {
2221 version: "1.0.0".into(),
2222 }) as Box<dyn Version>])
2223 })
2224 }
2225
2226 fn get_latest_matching<'a>(
2227 &'a self,
2228 _name: &'a deps_core::PackageName,
2229 _req: &'a deps_core::VersionReq,
2230 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2231 {
2232 Box::pin(async move { Ok(None) })
2233 }
2234
2235 fn search_raw<'a>(
2236 &'a self,
2237 _query: &'a str,
2238 _limit: usize,
2239 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2240 {
2241 Box::pin(async move { Ok(vec![]) })
2242 }
2243
2244 fn select_latest_matching_with_context(
2249 &self,
2250 versions: &[Box<dyn Version>],
2251 _req: &deps_core::VersionReq,
2252 minimum_stability: Option<&str>,
2253 ) -> Option<usize> {
2254 self.seen_minimum_stability
2255 .lock()
2256 .unwrap_or_else(|p| p.into_inner())
2257 .push(minimum_stability.map(str::to_string));
2258 if versions.is_empty() { None } else { Some(0) }
2259 }
2260
2261 fn as_any(&self) -> &dyn Any {
2262 self
2263 }
2264 }
2265
2266 let registry = Arc::new(ContextAwareRegistry {
2267 seen_minimum_stability: Mutex::new(Vec::new()),
2268 });
2269 let packages = vec![PackageName::new("vendor/pkg")];
2270
2271 let result = fetch_latest_versions_parallel(
2272 Arc::clone(®istry) as Arc<dyn Registry>,
2273 with_registry_source(packages),
2274 &HashMap::new(),
2275 None,
2276 deps_core::freshness::FreshnessSettings::default(),
2277 10,
2278 10,
2279 Some("beta"),
2280 )
2281 .await;
2282
2283 assert_eq!(
2284 *registry
2285 .seen_minimum_stability
2286 .lock()
2287 .unwrap_or_else(|p| p.into_inner()),
2288 vec![Some("beta".to_string())],
2289 "select_latest_matching_with_context must receive the caller's minimum_stability"
2290 );
2291 assert!(
2292 result.versions.contains_key("vendor/pkg"),
2293 "the pick must still succeed via the _with_context path"
2294 );
2295 }
2296
2297 #[tokio::test]
2301 async fn test_fetch_latest_versions_parallel_threads_minimum_stability_into_get_latest_matching_with_context()
2302 {
2303 use deps_core::{Metadata, Registry, Version};
2304 use std::any::Any;
2305 use std::sync::Mutex;
2306
2307 #[derive(Debug)]
2308 struct MockVersion {
2309 version: ConcreteVersion,
2310 }
2311
2312 impl Version for MockVersion {
2313 fn version_string(&self) -> &ConcreteVersion {
2314 &self.version
2315 }
2316 fn as_any(&self) -> &dyn Any {
2317 self
2318 }
2319 }
2320
2321 struct FallbackContextAwareRegistry {
2322 seen_minimum_stability: Mutex<Vec<Option<String>>>,
2325 }
2326
2327 impl Registry for FallbackContextAwareRegistry {
2328 fn get_versions<'a>(
2329 &'a self,
2330 _name: &'a deps_core::PackageName,
2331 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2332 {
2333 Box::pin(async move { Ok(vec![]) })
2336 }
2337
2338 fn get_latest_matching<'a>(
2339 &'a self,
2340 _name: &'a deps_core::PackageName,
2341 _req: &'a deps_core::VersionReq,
2342 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2343 {
2344 Box::pin(async move { Ok(None) })
2345 }
2346
2347 fn get_latest_matching_with_context<'a>(
2348 &'a self,
2349 _name: &'a deps_core::PackageName,
2350 _req: &'a deps_core::VersionReq,
2351 minimum_stability: Option<&'a str>,
2352 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2353 {
2354 self.seen_minimum_stability
2355 .lock()
2356 .unwrap_or_else(|p| p.into_inner())
2357 .push(minimum_stability.map(str::to_string));
2358 Box::pin(async move {
2359 Ok(Some(Box::new(MockVersion {
2360 version: "2.0.0-beta1".into(),
2361 }) as Box<dyn Version>))
2362 })
2363 }
2364
2365 fn search_raw<'a>(
2366 &'a self,
2367 _query: &'a str,
2368 _limit: usize,
2369 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2370 {
2371 Box::pin(async move { Ok(vec![]) })
2372 }
2373
2374 fn as_any(&self) -> &dyn Any {
2375 self
2376 }
2377 }
2378
2379 let registry = Arc::new(FallbackContextAwareRegistry {
2380 seen_minimum_stability: Mutex::new(Vec::new()),
2381 });
2382 let packages = vec![PackageName::new("vendor/pkg")];
2383
2384 let result = fetch_latest_versions_parallel(
2385 Arc::clone(®istry) as Arc<dyn Registry>,
2386 with_registry_source(packages),
2387 &HashMap::new(),
2388 None,
2389 deps_core::freshness::FreshnessSettings::default(),
2390 10,
2391 10,
2392 Some("beta"),
2393 )
2394 .await;
2395
2396 assert_eq!(
2397 *registry
2398 .seen_minimum_stability
2399 .lock()
2400 .unwrap_or_else(|p| p.into_inner()),
2401 vec![Some("beta".to_string())],
2402 "get_latest_matching_with_context must receive the caller's minimum_stability"
2403 );
2404 let widget = result
2405 .versions
2406 .get("vendor/pkg")
2407 .expect("fallback pick should succeed");
2408 assert_eq!(widget.latest, "2.0.0-beta1");
2409 }
2410
2411 #[tokio::test]
2417 async fn test_fetch_falls_back_to_get_latest_matching_when_list_based_pick_finds_nothing() {
2418 use deps_core::{Metadata, Registry, Version};
2419 use std::any::Any;
2420
2421 #[derive(Debug)]
2422 struct MockVersion {
2423 version: ConcreteVersion,
2424 }
2425
2426 impl Version for MockVersion {
2427 fn version_string(&self) -> &ConcreteVersion {
2428 &self.version
2429 }
2430 fn as_any(&self) -> &dyn Any {
2431 self
2432 }
2433 }
2434
2435 struct UntaggedModuleRegistry;
2441
2442 impl Registry for UntaggedModuleRegistry {
2443 fn get_versions<'a>(
2444 &'a self,
2445 _name: &'a deps_core::PackageName,
2446 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2447 {
2448 Box::pin(async move { Ok(vec![]) })
2449 }
2450
2451 fn get_latest_matching<'a>(
2452 &'a self,
2453 _name: &'a deps_core::PackageName,
2454 _req: &'a deps_core::VersionReq,
2455 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2456 {
2457 Box::pin(async move {
2458 Ok(Some(Box::new(MockVersion {
2459 version: "v0.0.0-20191109021931-daa7c04131f5".into(),
2460 }) as Box<dyn Version>))
2461 })
2462 }
2463
2464 fn search_raw<'a>(
2465 &'a self,
2466 _query: &'a str,
2467 _limit: usize,
2468 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2469 {
2470 Box::pin(async move { Ok(vec![]) })
2471 }
2472
2473 fn as_any(&self) -> &dyn Any {
2474 self
2475 }
2476 }
2477
2478 let registry: Arc<dyn Registry> = Arc::new(UntaggedModuleRegistry);
2479 let packages = vec![PackageName::new("golang.org/x/exp")];
2480
2481 let result = fetch_latest_versions_parallel(
2482 registry,
2483 with_registry_source(packages),
2484 &HashMap::new(),
2485 None,
2486 deps_core::freshness::FreshnessSettings::default(),
2487 5,
2488 10,
2489 None,
2490 )
2491 .await;
2492
2493 assert_eq!(
2494 result
2495 .versions
2496 .get("golang.org/x/exp")
2497 .map(|v| v.latest.as_str()),
2498 Some("v0.0.0-20191109021931-daa7c04131f5"),
2499 "must fall back to get_latest_matching instead of reporting no version found"
2500 );
2501 }
2502
2503 #[tokio::test]
2504 async fn test_fetch_registry_error_handled() {
2505 use deps_core::{Metadata, Registry, Version};
2506 use std::any::Any;
2507
2508 struct ErrorRegistry;
2509
2510 impl Registry for ErrorRegistry {
2511 fn get_versions<'a>(
2512 &'a self,
2513 name: &'a deps_core::PackageName,
2514 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2515 {
2516 Box::pin(async move {
2517 Err(deps_core::error::DepsError::CacheError(format!(
2518 "Failed to fetch package: {}",
2519 name.as_str()
2520 )))
2521 })
2522 }
2523
2524 fn get_latest_matching<'a>(
2525 &'a self,
2526 name: &'a deps_core::PackageName,
2527 _req: &'a deps_core::VersionReq,
2528 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2529 {
2530 Box::pin(async move {
2531 Err(deps_core::error::DepsError::CacheError(format!(
2532 "Failed to fetch package: {}",
2533 name.as_str()
2534 )))
2535 })
2536 }
2537
2538 fn search_raw<'a>(
2539 &'a self,
2540 _query: &'a str,
2541 _limit: usize,
2542 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2543 {
2544 Box::pin(async move { Ok(vec![]) })
2545 }
2546
2547 fn as_any(&self) -> &dyn Any {
2548 self
2549 }
2550 }
2551
2552 let registry: Arc<dyn Registry> = Arc::new(ErrorRegistry);
2553 let packages = vec![
2554 PackageName::new("package-1"),
2555 PackageName::new("package-2"),
2556 PackageName::new("package-3"),
2557 ];
2558
2559 let result = fetch_latest_versions_parallel(
2560 registry,
2561 with_registry_source(packages),
2562 &HashMap::new(),
2563 None,
2564 deps_core::freshness::FreshnessSettings::default(),
2565 5,
2566 10,
2567 None,
2568 )
2569 .await;
2570
2571 assert!(
2572 result.versions.is_empty(),
2573 "All packages with errors should be omitted from results"
2574 );
2575 assert_eq!(
2576 result.failed_count, 3,
2577 "All 3 packages should be marked as failed"
2578 );
2579 assert_eq!(
2583 result.fetch_failed,
2584 HashMap::from([
2585 (PackageName::new("package-1"), FetchFailure::Transient),
2586 (PackageName::new("package-2"), FetchFailure::Transient),
2587 (PackageName::new("package-3"), FetchFailure::Transient),
2588 ]),
2589 "every errored package must be recorded in fetch_failed"
2590 );
2591 }
2592
2593 #[tokio::test]
2602 async fn test_fetch_failed_log_redacts_credential_shaped_package_name() {
2603 use deps_core::{Metadata, Registry, Version};
2604 use std::any::Any;
2605
2606 #[tracing::instrument(skip_all, fields(package = %name.for_tracing()), level = "debug")]
2616 async fn inner_fetch(name: &PackageName) -> deps_core::Result<Vec<Box<dyn Version>>> {
2617 tracing::debug!("mock registry fetch invoked");
2622 Err(deps_core::error::DepsError::CacheError(
2623 "transient backend failure".to_string(),
2624 ))
2625 }
2626
2627 struct AlwaysFailsRegistry;
2628
2629 impl Registry for AlwaysFailsRegistry {
2630 fn get_versions<'a>(
2631 &'a self,
2632 name: &'a deps_core::PackageName,
2633 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2634 {
2635 Box::pin(inner_fetch(name))
2636 }
2637
2638 fn get_latest_matching<'a>(
2639 &'a self,
2640 _name: &'a deps_core::PackageName,
2641 _req: &'a deps_core::VersionReq,
2642 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2643 {
2644 Box::pin(async move {
2645 Err(deps_core::error::DepsError::CacheError(
2646 "transient backend failure".to_string(),
2647 ))
2648 })
2649 }
2650
2651 fn search_raw<'a>(
2652 &'a self,
2653 _query: &'a str,
2654 _limit: usize,
2655 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2656 {
2657 Box::pin(async move { Ok(vec![]) })
2658 }
2659
2660 fn as_any(&self) -> &dyn Any {
2661 self
2662 }
2663 }
2664
2665 let sentinel_name =
2666 PackageName::new("com.example:deploy:AUDITSENTINEL0000@git.internal.corp");
2667 let registry: Arc<dyn Registry> = Arc::new(AlwaysFailsRegistry);
2668 let packages = vec![sentinel_name.clone()];
2669
2670 let log =
2671 deps_core::test_util::capture_tracing_output_async_at(tracing::Level::DEBUG, async {
2672 let result = fetch_latest_versions_parallel(
2673 registry,
2674 with_registry_source(packages),
2675 &HashMap::new(),
2676 None,
2677 deps_core::freshness::FreshnessSettings::default(),
2678 5,
2679 10,
2680 None,
2681 )
2682 .await;
2683 assert_eq!(result.failed_count, 1);
2684 })
2685 .await;
2686
2687 assert!(
2688 log.contains("fetch failed"),
2689 "expected the fetch-failed WARN to fire: {log:?}"
2690 );
2691 assert!(
2692 log.contains("mock registry fetch invoked"),
2693 "expected the in-span event to fire — without it the span's fields never render, \
2694 silently downgrading this test back to event-field-only coverage: {log:?}"
2695 );
2696 assert!(
2697 !log.contains("AUDITSENTINEL0000"),
2698 "tracing output leaked a credential-shaped package name: {log:?}"
2699 );
2700 assert!(
2701 log.contains("git.internal.corp"),
2702 "host should survive redaction: {log:?}"
2703 );
2704 }
2705
2706 #[tokio::test]
2707 async fn test_fetch_not_found_is_not_recorded_as_fetch_failed() {
2708 use deps_core::{Metadata, Registry, Version};
2716 use std::any::Any;
2717
2718 struct NotFoundRegistry;
2719
2720 impl Registry for NotFoundRegistry {
2721 fn get_versions<'a>(
2722 &'a self,
2723 name: &'a deps_core::PackageName,
2724 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2725 {
2726 Box::pin(async move {
2727 Err(deps_core::error::DepsError::PackageNotFound {
2728 package: name.as_str().into(),
2729 registry: "mock",
2730 })
2731 })
2732 }
2733
2734 fn get_latest_matching<'a>(
2735 &'a self,
2736 name: &'a deps_core::PackageName,
2737 _req: &'a deps_core::VersionReq,
2738 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2739 {
2740 Box::pin(async move {
2741 Err(deps_core::error::DepsError::PackageNotFound {
2742 package: name.as_str().into(),
2743 registry: "mock",
2744 })
2745 })
2746 }
2747
2748 fn search_raw<'a>(
2749 &'a self,
2750 _query: &'a str,
2751 _limit: usize,
2752 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2753 {
2754 Box::pin(async move { Ok(vec![]) })
2755 }
2756
2757 fn as_any(&self) -> &dyn Any {
2758 self
2759 }
2760 }
2761
2762 let registry: Arc<dyn Registry> = Arc::new(NotFoundRegistry);
2763 let packages = vec![PackageName::new("typo-pkg")];
2764
2765 let result = fetch_latest_versions_parallel(
2766 registry,
2767 with_registry_source(packages),
2768 &HashMap::new(),
2769 None,
2770 deps_core::freshness::FreshnessSettings::default(),
2771 5,
2772 10,
2773 None,
2774 )
2775 .await;
2776
2777 assert!(result.versions.is_empty());
2778 assert!(
2779 result.fetch_failed.is_empty(),
2780 "a genuine not-found must not be recorded in fetch_failed, or \
2781 generate_diagnostics_from_cache would report it as a registry \
2782 error instead of Unknown package"
2783 );
2784 }
2785
2786 #[tokio::test]
2794 async fn test_fetch_success_with_zero_versions_is_recorded_as_no_comparable_versions() {
2795 use deps_core::{Metadata, Registry, Version};
2796 use std::any::Any;
2797
2798 struct EmptyButRealRegistry;
2799
2800 impl Registry for EmptyButRealRegistry {
2801 fn get_versions<'a>(
2802 &'a self,
2803 _name: &'a deps_core::PackageName,
2804 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2805 {
2806 Box::pin(async move { Ok(vec![]) })
2807 }
2808
2809 fn get_latest_matching<'a>(
2810 &'a self,
2811 _name: &'a deps_core::PackageName,
2812 _req: &'a deps_core::VersionReq,
2813 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2814 {
2815 Box::pin(async move { Ok(None) })
2816 }
2817
2818 fn search_raw<'a>(
2819 &'a self,
2820 _query: &'a str,
2821 _limit: usize,
2822 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2823 {
2824 Box::pin(async move { Ok(vec![]) })
2825 }
2826
2827 fn as_any(&self) -> &dyn Any {
2828 self
2829 }
2830 }
2831
2832 let registry: Arc<dyn Registry> = Arc::new(EmptyButRealRegistry);
2833 let packages = vec![PackageName::new("dtolnay/rust-toolchain")];
2834
2835 let result = fetch_latest_versions_parallel(
2836 registry,
2837 with_registry_source(packages),
2838 &HashMap::new(),
2839 None,
2840 deps_core::freshness::FreshnessSettings::default(),
2841 5,
2842 10,
2843 None,
2844 )
2845 .await;
2846
2847 assert!(result.versions.is_empty());
2848 assert!(
2849 result.fetch_failed.is_empty(),
2850 "a genuine empty-but-successful fetch must not be recorded as a fetch \
2851 failure, or generate_diagnostics_from_cache would report a registry \
2852 error instead of nothing"
2853 );
2854 assert!(
2855 result
2856 .no_comparable_versions
2857 .contains(&PackageName::new("dtolnay/rust-toolchain")),
2858 "a package whose fetch succeeded with zero comparable versions must be \
2859 recorded in no_comparable_versions, or R5 would misreport it as Unknown \
2860 package; got: {:?}",
2861 result.no_comparable_versions
2862 );
2863 }
2864
2865 #[tokio::test]
2866 async fn test_fetch_http_404_is_not_recorded_as_fetch_failed() {
2867 use deps_core::{Metadata, Registry, Version};
2872 use std::any::Any;
2873
2874 struct Http404Registry;
2875
2876 impl Registry for Http404Registry {
2877 fn get_versions<'a>(
2878 &'a self,
2879 name: &'a deps_core::PackageName,
2880 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2881 {
2882 Box::pin(async move {
2883 Err(deps_core::error::DepsError::HttpStatus {
2884 url: format!("https://example.com/{}", name.as_str()).into(),
2885 status: 404,
2886 })
2887 })
2888 }
2889
2890 fn get_latest_matching<'a>(
2891 &'a self,
2892 name: &'a deps_core::PackageName,
2893 _req: &'a deps_core::VersionReq,
2894 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2895 {
2896 Box::pin(async move {
2897 Err(deps_core::error::DepsError::HttpStatus {
2898 url: format!("https://example.com/{}", name.as_str()).into(),
2899 status: 404,
2900 })
2901 })
2902 }
2903
2904 fn search_raw<'a>(
2905 &'a self,
2906 _query: &'a str,
2907 _limit: usize,
2908 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2909 {
2910 Box::pin(async move { Ok(vec![]) })
2911 }
2912
2913 fn as_any(&self) -> &dyn Any {
2914 self
2915 }
2916 }
2917
2918 let registry: Arc<dyn Registry> = Arc::new(Http404Registry);
2919 let packages = vec![PackageName::new("typo-pkg")];
2920
2921 let result = fetch_latest_versions_parallel(
2922 registry,
2923 with_registry_source(packages),
2924 &HashMap::new(),
2925 None,
2926 deps_core::freshness::FreshnessSettings::default(),
2927 5,
2928 10,
2929 None,
2930 )
2931 .await;
2932
2933 assert!(result.versions.is_empty());
2934 assert!(
2935 result.fetch_failed.is_empty(),
2936 "a bare HTTP 404 must not be recorded in fetch_failed either"
2937 );
2938 }
2939
2940 #[tokio::test]
2941 async fn test_fetch_fallback_error_recorded_as_fetch_failed_unless_not_found() {
2942 use deps_core::{Metadata, Registry, Version};
2949 use std::any::Any;
2950
2951 struct FallbackErrorRegistry;
2952
2953 impl Registry for FallbackErrorRegistry {
2954 fn get_versions<'a>(
2955 &'a self,
2956 _name: &'a deps_core::PackageName,
2957 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
2958 {
2959 Box::pin(async move { Ok(vec![]) })
2960 }
2961
2962 fn get_latest_matching<'a>(
2963 &'a self,
2964 name: &'a deps_core::PackageName,
2965 _req: &'a deps_core::VersionReq,
2966 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
2967 {
2968 let name = name.clone();
2969 Box::pin(async move {
2970 if name.as_str() == "not-found" {
2971 Err(deps_core::error::DepsError::PackageNotFound {
2972 package: name.as_str().into(),
2973 registry: "mock",
2974 })
2975 } else {
2976 Err(deps_core::error::DepsError::CacheError(
2977 "mock fallback failure".to_string(),
2978 ))
2979 }
2980 })
2981 }
2982
2983 fn search_raw<'a>(
2984 &'a self,
2985 _query: &'a str,
2986 _limit: usize,
2987 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
2988 {
2989 Box::pin(async move { Ok(vec![]) })
2990 }
2991
2992 fn as_any(&self) -> &dyn Any {
2993 self
2994 }
2995 }
2996
2997 let registry: Arc<dyn Registry> = Arc::new(FallbackErrorRegistry);
2998 let packages = vec![PackageName::new("flaky"), PackageName::new("not-found")];
2999
3000 let result = fetch_latest_versions_parallel(
3001 registry,
3002 with_registry_source(packages),
3003 &HashMap::new(),
3004 None,
3005 deps_core::freshness::FreshnessSettings::default(),
3006 5,
3007 10,
3008 None,
3009 )
3010 .await;
3011
3012 assert!(result.versions.is_empty());
3013 assert_eq!(
3014 result.fetch_failed,
3015 HashMap::from([(PackageName::new("flaky"), FetchFailure::Transient)]),
3016 "the fallback's own non-not-found error must be recorded in fetch_failed, \
3017 but its not-found error must not"
3018 );
3019 assert_eq!(
3020 result.failed_count, 2,
3021 "both fallback failures count toward failed_count regardless of cause (S2)"
3022 );
3023 }
3024
3025 #[tokio::test]
3026 async fn test_fetch_fallback_timeout_recorded_as_fetch_failed() {
3027 use deps_core::{Metadata, Registry, Version};
3031 use std::any::Any;
3032 use std::time::Duration;
3033
3034 struct FallbackTimeoutRegistry;
3035
3036 impl Registry for FallbackTimeoutRegistry {
3037 fn get_versions<'a>(
3038 &'a self,
3039 _name: &'a deps_core::PackageName,
3040 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3041 {
3042 Box::pin(async move { Ok(vec![]) })
3043 }
3044
3045 fn get_latest_matching<'a>(
3046 &'a self,
3047 _name: &'a deps_core::PackageName,
3048 _req: &'a deps_core::VersionReq,
3049 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3050 {
3051 Box::pin(async move {
3052 tokio::time::sleep(Duration::from_secs(10)).await;
3053 Ok(None)
3054 })
3055 }
3056
3057 fn search_raw<'a>(
3058 &'a self,
3059 _query: &'a str,
3060 _limit: usize,
3061 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3062 {
3063 Box::pin(async move { Ok(vec![]) })
3064 }
3065
3066 fn as_any(&self) -> &dyn Any {
3067 self
3068 }
3069 }
3070
3071 let registry: Arc<dyn Registry> = Arc::new(FallbackTimeoutRegistry);
3072 let packages = vec![PackageName::new("slow-fallback")];
3073
3074 let result = fetch_latest_versions_parallel(
3075 registry,
3076 with_registry_source(packages),
3077 &HashMap::new(),
3078 None,
3079 deps_core::freshness::FreshnessSettings::default(),
3080 1,
3081 10,
3082 None,
3083 )
3084 .await;
3085
3086 assert!(result.versions.is_empty());
3087 assert_eq!(
3088 result.fetch_failed,
3089 HashMap::from([(PackageName::new("slow-fallback"), FetchFailure::Transient)])
3090 );
3091 assert_eq!(result.failed_count, 1);
3092 }
3093
3094 #[tokio::test]
3095 async fn test_first_error_prefers_actionable_error_over_not_found_regardless_of_race_order() {
3096 use deps_core::{Metadata, Registry, Version};
3101 use std::any::Any;
3102 use std::time::Duration;
3103
3104 struct MixedErrorRegistry;
3105
3106 impl Registry for MixedErrorRegistry {
3107 fn get_versions<'a>(
3108 &'a self,
3109 name: &'a deps_core::PackageName,
3110 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3111 {
3112 Box::pin(async move {
3113 if name.as_str() == "typo-pkg" {
3114 Err(deps_core::error::DepsError::PackageNotFound {
3115 package: name.as_str().into(),
3116 registry: "mock",
3117 })
3118 } else {
3119 tokio::time::sleep(Duration::from_millis(50)).await;
3120 Err(deps_core::error::DepsError::CacheError(
3121 "rate limit exceeded".to_string(),
3122 ))
3123 }
3124 })
3125 }
3126
3127 fn get_latest_matching<'a>(
3128 &'a self,
3129 _name: &'a deps_core::PackageName,
3130 _req: &'a deps_core::VersionReq,
3131 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3132 {
3133 Box::pin(async move { Ok(None) })
3134 }
3135
3136 fn search_raw<'a>(
3137 &'a self,
3138 _query: &'a str,
3139 _limit: usize,
3140 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3141 {
3142 Box::pin(async move { Ok(vec![]) })
3143 }
3144
3145 fn as_any(&self) -> &dyn Any {
3146 self
3147 }
3148 }
3149
3150 let registry: Arc<dyn Registry> = Arc::new(MixedErrorRegistry);
3151 let packages = vec![
3152 PackageName::new("typo-pkg"),
3153 PackageName::new("rate-limited"),
3154 ];
3155
3156 let result = fetch_latest_versions_parallel(
3157 registry,
3158 with_registry_source(packages),
3159 &HashMap::new(),
3160 None,
3161 deps_core::freshness::FreshnessSettings::default(),
3162 5,
3163 10,
3164 None,
3165 )
3166 .await;
3167
3168 let err = result
3169 .first_error
3170 .expect("an actionable failure occurred and must be reported");
3171 assert!(
3172 err.contains("rate limit exceeded"),
3173 "the actionable error must win the toast over the faster-finishing not-found, \
3174 got: {err}"
3175 );
3176 assert!(
3177 !err.contains("not found"),
3178 "a not-found error must never outrank an actionable error, got: {err}"
3179 );
3180 }
3181
3182 #[tokio::test]
3183 async fn test_first_error_falls_back_to_not_found_when_no_actionable_error_occurred() {
3184 use deps_core::{Metadata, Registry, Version};
3189 use std::any::Any;
3190
3191 struct AllNotFoundRegistry;
3192
3193 impl Registry for AllNotFoundRegistry {
3194 fn get_versions<'a>(
3195 &'a self,
3196 name: &'a deps_core::PackageName,
3197 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3198 {
3199 Box::pin(async move {
3200 Err(deps_core::error::DepsError::PackageNotFound {
3201 package: name.as_str().into(),
3202 registry: "mock",
3203 })
3204 })
3205 }
3206
3207 fn get_latest_matching<'a>(
3208 &'a self,
3209 _name: &'a deps_core::PackageName,
3210 _req: &'a deps_core::VersionReq,
3211 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3212 {
3213 Box::pin(async move { Ok(None) })
3214 }
3215
3216 fn search_raw<'a>(
3217 &'a self,
3218 _query: &'a str,
3219 _limit: usize,
3220 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3221 {
3222 Box::pin(async move { Ok(vec![]) })
3223 }
3224
3225 fn as_any(&self) -> &dyn Any {
3226 self
3227 }
3228 }
3229
3230 let registry: Arc<dyn Registry> = Arc::new(AllNotFoundRegistry);
3231 let packages = vec![
3232 PackageName::new("typo-pkg-1"),
3233 PackageName::new("typo-pkg-2"),
3234 ];
3235
3236 let result = fetch_latest_versions_parallel(
3237 registry,
3238 with_registry_source(packages),
3239 &HashMap::new(),
3240 None,
3241 deps_core::freshness::FreshnessSettings::default(),
3242 5,
3243 10,
3244 None,
3245 )
3246 .await;
3247
3248 assert!(
3249 result.fetch_failed.is_empty(),
3250 "not-found errors must never be recorded in fetch_failed"
3251 );
3252 let err = result
3253 .first_error
3254 .expect("a not-found-only batch must still fall back to reporting one via first_error");
3255 assert!(err.contains("not found"), "got: {err}");
3256 }
3257
3258 #[tokio::test]
3259 async fn test_timeout_only_batch_reports_first_error_alongside_failed_count() {
3260 use deps_core::{Metadata, Registry, Version};
3265 use std::any::Any;
3266 use std::time::Duration;
3267
3268 struct AlwaysTimesOutRegistry;
3269
3270 impl Registry for AlwaysTimesOutRegistry {
3271 fn get_versions<'a>(
3272 &'a self,
3273 _name: &'a deps_core::PackageName,
3274 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3275 {
3276 Box::pin(async move {
3277 tokio::time::sleep(Duration::from_secs(10)).await;
3278 Ok(vec![])
3279 })
3280 }
3281
3282 fn get_latest_matching<'a>(
3283 &'a self,
3284 _name: &'a deps_core::PackageName,
3285 _req: &'a deps_core::VersionReq,
3286 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3287 {
3288 Box::pin(async move {
3289 tokio::time::sleep(Duration::from_secs(10)).await;
3290 Ok(None)
3291 })
3292 }
3293
3294 fn search_raw<'a>(
3295 &'a self,
3296 _query: &'a str,
3297 _limit: usize,
3298 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3299 {
3300 Box::pin(async move { Ok(vec![]) })
3301 }
3302
3303 fn as_any(&self) -> &dyn Any {
3304 self
3305 }
3306 }
3307
3308 let registry: Arc<dyn Registry> = Arc::new(AlwaysTimesOutRegistry);
3309 let packages = vec![
3310 PackageName::new("slow-1"),
3311 PackageName::new("slow-2"),
3312 PackageName::new("slow-3"),
3313 ];
3314
3315 let result = fetch_latest_versions_parallel(
3316 registry,
3317 with_registry_source(packages),
3318 &HashMap::new(),
3319 None,
3320 deps_core::freshness::FreshnessSettings::default(),
3321 1,
3322 10,
3323 None,
3324 )
3325 .await;
3326
3327 assert_eq!(
3328 result.failed_count, 3,
3329 "all 3 packages must count toward failed_count"
3330 );
3331 let err = result
3332 .first_error
3333 .expect("a timeout is actionable and must populate first_error, not just failed_count");
3334 assert!(
3335 err.contains("timed out"),
3336 "first_error must be the actionable timeout message, got: {err}"
3337 );
3338 }
3339
3340 #[cfg(feature = "composer")]
3342 mod composer_tests {
3343 use super::*;
3344
3345 #[tokio::test]
3350 async fn test_composer_minimum_stability_extracts_from_real_parse_result() {
3351 let json = r#"{
3352 "minimum-stability": "beta",
3353 "require": {
3354 "symfony/console": "^6.0"
3355 }
3356}"#;
3357 let uri = deps_core::test_util::test_uri("/test/composer.json");
3358 let parse_result = crate::setup::parse_composer_json(json, &uri).unwrap();
3359
3360 assert_eq!(
3361 composer_minimum_stability(&parse_result as &dyn deps_core::ParseResult),
3362 Some("beta".to_string())
3363 );
3364 }
3365
3366 #[tokio::test]
3369 async fn test_composer_minimum_stability_none_when_absent() {
3370 let json = r#"{"require": {"symfony/console": "^6.0"}}"#;
3371 let uri = deps_core::test_util::test_uri("/test/composer.json");
3372 let parse_result = crate::setup::parse_composer_json(json, &uri).unwrap();
3373
3374 assert_eq!(
3375 composer_minimum_stability(&parse_result as &dyn deps_core::ParseResult),
3376 None
3377 );
3378 }
3379
3380 #[test]
3384 fn test_composer_minimum_stability_none_for_non_composer_parse_result() {
3385 struct OtherParseResult;
3386 impl deps_core::ParseResult for OtherParseResult {
3387 fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
3388 vec![]
3389 }
3390 fn workspace_root(&self) -> Option<&std::path::Path> {
3391 None
3392 }
3393 fn uri(&self) -> &url::Url {
3394 unimplemented!("not exercised by this test")
3395 }
3396 fn as_any(&self) -> &dyn std::any::Any {
3397 self
3398 }
3399 }
3400
3401 assert_eq!(
3402 composer_minimum_stability(&OtherParseResult as &dyn deps_core::ParseResult),
3403 None
3404 );
3405 }
3406 }
3407 mod yanked_check_tests {
3408 use super::*;
3409 use deps_core::{Metadata, Version};
3410 use std::any::Any;
3411 use std::sync::atomic::{AtomicUsize, Ordering};
3412
3413 #[derive(Debug, Clone)]
3414 struct MockYankVersion {
3415 version: ConcreteVersion,
3416 yanked: bool,
3417 }
3418
3419 impl Version for MockYankVersion {
3420 fn version_string(&self) -> &ConcreteVersion {
3421 &self.version
3422 }
3423 fn removal_status(&self) -> deps_core::RemovalStatus {
3424 deps_core::RemovalStatus::from_yanked(self.yanked)
3425 }
3426 fn as_any(&self) -> &dyn Any {
3427 self
3428 }
3429 }
3430
3431 enum FetchOutcome {
3434 Versions(Vec<(&'static str, bool)>),
3435 Error,
3436 Timeout,
3437 }
3438
3439 struct MockRegistry {
3449 reports_yanked: bool,
3450 versions: HashMap<&'static str, FetchOutcome>,
3451 latest_fallback: HashMap<&'static str, (&'static str, bool)>,
3452 fetch_calls: Arc<AtomicUsize>,
3453 }
3454
3455 impl Registry for MockRegistry {
3456 fn get_versions<'a>(
3457 &'a self,
3458 name: &'a PackageName,
3459 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3460 {
3461 self.fetch_calls.fetch_add(1, Ordering::Relaxed);
3462 let outcome = self.versions.get(name.as_str());
3463 Box::pin(async move {
3464 match outcome {
3465 Some(FetchOutcome::Versions(vs)) => Ok(vs
3466 .iter()
3467 .map(|(v, y)| {
3468 Box::new(MockYankVersion {
3469 version: (*v).into(),
3470 yanked: *y,
3471 }) as Box<dyn Version>
3472 })
3473 .collect()),
3474 Some(FetchOutcome::Error) => Err(deps_core::error::DepsError::CacheError(
3475 "mock fetch error".to_string(),
3476 )),
3477 Some(FetchOutcome::Timeout) => {
3478 tokio::time::sleep(std::time::Duration::from_secs(10)).await;
3479 Ok(vec![])
3480 }
3481 None => Ok(vec![]),
3482 }
3483 })
3484 }
3485
3486 fn select_latest_matching(
3487 &self,
3488 versions: &[Box<dyn Version>],
3489 _req: &VersionReq,
3490 ) -> Option<usize> {
3491 versions
3492 .iter()
3493 .position(|v| !v.removal_status().blocks_resolution())
3494 }
3495
3496 fn get_latest_matching<'a>(
3497 &'a self,
3498 name: &'a PackageName,
3499 _req: &'a VersionReq,
3500 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3501 {
3502 let outcome = self.latest_fallback.get(name.as_str()).copied();
3503 Box::pin(async move {
3504 Ok(outcome.map(|(v, y)| {
3505 Box::new(MockYankVersion {
3506 version: v.into(),
3507 yanked: y,
3508 }) as Box<dyn Version>
3509 }))
3510 })
3511 }
3512
3513 fn search_raw<'a>(
3514 &'a self,
3515 _query: &'a str,
3516 _limit: usize,
3517 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3518 {
3519 Box::pin(async move { Ok(vec![]) })
3520 }
3521
3522 fn reports_yanked(&self) -> bool {
3523 self.reports_yanked
3524 }
3525
3526 fn as_any(&self) -> &dyn Any {
3527 self
3528 }
3529 }
3530
3531 #[tokio::test]
3532 async fn reports_yanked_false_never_recorded() {
3533 let fetch_calls = Arc::new(AtomicUsize::new(0));
3537 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3538 reports_yanked: false,
3539 versions: HashMap::from([(
3540 "pkg",
3541 FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", true)]),
3542 )]),
3543 latest_fallback: HashMap::new(),
3544 fetch_calls: Arc::clone(&fetch_calls),
3545 });
3546 let mut in_use = HashMap::new();
3547 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3548
3549 let result = fetch_latest_versions_parallel(
3550 registry,
3551 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3552 &in_use,
3553 None,
3554 deps_core::freshness::FreshnessSettings::default(),
3555 5,
3556 10,
3557 None,
3558 )
3559 .await;
3560
3561 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3562 assert!(result.yanked_versions.is_empty());
3563 }
3564
3565 #[tokio::test]
3566 async fn in_use_equal_to_latest_not_yanked() {
3567 let fetch_calls = Arc::new(AtomicUsize::new(0));
3568 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3569 reports_yanked: true,
3570 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", false)]))]),
3571 latest_fallback: HashMap::new(),
3572 fetch_calls: Arc::clone(&fetch_calls),
3573 });
3574 let mut in_use = HashMap::new();
3575 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3576
3577 let result = fetch_latest_versions_parallel(
3578 registry,
3579 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3580 &in_use,
3581 None,
3582 deps_core::freshness::FreshnessSettings::default(),
3583 5,
3584 10,
3585 None,
3586 )
3587 .await;
3588
3589 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3590 assert!(result.yanked_versions.is_empty());
3591 }
3592
3593 #[tokio::test]
3594 async fn no_known_in_use_version_skips_the_check() {
3595 let fetch_calls = Arc::new(AtomicUsize::new(0));
3596 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3597 reports_yanked: true,
3598 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("2.0.0", false)]))]),
3599 latest_fallback: HashMap::new(),
3600 fetch_calls: Arc::clone(&fetch_calls),
3601 });
3602
3603 let result = fetch_latest_versions_parallel(
3604 registry,
3605 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3606 &HashMap::new(),
3607 None,
3608 deps_core::freshness::FreshnessSettings::default(),
3609 5,
3610 10,
3611 None,
3612 )
3613 .await;
3614
3615 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3616 assert!(result.yanked_versions.is_empty());
3617 }
3618
3619 #[tokio::test]
3620 async fn in_use_differs_and_yanked_is_recorded() {
3621 let fetch_calls = Arc::new(AtomicUsize::new(0));
3625 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3626 reports_yanked: true,
3627 versions: HashMap::from([(
3628 "pkg",
3629 FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", true)]),
3630 )]),
3631 latest_fallback: HashMap::new(),
3632 fetch_calls: Arc::clone(&fetch_calls),
3633 });
3634 let mut in_use = HashMap::new();
3635 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3636
3637 let result = fetch_latest_versions_parallel(
3638 registry,
3639 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3640 &in_use,
3641 None,
3642 deps_core::freshness::FreshnessSettings::default(),
3643 5,
3644 10,
3645 None,
3646 )
3647 .await;
3648
3649 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3650 assert_eq!(
3651 result.yanked_versions.get(&PackageName::new("pkg")),
3652 Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
3653 );
3654 }
3655
3656 #[tokio::test]
3657 async fn in_use_differs_and_not_yanked_is_not_recorded() {
3658 let fetch_calls = Arc::new(AtomicUsize::new(0));
3659 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3660 reports_yanked: true,
3661 versions: HashMap::from([(
3662 "pkg",
3663 FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", false)]),
3664 )]),
3665 latest_fallback: HashMap::new(),
3666 fetch_calls: Arc::clone(&fetch_calls),
3667 });
3668 let mut in_use = HashMap::new();
3669 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3670
3671 let result = fetch_latest_versions_parallel(
3672 registry,
3673 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3674 &in_use,
3675 None,
3676 deps_core::freshness::FreshnessSettings::default(),
3677 5,
3678 10,
3679 None,
3680 )
3681 .await;
3682
3683 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3684 assert!(result.yanked_versions.is_empty());
3685 }
3686
3687 #[tokio::test]
3688 async fn every_version_yanked_still_checks_in_use() {
3689 let fetch_calls = Arc::new(AtomicUsize::new(0));
3696 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3697 reports_yanked: true,
3698 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
3699 latest_fallback: HashMap::new(),
3700 fetch_calls: Arc::clone(&fetch_calls),
3701 });
3702 let mut in_use = HashMap::new();
3703 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3704
3705 let result = fetch_latest_versions_parallel(
3706 registry,
3707 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3708 &in_use,
3709 None,
3710 deps_core::freshness::FreshnessSettings::default(),
3711 5,
3712 10,
3713 None,
3714 )
3715 .await;
3716
3717 assert_eq!(
3718 result.yanked_versions.get(&PackageName::new("pkg")),
3719 Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
3720 );
3721 assert!(result.versions.is_empty());
3722 }
3723
3724 #[tokio::test]
3725 async fn latest_pick_needs_fallback_in_use_yanked_still_found() {
3726 let fetch_calls = Arc::new(AtomicUsize::new(0));
3732 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3733 reports_yanked: true,
3734 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
3735 latest_fallback: HashMap::from([("pkg", ("2.0.0", false))]),
3736 fetch_calls: Arc::clone(&fetch_calls),
3737 });
3738 let mut in_use = HashMap::new();
3739 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3740
3741 let result = fetch_latest_versions_parallel(
3742 registry,
3743 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3744 &in_use,
3745 None,
3746 deps_core::freshness::FreshnessSettings::default(),
3747 5,
3748 10,
3749 None,
3750 )
3751 .await;
3752
3753 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
3754 assert_eq!(
3755 result
3756 .versions
3757 .get(&PackageName::new("pkg"))
3758 .map(|v| v.latest.as_str()),
3759 Some("2.0.0")
3760 );
3761 assert_eq!(
3762 result.yanked_versions.get(&PackageName::new("pkg")),
3763 Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
3764 );
3765 }
3766
3767 #[tokio::test]
3768 async fn in_use_checks_every_occurrence_of_a_duplicate_name() {
3769 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3775 reports_yanked: true,
3776 versions: HashMap::from([(
3777 "pkg",
3778 FetchOutcome::Versions(vec![
3779 ("3.0.0", false),
3780 ("2.0.0", true),
3781 ("1.0.0", false),
3782 ]),
3783 )]),
3784 latest_fallback: HashMap::new(),
3785 fetch_calls: Arc::new(AtomicUsize::new(0)),
3786 });
3787 let mut in_use = HashMap::new();
3788 in_use.insert(
3789 PackageName::new("pkg"),
3790 vec!["1.0.0".to_string(), "2.0.0".to_string()],
3791 );
3792
3793 let result = fetch_latest_versions_parallel(
3794 registry,
3795 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3796 &in_use,
3797 None,
3798 deps_core::freshness::FreshnessSettings::default(),
3799 5,
3800 10,
3801 None,
3802 )
3803 .await;
3804
3805 assert_eq!(
3806 result.yanked_versions.get(&PackageName::new("pkg")),
3807 Some(&(ConcreteVersion::new("2.0.0"), RemovalStatus::Yanked)),
3808 "the yanked occurrence must be found even though a name-keyed \
3809 single-value map could have kept only the non-yanked \"1.0.0\" pin"
3810 );
3811 }
3812
3813 #[tokio::test]
3814 async fn latest_is_yanked_recorded_as_defense_in_depth() {
3815 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3821 reports_yanked: true,
3822 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
3823 latest_fallback: HashMap::from([("pkg", ("1.0.0", true))]),
3824 fetch_calls: Arc::new(AtomicUsize::new(0)),
3825 });
3826
3827 let result = fetch_latest_versions_parallel(
3828 registry,
3829 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3830 &HashMap::new(),
3831 None,
3832 deps_core::freshness::FreshnessSettings::default(),
3833 5,
3834 10,
3835 None,
3836 )
3837 .await;
3838
3839 assert_eq!(
3840 result.yanked_versions.get(&PackageName::new("pkg")),
3841 Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
3842 );
3843 }
3844
3845 #[tokio::test]
3846 async fn latest_is_yanked_not_recorded_when_reports_yanked_false() {
3847 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3855 reports_yanked: false,
3856 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
3857 latest_fallback: HashMap::from([("pkg", ("1.0.0", true))]),
3858 fetch_calls: Arc::new(AtomicUsize::new(0)),
3859 });
3860
3861 let result = fetch_latest_versions_parallel(
3862 registry,
3863 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3864 &HashMap::new(),
3865 None,
3866 deps_core::freshness::FreshnessSettings::default(),
3867 5,
3868 10,
3869 None,
3870 )
3871 .await;
3872
3873 assert!(
3874 result.yanked_versions.is_empty(),
3875 "a `reports_yanked() == false` registry's `removal_status()` must never be \
3876 trusted, even on the zero-cost row-1 path"
3877 );
3878 assert!(
3879 result
3880 .versions
3881 .get(&PackageName::new("pkg"))
3882 .expect("pkg was fetched")
3883 .yanked
3884 .is_empty(),
3885 "`PackageVersions::yanked` must stay empty for a `reports_yanked() == false` \
3886 registry, even though the fetched version is itself flagged"
3887 );
3888 }
3889
3890 #[tokio::test]
3891 async fn primary_fetch_error_counts_as_failed_no_yanked_data() {
3892 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3898 reports_yanked: true,
3899 versions: HashMap::from([("pkg", FetchOutcome::Error)]),
3900 latest_fallback: HashMap::new(),
3901 fetch_calls: Arc::new(AtomicUsize::new(0)),
3902 });
3903 let mut in_use = HashMap::new();
3904 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3905
3906 let result = fetch_latest_versions_parallel(
3907 registry,
3908 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3909 &in_use,
3910 None,
3911 deps_core::freshness::FreshnessSettings::default(),
3912 5,
3913 10,
3914 None,
3915 )
3916 .await;
3917
3918 assert!(result.yanked_versions.is_empty());
3919 assert_eq!(result.failed_count, 1);
3920 assert!(result.versions.is_empty());
3921 }
3922
3923 #[tokio::test]
3924 async fn primary_fetch_timeout_counts_as_failed_no_yanked_data() {
3925 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
3927 reports_yanked: true,
3928 versions: HashMap::from([("pkg", FetchOutcome::Timeout)]),
3929 latest_fallback: HashMap::new(),
3930 fetch_calls: Arc::new(AtomicUsize::new(0)),
3931 });
3932 let mut in_use = HashMap::new();
3933 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
3934
3935 let result = fetch_latest_versions_parallel(
3936 registry,
3937 vec![(PackageName::new("pkg"), DependencySource::Registry)],
3938 &in_use,
3939 None,
3940 deps_core::freshness::FreshnessSettings::default(),
3941 1,
3942 10,
3943 None,
3944 )
3945 .await;
3946
3947 assert!(result.yanked_versions.is_empty());
3948 assert_eq!(result.failed_count, 1);
3949 assert!(result.versions.is_empty());
3950 }
3951 }
3952
3953 mod deprecation_derivation_tests {
3959 use super::*;
3960 use deps_core::{Metadata, Version};
3961 use std::any::Any;
3962
3963 struct MockDeprecatedVersion {
3964 version: ConcreteVersion,
3965 deprecation: Option<Deprecation>,
3966 }
3967
3968 impl Version for MockDeprecatedVersion {
3969 fn version_string(&self) -> &ConcreteVersion {
3970 &self.version
3971 }
3972 fn removal_status(&self) -> RemovalStatus {
3973 RemovalStatus::from_advisory(self.deprecation.is_some())
3974 }
3975 fn deprecation(&self) -> Option<&Deprecation> {
3976 self.deprecation.as_ref()
3977 }
3978 fn as_any(&self) -> &dyn Any {
3979 self
3980 }
3981 }
3982
3983 struct SingleVersionRegistry {
3985 deprecation: Option<Deprecation>,
3986 }
3987
3988 impl Registry for SingleVersionRegistry {
3989 fn get_versions<'a>(
3990 &'a self,
3991 _name: &'a PackageName,
3992 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3993 {
3994 let deprecation = self.deprecation.clone();
3995 Box::pin(async move {
3996 Ok(vec![Box::new(MockDeprecatedVersion {
3997 version: "1.0.0".into(),
3998 deprecation,
3999 }) as Box<dyn Version>])
4000 })
4001 }
4002
4003 fn select_latest_matching(
4004 &self,
4005 versions: &[Box<dyn Version>],
4006 _req: &VersionReq,
4007 ) -> Option<usize> {
4008 (!versions.is_empty()).then_some(0)
4009 }
4010
4011 fn get_latest_matching<'a>(
4012 &'a self,
4013 _name: &'a PackageName,
4014 _req: &'a VersionReq,
4015 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4016 {
4017 Box::pin(async move { Ok(None) })
4018 }
4019
4020 fn search_raw<'a>(
4021 &'a self,
4022 _query: &'a str,
4023 _limit: usize,
4024 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4025 {
4026 Box::pin(async move { Ok(vec![]) })
4027 }
4028
4029 fn as_any(&self) -> &dyn Any {
4030 self
4031 }
4032 }
4033
4034 #[tokio::test]
4035 async fn fetch_result_carries_deprecation_from_resolved_pick() {
4036 let registry: Arc<dyn Registry> = Arc::new(SingleVersionRegistry {
4037 deprecation: Some(Deprecation {
4038 reason: Some("archived".to_string()),
4039 replacement: Some("other/pkg".to_string()),
4040 }),
4041 });
4042
4043 let result = fetch_latest_versions_parallel(
4044 registry,
4045 vec![(PackageName::new("pkg"), DependencySource::Registry)],
4046 &HashMap::new(),
4047 None,
4048 deps_core::freshness::FreshnessSettings::default(),
4049 5,
4050 10,
4051 None,
4052 )
4053 .await;
4054
4055 assert_eq!(
4056 result.deprecations.get(&PackageName::new("pkg")),
4057 Some(&Deprecation {
4058 reason: Some("archived".to_string()),
4059 replacement: Some("other/pkg".to_string()),
4060 })
4061 );
4062 }
4063
4064 #[tokio::test]
4065 async fn fetch_result_has_no_deprecation_when_resolved_pick_is_clean() {
4066 let registry: Arc<dyn Registry> = Arc::new(SingleVersionRegistry { deprecation: None });
4067
4068 let result = fetch_latest_versions_parallel(
4069 registry,
4070 vec![(PackageName::new("pkg"), DependencySource::Registry)],
4071 &HashMap::new(),
4072 None,
4073 deps_core::freshness::FreshnessSettings::default(),
4074 5,
4075 10,
4076 None,
4077 )
4078 .await;
4079
4080 assert!(result.deprecations.is_empty());
4081 }
4082 }
4083}