1use super::loader::{MAX_FILE_SIZE, load_document_from_disk};
7use super::state::{DocumentState, ServerState};
8use crate::config::DepsConfig;
9use crate::handlers::diagnostics;
10use crate::progress::{ProgressSender, RegistryProgress};
11use deps_core::ConcreteVersion;
12use deps_core::DependencyOutcomes;
13use deps_core::Deprecation;
14use deps_core::Ecosystem;
15use deps_core::EcosystemId;
16use deps_core::FetchFailure;
17use deps_core::PackageName;
18use deps_core::PackageVersions;
19use deps_core::Registry;
20use deps_core::RemovalStatus;
21use deps_core::Result;
22use deps_core::VersionReq;
23use deps_core::lsp_helpers::in_use_version;
24use std::collections::{HashMap, HashSet};
25use std::sync::Arc;
26use std::time::{Duration, Instant};
27use tokio::sync::RwLock;
28use tokio::task::JoinHandle;
29use tower_lsp_server::Client;
30use tower_lsp_server::ls_types::{MessageType, Uri};
31
32type DepSources = Vec<(PackageName, deps_core::parser::DependencySource)>;
35
36fn resolve_ecosystem_id(ecosystem: &dyn Ecosystem) -> EcosystemId {
42 ecosystem
43 .id()
44 .parse()
45 .expect("ecosystem.id() must be a registered EcosystemId")
46}
47
48fn dedup_dependencies_by_source(
78 parse_result: &dyn deps_core::ParseResult,
79 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
80) -> (
81 HashMap<PackageName, deps_core::parser::DependencySource>,
82 HashSet<PackageName>,
83) {
84 use std::collections::hash_map::Entry;
85
86 let mut by_name: HashMap<PackageName, deps_core::parser::DependencySource> = HashMap::new();
87 let mut collided: HashSet<PackageName> = HashSet::new();
88
89 for dep in parse_result
90 .dependencies()
91 .into_iter()
92 .filter(|dep| formatter.can_resolve_source(&dep.source()))
93 {
94 let name = dep.name().clone();
95 let source = dep.source();
96 match by_name.entry(name.clone()) {
97 Entry::Vacant(entry) => {
98 entry.insert(source);
99 }
100 Entry::Occupied(entry) => {
101 if *entry.get() != source && collided.insert(name.clone()) {
102 tracing::warn!(
103 package = %name,
104 source_a = ?entry.get(),
105 source_b = ?source,
106 "dependency declared against two different resolved registries; \
107 skipping version resolution for all occurrences"
108 );
109 }
110 }
111 }
112 }
113
114 for name in &collided {
115 by_name.remove(name);
116 }
117 (by_name, collided)
118}
119
120#[cfg(feature = "composer")]
129fn composer_minimum_stability(parse_result: &dyn deps_core::ParseResult) -> Option<String> {
130 parse_result
131 .as_any()
132 .downcast_ref::<crate::ComposerParseResult>()
133 .and_then(|r| r.minimum_stability.clone())
134}
135
136#[cfg(not(feature = "composer"))]
139fn composer_minimum_stability(_parse_result: &dyn deps_core::ParseResult) -> Option<String> {
140 None
141}
142
143fn check_content_size(content: &str, uri: &Uri) -> Result<()> {
155 let size = content.len() as u64;
156 if size > MAX_FILE_SIZE {
157 tracing::error!(
158 "Document content exceeds maximum size: {} bytes (limit: {} bytes) for {:?}",
159 size,
160 MAX_FILE_SIZE,
161 uri
162 );
163 return Err(deps_core::error::DepsError::CacheError(format!(
164 "document too large: {size} bytes (max: {MAX_FILE_SIZE} bytes)"
165 )));
166 }
167 Ok(())
168}
169
170fn preserve_cache(new_state: &mut DocumentState, old_state: &DocumentState) {
173 tracing::trace!(
174 cached = old_state.cached_versions.len(),
175 resolved = old_state.resolved_versions.len(),
176 vulnerabilities = old_state.vulnerabilities.len(),
177 outcomes_yanked = old_state.outcomes.yanked_count(),
178 outcomes_deprecated = old_state.outcomes.deprecation_count(),
179 outcomes_fetch_failed = old_state.outcomes.fetch_failure_count(),
180 "preserving version cache"
181 );
182 new_state
183 .cached_versions
184 .clone_from(&old_state.cached_versions);
185 new_state
186 .resolved_versions
187 .clone_from(&old_state.resolved_versions);
188 new_state
192 .vulnerabilities
193 .clone_from(&old_state.vulnerabilities);
194 new_state.outcomes.clone_from(&old_state.outcomes);
199}
200
201fn drop_cache_for_forced_refetch(
224 doc: &mut DocumentState,
225 deps_to_fetch: &[PackageName],
226 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
227) {
228 doc.cached_versions.clear();
229 doc.outcomes.clear_all_fetch_failures();
230 for name in deps_to_fetch {
231 doc.outcomes.set_fetch_failure_if_absent(
232 formatter.normalize_package_name(name),
233 FetchFailure::NotAttempted,
234 );
235 }
236}
237
238fn merge_deprecations_after_fetch(
254 doc: &mut DocumentState,
255 fetched_names: &[PackageName],
256 mut fetched_deprecations: HashMap<PackageName, Deprecation>,
257 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
258) {
259 let mut per_normalized: HashMap<String, Option<Deprecation>> = HashMap::new();
267 for name in fetched_names {
268 let normalized = formatter.normalize_package_name(name);
269 let found = fetched_deprecations.remove(name);
270 let entry = per_normalized.entry(normalized).or_insert(None);
271 if entry.is_none() {
272 *entry = found;
273 }
274 }
275 for (normalized, deprecation) in per_normalized {
276 match deprecation {
277 Some(deprecation) => {
278 doc.outcomes.set_deprecation(normalized, deprecation);
279 }
280 None => {
281 doc.outcomes.clear_deprecation(&normalized);
282 }
283 }
284 }
285}
286
287fn merge_no_comparable_versions_after_fetch(
304 doc: &mut DocumentState,
305 attempted_names: &[PackageName],
306 mut fetched_no_comparable_versions: HashSet<PackageName>,
307 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
308) {
309 let mut per_normalized: HashMap<String, bool> = HashMap::new();
314 for name in attempted_names {
315 let normalized = formatter.normalize_package_name(name);
316 let found = fetched_no_comparable_versions.remove(name);
317 let entry = per_normalized.entry(normalized).or_insert(false);
318 *entry = *entry || found;
319 }
320 for (normalized, found) in per_normalized {
321 if found {
322 doc.outcomes.set_no_comparable_versions(normalized);
323 } else {
324 doc.outcomes.clear_no_comparable_versions(&normalized);
325 }
326 }
327}
328
329const OSV_SCAN_TIMEOUT_CEILING_SECS: u64 = 30;
334
335fn collect_in_use_versions(
348 parse_result: &dyn deps_core::ParseResult,
349 resolved_versions: &HashMap<PackageName, ConcreteVersion>,
350 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
351 ecosystem: EcosystemId,
352) -> HashMap<PackageName, Vec<String>> {
353 let mut map: HashMap<PackageName, Vec<String>> = HashMap::new();
354 for dep in parse_result
355 .dependencies()
356 .into_iter()
357 .filter(|dep| formatter.source_is_public_registry_content(&dep.source()))
358 {
359 let normalized_name = formatter.normalize_package_name(dep.name());
360 if let Some(v) = in_use_version(
361 dep,
362 &normalized_name,
363 resolved_versions,
364 formatter,
365 ecosystem,
366 ) {
367 map.entry(dep.name().clone()).or_default().push(v);
368 }
369 }
370 map
371}
372
373fn build_scan_targets(
418 parse_result: &dyn deps_core::ParseResult,
419 resolved_versions: &HashMap<PackageName, ConcreteVersion>,
420 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
421 ecosystem: EcosystemId,
422) -> (
423 Vec<deps_core::osv::ScanTarget>,
424 deps_core::osv::VulnerabilityMap,
425) {
426 use deps_core::osv::{ScanOutcome, SkipReason};
427
428 let mut targets = Vec::new();
429 let mut skipped = deps_core::osv::VulnerabilityMap::new();
430 let keys =
431 deps_core::osv::vulnerability_keys(parse_result, resolved_versions, formatter, ecosystem);
432
433 for dep in parse_result.dependencies() {
434 let normalized_name = formatter.normalize_package_name(dep.name());
435 let key = keys
436 .get(&dep.name_range())
437 .cloned()
438 .unwrap_or_else(|| normalized_name.clone());
439
440 if !formatter.source_is_public_registry_content(&dep.source()) {
441 skipped.insert(key, ScanOutcome::Skipped(SkipReason::NonRegistrySource));
442 continue;
443 }
444
445 let version = in_use_version(
456 dep,
457 &normalized_name,
458 resolved_versions,
459 formatter,
460 ecosystem,
461 );
462
463 let Some(version) = version else {
464 skipped.insert(key, ScanOutcome::Skipped(SkipReason::NoConcreteVersion));
465 continue;
466 };
467
468 let Some(osv_name) = formatter.osv_package_name(dep) else {
469 skipped.insert(key, ScanOutcome::Skipped(SkipReason::UnmappableName));
470 continue;
471 };
472
473 targets.push(deps_core::osv::ScanTarget {
474 key,
475 osv_name,
476 version: formatter.osv_version(&version),
477 display_version: version,
478 });
479 }
480
481 (targets, skipped)
482}
483
484fn log_osv_run_summary(vulnerabilities: &deps_core::osv::VulnerabilityMap) {
490 let mut clean = 0usize;
491 let mut vulnerable = 0usize;
492 let mut skipped = 0usize;
493 for outcome in vulnerabilities.values() {
494 match outcome {
495 deps_core::osv::ScanOutcome::Clean => clean += 1,
496 deps_core::osv::ScanOutcome::Vulnerable(_) => vulnerable += 1,
497 deps_core::osv::ScanOutcome::Skipped(_) => skipped += 1,
498 }
499 }
500 tracing::info!(
501 "OSV: document scan complete, {} dependencies considered, {clean} clean, {vulnerable} vulnerable, {skipped} skipped",
502 vulnerabilities.len(),
503 );
504}
505
506struct OsvScanResult {
509 content_snapshot: String,
512 vulnerabilities: deps_core::osv::VulnerabilityMap,
513 osv_name_by_key: HashMap<String, String>,
515 raw_name_by_key: HashMap<String, String>,
520}
521
522async fn run_osv_scan_phase_a(
533 uri: Uri,
534 state: Arc<ServerState>,
535 ecosystem: Arc<dyn Ecosystem>,
536 fetch_timeout_secs: u64,
537) -> Option<OsvScanResult> {
538 let ecosystem_id = resolve_ecosystem_id(ecosystem.as_ref());
539
540 let (content_snapshot, targets, mut vulnerabilities, raw_name_by_key) = {
541 let doc = state.get_document(&uri)?;
542 let parse_result = doc.parse_result()?;
543 let (targets, skipped) = build_scan_targets(
544 parse_result,
545 &doc.resolved_versions,
546 ecosystem.formatter(),
547 ecosystem_id,
548 );
549 let vuln_keys = deps_core::osv::vulnerability_keys(
554 parse_result,
555 &doc.resolved_versions,
556 ecosystem.formatter(),
557 ecosystem_id,
558 );
559 let raw_name_by_key: HashMap<String, String> = parse_result
560 .dependencies()
561 .into_iter()
562 .map(|d| {
563 let key = vuln_keys
564 .get(&d.name_range())
565 .cloned()
566 .unwrap_or_else(|| ecosystem.formatter().normalize_package_name(d.name()));
567 (key, d.name().to_string())
568 })
569 .collect();
570 (doc.content.clone(), targets, skipped, raw_name_by_key)
571 };
572
573 if targets.is_empty() && vulnerabilities.is_empty() {
574 return None;
575 }
576
577 let osv_name_by_key: HashMap<String, String> = targets
578 .iter()
579 .map(|t| (t.key.clone(), t.osv_name.clone()))
580 .collect();
581
582 if !targets.is_empty() {
583 let timeout_duration =
584 Duration::from_secs(fetch_timeout_secs.min(OSV_SCAN_TIMEOUT_CEILING_SECS));
585 let scanned = state
586 .osv
587 .scan(ecosystem_id, &targets, timeout_duration)
588 .await;
589 vulnerabilities.extend(scanned);
590 }
591
592 log_osv_run_summary(&vulnerabilities);
593
594 Some(OsvScanResult {
595 content_snapshot,
596 vulnerabilities,
597 osv_name_by_key,
598 raw_name_by_key,
599 })
600}
601
602async fn run_osv_phase_b_and_commit(
629 uri: &Uri,
630 state: &Arc<ServerState>,
631 ecosystem_id: EcosystemId,
632 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
633 fetch_timeout_secs: u64,
634 mut result: OsvScanResult,
635) {
636 let vulnerable_keys: Vec<String> = result
637 .vulnerabilities
638 .iter()
639 .filter(|(_, outcome)| matches!(outcome, deps_core::osv::ScanOutcome::Vulnerable(_)))
640 .map(|(key, _)| key.clone())
641 .collect();
642
643 if !vulnerable_keys.is_empty() {
644 let phase_b_deadline = Instant::now()
645 + Duration::from_secs(fetch_timeout_secs.min(OSV_SCAN_TIMEOUT_CEILING_SECS));
646
647 let latest_native_by_key: HashMap<String, String> = {
652 let Some(doc) = state.get_document(uri) else {
653 return;
654 };
655 vulnerable_keys
656 .iter()
657 .filter_map(|key| {
658 let latest = doc
659 .cached_versions
660 .get(key.as_str())
661 .or_else(|| {
662 let raw = result.raw_name_by_key.get(key)?;
663 doc.cached_versions.get(raw.as_str())
664 })?
665 .latest
666 .clone();
667 Some((key.clone(), latest.to_string()))
668 })
669 .collect()
670 };
671
672 let candidates: Vec<deps_core::osv::ScanTarget> = vulnerable_keys
673 .iter()
674 .filter_map(|key| {
675 let osv_name = result.osv_name_by_key.get(key)?.clone();
676 let latest_native = latest_native_by_key.get(key)?.clone();
677 Some(deps_core::osv::ScanTarget {
678 key: key.clone(),
679 osv_name,
680 version: formatter.osv_version(&latest_native),
681 display_version: latest_native,
682 })
683 })
684 .collect();
685
686 if !candidates.is_empty() {
687 let timeout_duration = phase_b_deadline.saturating_duration_since(Instant::now());
688 let statuses = state
689 .osv
690 .check_candidates(ecosystem_id, &candidates, timeout_duration)
691 .await;
692 for (key, status) in statuses {
693 if let Some(deps_core::osv::ScanOutcome::Vulnerable(dv)) =
694 result.vulnerabilities.get_mut(&key)
695 {
696 dv.upgrade_status = status;
697 }
698 }
699 }
700
701 run_osv_fix_target_verification(
702 &mut result.vulnerabilities,
703 &vulnerable_keys,
704 &result.osv_name_by_key,
705 &latest_native_by_key,
706 ecosystem_id,
707 formatter,
708 &state.osv,
709 phase_b_deadline,
710 )
711 .await;
712 }
713
714 if let Some(mut doc) = state.documents.get_mut(uri) {
715 if doc.content == result.content_snapshot {
716 doc.update_vulnerabilities(result.vulnerabilities);
717 } else {
718 tracing::debug!("dropping stale OSV scan result: document content changed mid-scan");
719 }
720 }
721}
722
723const FIX_TARGET_KEY_SUFFIX: &str = "\u{0}fix";
727
728#[allow(clippy::too_many_arguments)]
759async fn run_osv_fix_target_verification(
760 vulnerabilities: &mut deps_core::osv::VulnerabilityMap,
761 vulnerable_keys: &[String],
762 osv_name_by_key: &HashMap<String, String>,
763 latest_native_by_key: &HashMap<String, String>,
764 ecosystem_id: EcosystemId,
765 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
766 osv: &deps_core::osv::OsvClient,
767 phase_b_deadline: Instant,
768) {
769 use deps_core::osv::ScanOutcome;
770
771 let (resolved, live_check_candidates) = collect_fix_target_resolutions(
772 vulnerabilities,
773 vulnerable_keys,
774 osv_name_by_key,
775 latest_native_by_key,
776 formatter,
777 );
778
779 for (key, status) in resolved {
780 if let Some(ScanOutcome::Vulnerable(dv)) = vulnerabilities.get_mut(key.as_str()) {
781 dv.fix_target_status = status;
782 }
783 }
784
785 if live_check_candidates.is_empty() {
786 return;
787 }
788
789 let timeout_duration = phase_b_deadline.saturating_duration_since(Instant::now());
790 let statuses = osv
791 .check_candidates(ecosystem_id, &live_check_candidates, timeout_duration)
792 .await;
793
794 apply_live_fix_target_statuses(vulnerabilities, statuses);
795}
796
797#[derive(Debug, PartialEq, Eq)]
799enum FixTargetResolution {
800 Skip,
804 Resolved(deps_core::osv::UpgradeStatus),
807 NeedsLiveCheck(deps_core::osv::ScanTarget),
813}
814
815fn resolve_fix_target(
819 dv: &deps_core::osv::DependencyVulnerabilities,
820 key: &str,
821 latest_native_by_key: &HashMap<String, String>,
822 osv_name_by_key: &HashMap<String, String>,
823 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
824) -> FixTargetResolution {
825 use deps_core::lsp_helpers::is_safe_version_string;
826 use deps_core::osv::ScanTarget;
827
828 let Some(fix) = dv.recommended_fix() else {
829 return FixTargetResolution::Skip;
830 };
831 let version_native = formatter.osv_version_to_native(&fix.version);
832 if !is_safe_version_string(&version_native) {
833 tracing::debug!(
834 key,
835 version = %fix.version,
836 "OSV #462: fix-target version failed validation, skipping verification"
837 );
838 return FixTargetResolution::Skip;
839 }
840
841 if latest_native_by_key.get(key) == Some(&version_native) {
842 return FixTargetResolution::Resolved(dv.upgrade_status.clone());
843 }
844
845 let Some(osv_name) = osv_name_by_key.get(key).cloned() else {
846 tracing::debug!(
847 key,
848 "OSV #462: no osv_name on record for fix-target verification, skipping"
849 );
850 return FixTargetResolution::Skip;
851 };
852 FixTargetResolution::NeedsLiveCheck(ScanTarget {
853 key: format!("{key}{FIX_TARGET_KEY_SUFFIX}"),
854 osv_name,
855 version: fix.version,
856 display_version: version_native,
857 })
858}
859
860fn collect_fix_target_resolutions(
869 vulnerabilities: &deps_core::osv::VulnerabilityMap,
870 vulnerable_keys: &[String],
871 osv_name_by_key: &HashMap<String, String>,
872 latest_native_by_key: &HashMap<String, String>,
873 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
874) -> (
875 Vec<(String, deps_core::osv::UpgradeStatus)>,
876 Vec<deps_core::osv::ScanTarget>,
877) {
878 use deps_core::osv::ScanOutcome;
879
880 let mut resolved = Vec::new();
881 let mut live_check_candidates = Vec::new();
882
883 for key in vulnerable_keys {
884 let Some(ScanOutcome::Vulnerable(dv)) = vulnerabilities.get(key.as_str()) else {
885 continue;
886 };
887 match resolve_fix_target(dv, key, latest_native_by_key, osv_name_by_key, formatter) {
888 FixTargetResolution::Skip => {}
889 FixTargetResolution::Resolved(status) => resolved.push((key.clone(), status)),
890 FixTargetResolution::NeedsLiveCheck(target) => live_check_candidates.push(target),
891 }
892 }
893
894 (resolved, live_check_candidates)
895}
896
897fn apply_live_fix_target_statuses(
905 vulnerabilities: &mut deps_core::osv::VulnerabilityMap,
906 statuses: HashMap<String, deps_core::osv::UpgradeStatus>,
907) {
908 use deps_core::osv::ScanOutcome;
909
910 for (synthetic_key, status) in statuses {
911 let Some(key) = synthetic_key.strip_suffix(FIX_TARGET_KEY_SUFFIX) else {
912 continue;
913 };
914 if let Some(ScanOutcome::Vulnerable(dv)) = vulnerabilities.get_mut(key) {
915 dv.fix_target_status = status;
916 }
917 }
918}
919
920#[derive(Debug, Clone, Default)]
928struct DependencyDiff {
929 added: Vec<PackageName>,
930 removed: Vec<PackageName>,
931 version_changed: Vec<PackageName>,
932}
933
934impl DependencyDiff {
935 fn compute(
956 old: &HashMap<PackageName, Vec<Option<VersionReq>>>,
957 new: &HashMap<PackageName, Vec<Option<VersionReq>>>,
958 ) -> Self {
959 let old_names: HashSet<&PackageName> = old.keys().collect();
960 let new_names: HashSet<&PackageName> = new.keys().collect();
961
962 let added = new_names
963 .difference(&old_names)
964 .map(|s| (*s).clone())
965 .collect();
966 let removed = old_names
967 .difference(&new_names)
968 .map(|s| (*s).clone())
969 .collect();
970 let version_changed = new_names
971 .intersection(&old_names)
972 .filter(|name| old.get(**name) != new.get(**name))
973 .map(|s| (*s).clone())
974 .collect();
975
976 Self {
977 added,
978 removed,
979 version_changed,
980 }
981 }
982
983 #[cfg(test)]
992 fn needs_fetch(&self) -> bool {
993 !self.added.is_empty() || !self.version_changed.is_empty()
994 }
995
996 fn needs_osv_rescan(&self) -> bool {
1002 !self.added.is_empty() || !self.version_changed.is_empty()
1003 }
1004}
1005
1006fn dependency_version_map(
1022 pr: &dyn deps_core::ParseResult,
1023) -> HashMap<PackageName, Vec<Option<VersionReq>>> {
1024 let mut map: HashMap<PackageName, Vec<Option<VersionReq>>> = HashMap::new();
1025 for d in pr.dependencies() {
1026 map.entry(d.name().clone())
1027 .or_default()
1028 .push(d.version_requirement().cloned());
1029 }
1030 map
1031}
1032
1033struct FetchResult {
1035 versions: HashMap<PackageName, PackageVersions>,
1037 yanked_versions: HashMap<PackageName, (ConcreteVersion, RemovalStatus)>,
1046 deprecations: HashMap<PackageName, Deprecation>,
1051 fetch_failed: HashMap<PackageName, FetchFailure>,
1057 no_comparable_versions: HashSet<PackageName>,
1064 failed_count: usize,
1070 first_error: Option<String>,
1072}
1073
1074#[allow(
1117 clippy::too_many_arguments,
1118 reason = "internal (non-pub) call-site-controlled fetch tuning + ecosystem-context \
1119 parameters; grouping into a config struct would only move, not reduce, the \
1120 per-call-site churn across this module's ~15 production and test call sites"
1121)]
1122async fn fetch_latest_versions_parallel(
1123 registry: Arc<dyn Registry>,
1124 package_sources: DepSources,
1125 in_use: &HashMap<PackageName, Vec<String>>,
1126 progress_sender: Option<ProgressSender>,
1127 freshness: deps_core::freshness::FreshnessSettings,
1128 timeout_secs: u64,
1129 max_concurrent: usize,
1130 minimum_stability: Option<&str>,
1131) -> FetchResult {
1132 use futures::stream::{self, StreamExt};
1133 use std::time::Duration;
1134
1135 let fetched = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1136 let failed = Arc::new(std::sync::atomic::AtomicUsize::new(0));
1137 let first_error: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
1138 let timeout = Duration::from_secs(timeout_secs);
1155 let wildcard_req = deps_core::VersionReq::new("*");
1156 let check_yanked = registry.reports_yanked();
1157
1158 let results: Vec<_> = stream::iter(package_sources)
1159 .map(|(name, source)| {
1160 let registry = Arc::clone(®istry);
1161 let fetched = Arc::clone(&fetched);
1162 let failed = Arc::clone(&failed);
1163 let first_error = Arc::clone(&first_error);
1164 let progress_sender = progress_sender.clone();
1165 let wildcard_req = &wildcard_req;
1166 let in_use_versions = in_use.get(&name).cloned().unwrap_or_default();
1167 async move {
1168 fetch_and_classify_package(
1169 registry.as_ref(),
1170 name,
1171 source,
1172 in_use_versions,
1173 wildcard_req,
1174 freshness,
1175 timeout,
1176 minimum_stability,
1177 check_yanked,
1178 &fetched,
1179 &failed,
1180 &first_error,
1181 progress_sender.as_ref(),
1182 )
1183 .await
1184 }
1185 })
1186 .buffer_unordered(max_concurrent)
1187 .collect()
1188 .await;
1189
1190 let mut versions = HashMap::with_capacity(results.len());
1191 let mut yanked_versions = HashMap::new();
1192 let mut fetch_failed = HashMap::new();
1193 let mut deprecations = HashMap::new();
1194 let mut no_comparable_versions = HashSet::new();
1195 let mut priority_error: Option<String> = None;
1199 for (version, yanked, failed_name, deprecation, no_comparable_versions_name) in results {
1200 if let Some((name, v)) = version {
1201 versions.insert(name, v);
1202 }
1203 if let Some((name, v, status)) = yanked {
1204 yanked_versions.insert(name, (v, status));
1205 }
1206 if let Some((name, failure, message)) = failed_name {
1207 fetch_failed.insert(name, failure);
1208 if priority_error.is_none() {
1209 priority_error = Some(message);
1210 }
1211 }
1212 if let Some((name, d)) = deprecation {
1213 deprecations.insert(name, d);
1214 }
1215 if let Some(name) = no_comparable_versions_name {
1216 no_comparable_versions.insert(name);
1217 }
1218 }
1219
1220 let error_message =
1225 priority_error.or_else(|| first_error.lock().unwrap_or_else(|p| p.into_inner()).take());
1226
1227 FetchResult {
1228 versions,
1229 yanked_versions,
1230 fetch_failed,
1231 deprecations,
1232 no_comparable_versions,
1233 failed_count: failed.load(std::sync::atomic::Ordering::Relaxed),
1234 first_error: error_message,
1235 }
1236}
1237
1238type PackageFetchOutcome = (
1244 Option<(PackageName, PackageVersions)>,
1245 Option<(PackageName, ConcreteVersion, RemovalStatus)>,
1246 Option<(PackageName, FetchFailure, String)>,
1247 Option<(PackageName, Deprecation)>,
1248 Option<PackageName>,
1249);
1250
1251#[allow(
1258 clippy::too_many_arguments,
1259 reason = "mirrors the per-package async closure this was extracted from — every \
1260 parameter is either call-site fetch tuning already threaded through \
1261 fetch_latest_versions_parallel or a counter/sender shared across the \
1262 whole stream; grouping into a struct would only move, not reduce, churn"
1263)]
1264async fn fetch_and_classify_package(
1265 registry: &dyn Registry,
1266 name: PackageName,
1267 source: deps_core::parser::DependencySource,
1268 in_use_versions: Vec<String>,
1269 wildcard_req: &VersionReq,
1270 freshness: deps_core::freshness::FreshnessSettings,
1271 timeout: Duration,
1272 minimum_stability: Option<&str>,
1273 check_yanked: bool,
1274 fetched: &std::sync::atomic::AtomicUsize,
1275 failed: &std::sync::atomic::AtomicUsize,
1276 first_error: &std::sync::Mutex<Option<String>>,
1277 progress_sender: Option<&ProgressSender>,
1278) -> PackageFetchOutcome {
1279 let result = tokio::time::timeout(
1290 timeout,
1291 registry.get_versions_from(&name, &source, freshness),
1292 )
1293 .await;
1294
1295 let mut yanked: Option<(PackageName, ConcreteVersion, RemovalStatus)> = None;
1296 let mut failed_name: Option<(PackageName, FetchFailure, String)> = None;
1297 let mut deprecation: Option<(PackageName, Deprecation)> = None;
1298 let mut no_comparable_versions = false;
1302 let version = match result {
1303 Ok(Ok(versions)) => {
1304 let available: Arc<[ConcreteVersion]> = versions
1305 .iter()
1306 .map(|v| v.version_string().clone())
1307 .collect();
1308 let yanked_list: Arc<[(ConcreteVersion, RemovalStatus)]> = if check_yanked {
1317 versions
1318 .iter()
1319 .filter_map(|v| {
1320 let status = v.removal_status();
1321 status
1322 .is_flagged()
1323 .then(|| (v.version_string().clone(), status))
1324 })
1325 .collect()
1326 } else {
1327 Arc::from([])
1328 };
1329 let resolved = if let Some(v) = registry
1337 .select_latest_matching_with_context(&versions, wildcard_req, minimum_stability)
1338 .and_then(|idx| versions.get(idx))
1339 {
1340 let latest = v.version_string().clone();
1341 tracing::debug!(package = %name, version = %latest, "fetched");
1342 Some((
1343 latest,
1344 v.removal_status(),
1345 v.published_at(),
1346 v.deprecation().cloned(),
1347 ))
1348 } else {
1349 let fallback = tokio::time::timeout(
1361 timeout,
1362 registry.get_latest_matching_from(
1363 &name,
1364 &source,
1365 wildcard_req,
1366 minimum_stability,
1367 ),
1368 )
1369 .await;
1370 match fallback {
1371 Ok(Ok(Some(v))) => {
1372 let latest = v.version_string().clone();
1373 tracing::debug!(
1374 package = %name,
1375 version = %latest,
1376 "fetched via get_latest_matching fallback"
1377 );
1378 Some((
1379 latest,
1380 v.removal_status(),
1381 v.published_at(),
1382 v.deprecation().cloned(),
1383 ))
1384 }
1385 Ok(Ok(None)) => {
1386 tracing::debug!(package = %name, "no version found");
1387 no_comparable_versions = true;
1396 None
1397 }
1398 Ok(Err(e)) => {
1399 tracing::warn!(
1400 package = %name,
1401 error = %e,
1402 "fetch fallback failed"
1403 );
1404 failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1405 let mut fe = first_error.lock().unwrap_or_else(|p| p.into_inner());
1406 if fe.is_none() {
1407 *fe = Some(e.to_string());
1408 }
1409 drop(fe);
1410 if !e.is_not_found() {
1415 failed_name = Some((name.clone(), e.fetch_failure(), e.to_string()));
1416 }
1417 None
1418 }
1419 Err(_) => {
1420 tracing::warn!(
1421 package = %name,
1422 "fetch fallback timed out ({}s)",
1423 timeout.as_secs()
1424 );
1425 failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1426 failed_name = Some((
1427 name.clone(),
1428 FetchFailure::Transient,
1429 format!(
1430 "{name}: registry request timed out after {}s",
1431 timeout.as_secs()
1432 ),
1433 ));
1434 None
1435 }
1436 }
1437 };
1438
1439 if check_yanked {
1440 if let Some((latest, status, _, _)) = &resolved
1447 && status.is_flagged()
1448 {
1449 yanked = Some((name.clone(), latest.clone(), *status));
1450 }
1451
1452 if let Some((iv, status)) = in_use_versions.iter().find_map(|iv| {
1478 versions
1479 .iter()
1480 .find(|v| {
1481 v.version_string() == iv.as_str() && v.removal_status().is_flagged()
1482 })
1483 .map(|v| (iv, v.removal_status()))
1484 }) {
1485 yanked = Some((name.clone(), iv.as_str().into(), status));
1486 }
1487 }
1488
1489 if let Some((_, _, _, dep_info)) = &resolved
1496 && let Some(dep_info) = dep_info
1497 {
1498 deprecation = Some((name.clone(), dep_info.clone()));
1499 }
1500
1501 resolved.map(|(latest, _, published_at, _)| {
1502 (
1503 name.clone(),
1504 PackageVersions {
1505 latest,
1506 available,
1507 yanked: yanked_list,
1508 published_at,
1509 },
1510 )
1511 })
1512 }
1513 Ok(Err(e)) => {
1514 if e.is_offline() {
1519 tracing::debug!(package = %name, "fetch skipped: offline");
1520 } else {
1521 tracing::warn!(package = %name, error = %e, "fetch failed");
1522 }
1523 failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1524 let mut fe = first_error.lock().unwrap_or_else(|p| p.into_inner());
1525 if fe.is_none() {
1526 *fe = Some(e.to_string());
1527 }
1528 drop(fe);
1529 if !e.is_not_found() {
1538 failed_name = Some((name.clone(), e.fetch_failure(), e.to_string()));
1539 }
1540 None
1541 }
1542 Err(_) => {
1543 tracing::warn!(package = %name, "fetch timed out ({}s)", timeout.as_secs());
1544 failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1545 failed_name = Some((
1546 name.clone(),
1547 FetchFailure::Transient,
1548 format!(
1549 "{name}: registry request timed out after {}s",
1550 timeout.as_secs()
1551 ),
1552 ));
1553 None
1554 }
1555 };
1556
1557 let count = fetched.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
1558 if let Some(sender) = progress_sender {
1559 sender.send(count);
1560 }
1561
1562 let no_comparable_versions_name = no_comparable_versions.then(|| name.clone());
1563 (
1564 version,
1565 yanked,
1566 failed_name,
1567 deprecation,
1568 no_comparable_versions_name,
1569 )
1570}
1571
1572fn fetch_failure_toast(
1589 failed_count: usize,
1590 first_error: Option<&str>,
1591 offline: bool,
1592) -> Option<String> {
1593 if failed_count == 0 || offline {
1594 return None;
1595 }
1596 Some(format!(
1597 "deps-lsp: {failed_count} package(s) could not be resolved: {}",
1598 first_error.unwrap_or("timeout or network error")
1599 ))
1600}
1601
1602pub async fn handle_document_open(
1607 uri: Uri,
1608 content: String,
1609 version: Option<i32>,
1610 state: Arc<ServerState>,
1611 client: Client,
1612 config: Arc<RwLock<DepsConfig>>,
1613) -> Result<JoinHandle<()>> {
1614 let ecosystem = match state.ecosystem_registry.get_for_uri(&uri) {
1616 Some(e) => e,
1617 None => {
1618 tracing::debug!("No ecosystem handler for {:?}", uri);
1619 return Err(deps_core::error::DepsError::UnsupportedEcosystem(format!(
1620 "{uri:?}"
1621 )));
1622 }
1623 };
1624
1625 check_content_size(&content, &uri)?;
1626
1627 tracing::info!(
1628 "Opening {:?} with ecosystem: {}",
1629 uri,
1630 ecosystem.display_name()
1631 );
1632
1633 let parse_result = ecosystem.parse_manifest(&content, &uri).await.ok();
1635
1636 let mut doc_state = if let Some(pr) = parse_result {
1638 DocumentState::new_from_parse_result(resolve_ecosystem_id(&*ecosystem), content, pr)
1639 } else {
1640 tracing::debug!("Failed to parse manifest, storing document without parse result");
1641 DocumentState::new_without_parse_result(resolve_ecosystem_id(&*ecosystem), content)
1642 };
1643 doc_state.set_version(version);
1644
1645 state.update_document(uri.clone(), doc_state);
1646
1647 let (cache_config, vulnerabilities_enabled, freshness_settings, diagnostic_severities, offline) = {
1651 let cfg = config.read().await;
1652 (
1653 cfg.cache.clone(),
1654 cfg.diagnostics.vulnerabilities_enabled,
1655 cfg.freshness.to_settings(),
1656 cfg.diagnostics.to_severities(),
1657 cfg.network.offline,
1658 )
1659 };
1660
1661 let task = tokio::spawn(run_document_open_background_task(
1663 uri.clone(),
1664 Arc::clone(&state),
1665 Arc::clone(&ecosystem),
1666 client.clone(),
1667 cache_config,
1668 vulnerabilities_enabled,
1669 freshness_settings,
1670 diagnostic_severities,
1671 offline,
1672 ));
1673
1674 Ok(task)
1675}
1676
1677#[allow(
1683 clippy::too_many_arguments,
1684 reason = "mirrors the async move closure this was extracted from — every parameter \
1685 is config already read (and thus fixed) before the task was spawned in \
1686 handle_document_open, so a config struct here would only relocate, not \
1687 reduce, the parameter list"
1688)]
1689async fn run_document_open_background_task(
1690 uri: Uri,
1691 state: Arc<ServerState>,
1692 ecosystem: Arc<dyn Ecosystem>,
1693 client: Client,
1694 cache_config: crate::config::CacheConfig,
1695 vulnerabilities_enabled: bool,
1696 freshness_settings: deps_core::freshness::FreshnessSettings,
1697 diagnostic_severities: deps_core::DiagnosticSeverities,
1698 offline: bool,
1699) {
1700 tracing::debug!("background task started");
1701
1702 let resolved_versions = load_resolved_versions(&uri, &state, ecosystem.as_ref()).await;
1704
1705 if !resolved_versions.is_empty()
1707 && let Some(mut doc) = state.documents.get_mut(&uri)
1708 {
1709 doc.update_resolved_versions(resolved_versions.clone());
1710
1711 let formatter = ecosystem.formatter();
1720 let instant_resolved: HashMap<PackageName, ConcreteVersion> = match doc.parse_result() {
1721 Some(parse_result) => {
1722 let deps = parse_result.dependencies();
1723 resolved_versions
1724 .iter()
1725 .filter(|(name, _)| {
1726 deps.iter()
1727 .find(|d| d.name() == *name)
1728 .is_none_or(|d| !formatter.manifest_requirement_is_resolved_version(*d))
1729 })
1730 .map(|(name, version)| (name.clone(), version.clone()))
1731 .collect()
1732 }
1733 None => resolved_versions.clone(),
1734 };
1735 doc.update_cached_versions(cached_versions_from_lockfile(&instant_resolved));
1736 }
1737
1738 let osv_task = vulnerabilities_enabled.then(|| {
1742 tokio::spawn(run_osv_scan_phase_a(
1743 uri.clone(),
1744 Arc::clone(&state),
1745 Arc::clone(&ecosystem),
1746 cache_config.fetch_timeout_secs,
1747 ))
1748 });
1749
1750 let (dep_sources, in_use, minimum_stability, collided_names): (
1753 DepSources,
1754 HashMap<PackageName, Vec<String>>,
1755 Option<String>,
1756 HashSet<PackageName>,
1757 ) = {
1758 let doc = match state.get_document(&uri) {
1759 Some(d) => d,
1760 None => {
1761 tracing::warn!("document not found, aborting fetch");
1762 return;
1763 }
1764 };
1765 let parse_result = match doc.parse_result() {
1766 Some(p) => p,
1767 None => {
1768 tracing::warn!("no parse result, aborting fetch");
1769 return;
1770 }
1771 };
1772 let (sources_map, collided_names) =
1781 dedup_dependencies_by_source(parse_result, ecosystem.formatter());
1782 let dep_sources: Vec<_> = sources_map.into_iter().collect();
1783 let in_use = collect_in_use_versions(
1784 parse_result,
1785 &resolved_versions,
1786 ecosystem.formatter(),
1787 resolve_ecosystem_id(ecosystem.as_ref()),
1788 );
1789 let minimum_stability = composer_minimum_stability(parse_result);
1790 (dep_sources, in_use, minimum_stability, collided_names)
1791 };
1792
1793 tracing::debug!(count = dep_sources.len(), "starting registry fetch");
1794
1795 let fetch_permit = state
1805 .fetch_permits
1806 .acquire()
1807 .await
1808 .expect("fetch_permits semaphore is never closed");
1809
1810 if let Some(mut doc) = state.documents.get_mut(&uri) {
1812 doc.set_loading();
1813 }
1814
1815 let (progress, progress_sender) = if state.supports_progress() {
1816 match tokio::time::timeout(
1817 std::time::Duration::from_secs(2),
1818 RegistryProgress::start(client.clone(), uri.as_str(), dep_sources.len()),
1819 )
1820 .await
1821 {
1822 Ok(Ok((p, s))) => (Some(p), Some(s)),
1823 _ => (None, None),
1824 }
1825 } else {
1826 (None, None)
1827 };
1828
1829 tracing::debug!("progress started, fetching versions");
1830
1831 let registry = ecosystem.registry();
1833 let fetch_result = fetch_latest_versions_parallel(
1834 registry,
1835 dep_sources,
1836 &in_use,
1837 progress_sender,
1838 freshness_settings,
1839 cache_config.fetch_timeout_secs,
1840 cache_config.max_concurrent_fetches,
1841 minimum_stability.as_deref(),
1842 )
1843 .await;
1844 drop(fetch_permit);
1845
1846 let success = !fetch_result.versions.is_empty();
1847 tracing::debug!(
1848 fetched = fetch_result.versions.len(),
1849 failed = fetch_result.failed_count,
1850 yanked = fetch_result.yanked_versions.len(),
1851 "registry fetch complete"
1852 );
1853
1854 if let Some(mut doc) = state.documents.get_mut(&uri) {
1856 doc.update_cached_versions(fetch_result.versions);
1857 let formatter = ecosystem.formatter();
1860 let mut outcomes = DependencyOutcomes::new();
1861 for (name, d) in fetch_result.deprecations {
1862 outcomes.set_deprecation(formatter.normalize_package_name(&name), d);
1863 }
1864 for (name, v) in fetch_result.yanked_versions {
1865 outcomes.set_yanked(formatter.normalize_package_name(&name), v);
1866 }
1867 for (name, failure) in fetch_result.fetch_failed {
1868 outcomes.set_fetch_failure(formatter.normalize_package_name(&name), failure);
1869 }
1870 for name in fetch_result.no_comparable_versions {
1871 outcomes.set_no_comparable_versions(formatter.normalize_package_name(&name));
1872 }
1873 for name in collided_names {
1882 outcomes.set_fetch_failure_if_absent(
1883 formatter.normalize_package_name(&name),
1884 FetchFailure::NotAttempted,
1885 );
1886 }
1887 doc.replace_outcomes(outcomes);
1888 if success {
1889 doc.set_loaded();
1890 } else {
1891 doc.set_failed();
1892 }
1893 }
1894
1895 if let Some(progress) = progress {
1897 progress.end(success).await;
1898 }
1899
1900 match fetch_failure_toast(
1906 fetch_result.failed_count,
1907 fetch_result.first_error.as_deref(),
1908 state.cache.is_offline(),
1909 ) {
1910 Some(message) => {
1911 client.show_message(MessageType::WARNING, message).await;
1912 }
1913 None if fetch_result.failed_count > 0 => {
1914 tracing::debug!(
1915 failed_count = fetch_result.failed_count,
1916 "suppressing fetch-failure toast: offline"
1917 );
1918 }
1919 None => {}
1920 }
1921
1922 state.spawn_refresh_requests(&client);
1931
1932 if let Some(osv_task) = osv_task {
1937 match osv_task.await {
1938 Ok(Some(phase_a_result)) => {
1939 let ecosystem_id = resolve_ecosystem_id(ecosystem.as_ref());
1940 run_osv_phase_b_and_commit(
1941 &uri,
1942 &state,
1943 ecosystem_id,
1944 ecosystem.formatter(),
1945 cache_config.fetch_timeout_secs,
1946 phase_a_result,
1947 )
1948 .await;
1949 }
1950 Ok(None) => {}
1951 Err(e) => tracing::warn!("OSV scan task failed: {e}"),
1952 }
1953 }
1954
1955 let diags = diagnostics::generate_diagnostics_internal(
1957 Arc::clone(&state),
1958 &uri,
1959 freshness_settings,
1960 diagnostic_severities,
1961 offline,
1962 )
1963 .await;
1964
1965 client.publish_diagnostics(uri.clone(), diags, None).await;
1966}
1967
1968async fn parse_and_diff_manifest(
1973 uri: &Uri,
1974 content: &str,
1975 state: &ServerState,
1976 ecosystem: &dyn Ecosystem,
1977) -> (Option<Box<dyn deps_core::ParseResult>>, DependencyDiff) {
1978 let old_deps: HashMap<PackageName, Vec<Option<VersionReq>>> =
1981 state.get_document(uri).map_or_else(HashMap::new, |doc| {
1982 doc.parse_result()
1983 .map(dependency_version_map)
1984 .unwrap_or_default()
1985 });
1986
1987 let parse_result = ecosystem.parse_manifest(content, uri).await.ok();
1989
1990 let new_deps: HashMap<PackageName, Vec<Option<VersionReq>>> = parse_result
1992 .as_ref()
1993 .map(|pr| dependency_version_map(pr.as_ref()))
1994 .unwrap_or_default();
1995
1996 let diff = DependencyDiff::compute(&old_deps, &new_deps);
1998 tracing::debug!(
1999 added = diff.added.len(),
2000 removed = diff.removed.len(),
2001 version_changed = diff.version_changed.len(),
2002 "dependency diff"
2003 );
2004
2005 (parse_result, diff)
2006}
2007
2008#[derive(Debug, Clone, Copy)]
2011pub(crate) enum CommitGuard {
2012 Unconditional,
2014 ExpectVersion(Option<i32>),
2017}
2018
2019struct CommitOptions<'a> {
2022 diff: &'a DependencyDiff,
2023 guard: CommitGuard,
2024}
2025
2026#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2037pub(crate) enum RefetchPolicy {
2038 Diff,
2042 AllDependencies,
2047}
2048
2049fn commit_parsed_document(
2065 uri: &Uri,
2066 ecosystem: &dyn Ecosystem,
2067 content: String,
2068 parse_result: Option<Box<dyn deps_core::ParseResult>>,
2069 version: Option<i32>,
2070 state: &ServerState,
2071 options: CommitOptions<'_>,
2072) -> bool {
2073 let diff = options.diff;
2074 if let CommitGuard::ExpectVersion(expected) = options.guard {
2075 let current = state.with_document(uri, |doc| doc.version);
2076 if current != Some(expected) {
2077 tracing::debug!(
2078 ?uri,
2079 ?expected,
2080 ?current,
2081 "skipping stale reparse commit: document version changed since this reparse started"
2082 );
2083 return false;
2084 }
2085 }
2086
2087 let mut doc_state = if let Some(pr) = parse_result {
2088 DocumentState::new_from_parse_result(resolve_ecosystem_id(ecosystem), content, pr)
2089 } else {
2090 tracing::debug!("Failed to parse manifest, storing document without parse result");
2091 DocumentState::new_without_parse_result(resolve_ecosystem_id(ecosystem), content)
2092 };
2093 doc_state.set_version(version);
2094
2095 if let Some(old_doc) = state.get_document(uri) {
2096 preserve_cache(&mut doc_state, &old_doc);
2097 }
2098
2099 let formatter = ecosystem.formatter();
2105 for removed_dep in &diff.removed {
2106 doc_state.cached_versions.remove(removed_dep);
2107 doc_state.resolved_versions.remove(removed_dep);
2108 doc_state
2109 .vulnerabilities
2110 .remove(&formatter.normalize_package_name(removed_dep));
2111 doc_state
2112 .outcomes
2113 .remove(&formatter.normalize_package_name(removed_dep));
2114 }
2115
2116 for changed_dep in &diff.version_changed {
2131 let normalized = formatter.normalize_package_name(changed_dep);
2132 doc_state.outcomes.clear_yanked(&normalized);
2133 doc_state.outcomes.clear_fetch_failure(&normalized);
2134 }
2135
2136 state.update_document(uri.clone(), doc_state);
2137 true
2138}
2139
2140pub async fn handle_document_change(
2145 uri: Uri,
2146 content: String,
2147 version: Option<i32>,
2148 state: Arc<ServerState>,
2149 client: Client,
2150 config: Arc<RwLock<DepsConfig>>,
2151) -> Result<JoinHandle<()>> {
2152 let task = handle_document_change_guarded(
2153 uri,
2154 content,
2155 version,
2156 CommitGuard::Unconditional,
2157 RefetchPolicy::Diff,
2158 state,
2159 client,
2160 config,
2161 )
2162 .await?;
2163 Ok(task.expect("CommitGuard::Unconditional never skips the commit"))
2164}
2165
2166#[allow(
2186 clippy::too_many_arguments,
2187 reason = "issue #592 added `refetch: RefetchPolicy` alongside the pre-existing `guard: \
2188 CommitGuard` — both are commit/fetch-behavior switches the caller must set \
2189 independently; bundling them into one struct would only relocate, not reduce, \
2190 the parameter list"
2191)]
2192pub(crate) async fn handle_document_change_guarded(
2193 uri: Uri,
2194 content: String,
2195 version: Option<i32>,
2196 guard: CommitGuard,
2197 refetch: RefetchPolicy,
2198 state: Arc<ServerState>,
2199 client: Client,
2200 config: Arc<RwLock<DepsConfig>>,
2201) -> Result<Option<JoinHandle<()>>> {
2202 let ecosystem = match state.ecosystem_registry.get_for_uri(&uri) {
2204 Some(e) => e,
2205 None => {
2206 tracing::debug!("No ecosystem handler for {:?}", uri);
2207 return Err(deps_core::error::DepsError::UnsupportedEcosystem(format!(
2208 "{uri:?}"
2209 )));
2210 }
2211 };
2212
2213 check_content_size(&content, &uri)?;
2214
2215 let (parse_result, diff) =
2216 parse_and_diff_manifest(&uri, &content, &state, ecosystem.as_ref()).await;
2217
2218 let all_dependency_names: Vec<PackageName> = match refetch {
2224 RefetchPolicy::Diff => Vec::new(),
2225 RefetchPolicy::AllDependencies => parse_result
2226 .as_deref()
2227 .map(|pr| dependency_version_map(pr).into_keys().collect())
2228 .unwrap_or_default(),
2229 };
2230
2231 if !commit_parsed_document(
2232 &uri,
2233 ecosystem.as_ref(),
2234 content,
2235 parse_result,
2236 version,
2237 &state,
2238 CommitOptions { diff: &diff, guard },
2239 ) {
2240 return Ok(None);
2241 }
2242
2243 let (cache_config, vulnerabilities_enabled, freshness_settings, diagnostic_severities, offline) = {
2246 let cfg = config.read().await;
2247 (
2248 cfg.cache.clone(),
2249 cfg.diagnostics.vulnerabilities_enabled,
2250 cfg.freshness.to_settings(),
2251 cfg.diagnostics.to_severities(),
2252 cfg.network.offline,
2253 )
2254 };
2255
2256 let needs_osv_rescan = diff.needs_osv_rescan();
2257 let deps_to_fetch = match refetch {
2258 RefetchPolicy::Diff => {
2259 let mut v = diff.added;
2264 v.extend(diff.version_changed);
2265 v
2266 }
2267 RefetchPolicy::AllDependencies => all_dependency_names,
2268 };
2269
2270 let task = tokio::spawn(run_document_change_task(
2272 uri,
2273 state,
2274 ecosystem,
2275 client,
2276 ChangeTaskConfig {
2277 cache: cache_config,
2278 vulnerabilities_enabled,
2279 freshness: freshness_settings,
2280 diagnostic_severities,
2281 offline,
2282 refetch,
2283 },
2284 needs_osv_rescan,
2285 deps_to_fetch,
2286 ));
2287
2288 Ok(Some(task))
2289}
2290
2291struct ChangeTaskConfig {
2296 cache: crate::config::CacheConfig,
2297 vulnerabilities_enabled: bool,
2298 freshness: deps_core::FreshnessSettings,
2299 diagnostic_severities: deps_core::DiagnosticSeverities,
2300 offline: bool,
2301 refetch: RefetchPolicy,
2302}
2303
2304async fn run_document_change_task(
2309 uri: Uri,
2310 state: Arc<ServerState>,
2311 ecosystem: Arc<dyn Ecosystem>,
2312 client: Client,
2313 config: ChangeTaskConfig,
2314 needs_osv_rescan: bool,
2315 deps_to_fetch: Vec<PackageName>,
2316) {
2317 tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
2319
2320 let resolved_versions = load_resolved_versions(&uri, &state, ecosystem.as_ref()).await;
2322
2323 if !resolved_versions.is_empty()
2326 && let Some(mut doc) = state.documents.get_mut(&uri)
2327 {
2328 doc.update_resolved_versions(resolved_versions.clone());
2329 }
2330
2331 let osv_task = (config.vulnerabilities_enabled && needs_osv_rescan).then(|| {
2335 tokio::spawn(run_osv_scan_phase_a(
2336 uri.clone(),
2337 Arc::clone(&state),
2338 Arc::clone(&ecosystem),
2339 config.cache.fetch_timeout_secs,
2340 ))
2341 });
2342
2343 if deps_to_fetch.is_empty() {
2354 tracing::debug!("no added or version-changed dependencies, skipping registry fetch");
2355
2356 if let Some(mut doc) = state.documents.get_mut(&uri) {
2357 doc.set_loaded();
2358 }
2359
2360 state.spawn_refresh_requests(&client);
2363
2364 await_and_commit_osv_phase_b(
2365 osv_task,
2366 &uri,
2367 &state,
2368 ecosystem.as_ref(),
2369 config.cache.fetch_timeout_secs,
2370 )
2371 .await;
2372
2373 generate_and_publish_diagnostics(
2374 &state,
2375 &uri,
2376 &client,
2377 config.freshness,
2378 config.diagnostic_severities,
2379 config.offline,
2380 )
2381 .await;
2382 return;
2383 }
2384
2385 let fetch_permit = state
2391 .fetch_permits
2392 .acquire()
2393 .await
2394 .expect("fetch_permits semaphore is never closed");
2395
2396 let (progress, fetch_result, attempted_names, collided_names) =
2397 fetch_registry_versions_for_change(
2398 &uri,
2399 &state,
2400 &client,
2401 ecosystem.as_ref(),
2402 &resolved_versions,
2403 deps_to_fetch,
2404 config.freshness,
2405 config.cache.fetch_timeout_secs,
2406 config.cache.max_concurrent_fetches,
2407 config.refetch,
2408 )
2409 .await;
2410
2411 let success = !fetch_result.versions.is_empty();
2412
2413 let (failed_count, first_error) = merge_registry_fetch_result(
2415 &state,
2416 &uri,
2417 ecosystem.formatter(),
2418 fetch_result,
2419 &attempted_names,
2420 collided_names,
2421 success,
2422 );
2423 drop(fetch_permit);
2424
2425 if let Some(progress) = progress {
2426 progress.end(success).await;
2427 }
2428
2429 match fetch_failure_toast(
2435 failed_count,
2436 first_error.as_deref(),
2437 state.cache.is_offline(),
2438 ) {
2439 Some(message) => {
2440 client.show_message(MessageType::WARNING, message).await;
2441 }
2442 None if failed_count > 0 => {
2443 tracing::debug!(failed_count, "suppressing fetch-failure toast: offline");
2444 }
2445 None => {}
2446 }
2447
2448 state.spawn_refresh_requests(&client);
2451
2452 await_and_commit_osv_phase_b(
2453 osv_task,
2454 &uri,
2455 &state,
2456 ecosystem.as_ref(),
2457 config.cache.fetch_timeout_secs,
2458 )
2459 .await;
2460
2461 generate_and_publish_diagnostics(
2462 &state,
2463 &uri,
2464 &client,
2465 config.freshness,
2466 config.diagnostic_severities,
2467 config.offline,
2468 )
2469 .await;
2470}
2471
2472async fn await_and_commit_osv_phase_b(
2478 osv_task: Option<JoinHandle<Option<OsvScanResult>>>,
2479 uri: &Uri,
2480 state: &Arc<ServerState>,
2481 ecosystem: &dyn Ecosystem,
2482 fetch_timeout_secs: u64,
2483) {
2484 let Some(osv_task) = osv_task else {
2485 return;
2486 };
2487 match osv_task.await {
2488 Ok(Some(phase_a_result)) => {
2489 let ecosystem_id = resolve_ecosystem_id(ecosystem);
2490 run_osv_phase_b_and_commit(
2491 uri,
2492 state,
2493 ecosystem_id,
2494 ecosystem.formatter(),
2495 fetch_timeout_secs,
2496 phase_a_result,
2497 )
2498 .await;
2499 }
2500 Ok(None) => {}
2501 Err(e) => tracing::warn!("OSV scan task failed: {e}"),
2502 }
2503}
2504
2505async fn generate_and_publish_diagnostics(
2509 state: &Arc<ServerState>,
2510 uri: &Uri,
2511 client: &Client,
2512 freshness_settings: deps_core::FreshnessSettings,
2513 diagnostic_severities: deps_core::DiagnosticSeverities,
2514 offline: bool,
2515) {
2516 let diags = diagnostics::generate_diagnostics_internal(
2517 Arc::clone(state),
2518 uri,
2519 freshness_settings,
2520 diagnostic_severities,
2521 offline,
2522 )
2523 .await;
2524 client.publish_diagnostics(uri.clone(), diags, None).await;
2525}
2526
2527#[allow(clippy::too_many_arguments)]
2534async fn fetch_registry_versions_for_change(
2535 uri: &Uri,
2536 state: &ServerState,
2537 client: &Client,
2538 ecosystem: &dyn Ecosystem,
2539 resolved_versions: &HashMap<PackageName, ConcreteVersion>,
2540 deps_to_fetch: Vec<PackageName>,
2541 freshness_settings: deps_core::FreshnessSettings,
2542 fetch_timeout_secs: u64,
2543 max_concurrent_fetches: usize,
2544 refetch: RefetchPolicy,
2545) -> (
2546 Option<RegistryProgress>,
2547 FetchResult,
2548 Vec<PackageName>,
2549 HashSet<PackageName>,
2550) {
2551 tracing::info!(
2552 count = deps_to_fetch.len(),
2553 "fetching versions for added/version-changed dependencies"
2554 );
2555
2556 if let Some(mut doc) = state.documents.get_mut(uri) {
2563 if refetch == RefetchPolicy::AllDependencies {
2564 drop_cache_for_forced_refetch(&mut doc, &deps_to_fetch, ecosystem.formatter());
2565 }
2566 doc.set_loading();
2567 }
2568
2569 let (progress, progress_sender) = if state.supports_progress() {
2570 match tokio::time::timeout(
2571 std::time::Duration::from_secs(2),
2572 RegistryProgress::start(client.clone(), uri.as_str(), deps_to_fetch.len()),
2573 )
2574 .await
2575 {
2576 Ok(Ok((p, s))) => (Some(p), Some(s)),
2577 _ => (None, None),
2578 }
2579 } else {
2580 (None, None)
2581 };
2582
2583 let (in_use, minimum_stability, dep_sources, collided_names): (
2587 HashMap<PackageName, Vec<String>>,
2588 Option<String>,
2589 DepSources,
2590 HashSet<PackageName>,
2591 ) = match state.get_document(uri) {
2592 Some(doc) => match doc.parse_result() {
2593 Some(pr) => {
2594 let (sources, collided_names) =
2595 dedup_dependencies_by_source(pr, ecosystem.formatter());
2596 let dep_sources = deps_to_fetch
2597 .iter()
2598 .filter_map(|name| sources.get(name).map(|s| (name.clone(), s.clone())))
2599 .collect();
2600 (
2601 collect_in_use_versions(
2602 pr,
2603 resolved_versions,
2604 ecosystem.formatter(),
2605 resolve_ecosystem_id(ecosystem),
2606 ),
2607 composer_minimum_stability(pr),
2608 dep_sources,
2609 collided_names,
2610 )
2611 }
2612 None => (HashMap::new(), None, Vec::new(), HashSet::new()),
2613 },
2614 None => (HashMap::new(), None, Vec::new(), HashSet::new()),
2615 };
2616
2617 let attempted_names: Vec<PackageName> =
2627 dep_sources.iter().map(|(name, _)| name.clone()).collect();
2628 let registry = ecosystem.registry();
2629 let fetch_result = fetch_latest_versions_parallel(
2630 registry,
2631 dep_sources,
2632 &in_use,
2633 progress_sender,
2634 freshness_settings,
2635 fetch_timeout_secs,
2636 max_concurrent_fetches,
2637 minimum_stability.as_deref(),
2638 )
2639 .await;
2640
2641 (progress, fetch_result, attempted_names, collided_names)
2642}
2643
2644fn merge_registry_fetch_result(
2652 state: &ServerState,
2653 uri: &Uri,
2654 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
2655 fetch_result: FetchResult,
2656 attempted_names: &[PackageName],
2657 collided_names: HashSet<PackageName>,
2658 success: bool,
2659) -> (usize, Option<String>) {
2660 if let Some(mut doc) = state.documents.get_mut(uri) {
2661 let fetched_names: Vec<PackageName> = fetch_result.versions.keys().cloned().collect();
2666 for (name, version) in fetch_result.versions {
2667 doc.cached_versions.insert(name, version);
2668 }
2669 for (name, version) in fetch_result.yanked_versions {
2671 doc.outcomes
2672 .set_yanked(formatter.normalize_package_name(&name), version);
2673 }
2674 for (name, failure) in fetch_result.fetch_failed {
2675 doc.outcomes
2676 .set_fetch_failure(formatter.normalize_package_name(&name), failure);
2677 }
2678 for name in collided_names {
2682 doc.outcomes.set_fetch_failure_if_absent(
2683 formatter.normalize_package_name(&name),
2684 FetchFailure::NotAttempted,
2685 );
2686 }
2687 merge_deprecations_after_fetch(
2688 &mut doc,
2689 &fetched_names,
2690 fetch_result.deprecations,
2691 formatter,
2692 );
2693 merge_no_comparable_versions_after_fetch(
2694 &mut doc,
2695 attempted_names,
2696 fetch_result.no_comparable_versions,
2697 formatter,
2698 );
2699 if success {
2700 doc.set_loaded();
2701 } else {
2702 doc.set_failed();
2703 }
2704 }
2705
2706 (fetch_result.failed_count, fetch_result.first_error)
2707}
2708
2709fn cached_versions_from_lockfile(
2719 resolved: &HashMap<PackageName, ConcreteVersion>,
2720) -> HashMap<PackageName, PackageVersions> {
2721 resolved
2722 .iter()
2723 .map(|(name, version)| {
2724 (
2725 name.clone(),
2726 PackageVersions::latest_without_list(version.clone()),
2727 )
2728 })
2729 .collect()
2730}
2731
2732async fn load_resolved_versions(
2738 uri: &Uri,
2739 state: &ServerState,
2740 ecosystem: &dyn Ecosystem,
2741) -> HashMap<PackageName, ConcreteVersion> {
2742 let lock_provider = match ecosystem.lockfile_provider() {
2743 Some(p) => p,
2744 None => {
2745 tracing::debug!("No lock file provider for ecosystem {}", ecosystem.id());
2746 return HashMap::new();
2747 }
2748 };
2749
2750 let lockfile_path = match lock_provider.locate_lockfile(uri) {
2751 Some(path) => path,
2752 None => {
2753 tracing::debug!("No lock file found for {:?}", uri);
2754 return HashMap::new();
2755 }
2756 };
2757
2758 match state
2759 .lockfile_cache
2760 .get_or_parse(lock_provider.as_ref(), &lockfile_path)
2761 .await
2762 {
2763 Ok(resolved) => {
2764 tracing::info!(
2765 "Loaded {} resolved versions from {}",
2766 resolved.len(),
2767 lockfile_path.display()
2768 );
2769 resolved
2770 .iter()
2771 .map(|(name, pkg)| (PackageName::new(name.as_str()), pkg.version.clone().into()))
2772 .collect()
2773 }
2774 Err(e) => {
2775 tracing::warn!("Failed to parse lock file: {}", e);
2776 HashMap::new()
2777 }
2778 }
2779}
2780
2781pub async fn ensure_document_loaded(
2828 uri: &Uri,
2829 state: Arc<ServerState>,
2830 client: Client,
2831 config: Arc<RwLock<DepsConfig>>,
2832) -> bool {
2833 if state.get_document(uri).is_some() {
2835 tracing::debug!("Document already loaded: {:?}", uri);
2836 return true;
2837 }
2838
2839 let cold_start_config = { config.read().await.cold_start.clone() };
2841
2842 if !cold_start_config.enabled {
2844 tracing::debug!("Cold start disabled via configuration");
2845 return false;
2846 }
2847
2848 if !state.cold_start_limiter.allow_cold_start(uri) {
2850 tracing::warn!("Cold start rate limited: {:?}", uri);
2851 return false;
2852 }
2853
2854 if state.ecosystem_registry.get_for_uri(uri).is_none() {
2856 tracing::debug!("Unsupported file type: {:?}", uri);
2857 return false;
2858 }
2859
2860 tracing::info!("Loading document from disk (cold start): {:?}", uri);
2862 let content = match load_document_from_disk(uri).await {
2863 Ok(c) => c,
2864 Err(e) => {
2865 tracing::warn!("Failed to load document {:?}: {}", uri, e);
2866 client
2867 .log_message(MessageType::WARNING, format!("Could not load file: {e}"))
2868 .await;
2869 return false;
2870 }
2871 };
2872
2873 match handle_document_open(
2877 uri.clone(),
2878 content,
2879 None,
2880 Arc::clone(&state),
2881 client.clone(),
2882 Arc::clone(&config),
2883 )
2884 .await
2885 {
2886 Ok(task) => {
2887 state.spawn_background_task(uri.clone(), task).await;
2888 tracing::info!("Document loaded successfully from disk: {:?}", uri);
2889 true
2890 }
2891 Err(e) => {
2892 tracing::warn!("Failed to process loaded document {:?}: {}", uri, e);
2893 false
2894 }
2895 }
2896}
2897
2898#[cfg(test)]
2899mod tests {
2900 use super::*;
2901 use deps_core::parser::DependencySource;
2902 use std::assert_matches;
2903
2904 fn with_registry_source(names: Vec<PackageName>) -> Vec<(PackageName, DependencySource)> {
2909 names
2910 .into_iter()
2911 .map(|name| (name, DependencySource::Registry))
2912 .collect()
2913 }
2914
2915 mod fetch_failure_toast_tests {
2920 use super::*;
2921
2922 #[test]
2923 fn test_no_failures_produces_no_toast_regardless_of_offline() {
2924 assert_eq!(fetch_failure_toast(0, None, false), None);
2925 assert_eq!(fetch_failure_toast(0, Some("ignored"), true), None);
2926 }
2927
2928 #[test]
2929 fn test_offline_suppresses_toast_even_with_failures() {
2930 assert_eq!(
2931 fetch_failure_toast(3, Some("offline: request to https://x was blocked"), true),
2932 None,
2933 "every fetch fails by design while offline; toasting would make it unusable"
2934 );
2935 }
2936
2937 #[test]
2938 fn test_online_failure_with_first_error_uses_it_verbatim() {
2939 assert_eq!(
2940 fetch_failure_toast(1, Some("HTTP 503 for https://example.com"), false),
2941 Some(
2942 "deps-lsp: 1 package(s) could not be resolved: HTTP 503 for https://example.com"
2943 .to_string()
2944 )
2945 );
2946 }
2947
2948 #[test]
2949 fn test_online_failure_with_no_first_error_uses_count_fallback() {
2950 assert_eq!(
2951 fetch_failure_toast(5, None, false),
2952 Some(
2953 "deps-lsp: 5 package(s) could not be resolved: timeout or network error"
2954 .to_string()
2955 )
2956 );
2957 }
2958 }
2959
2960 mod refetch_policy_tests {
2964 use super::*;
2965 use deps_core::PackageVersions;
2966
2967 struct IdentityFormatter;
2971 impl deps_core::lsp_helpers::PackageNaming for IdentityFormatter {}
2972 impl deps_core::lsp_helpers::PackageRendering for IdentityFormatter {
2973 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
2974 version.to_string()
2975 }
2976 fn package_url(&self, name: &PackageName) -> String {
2977 name.to_string()
2978 }
2979 }
2980 impl deps_core::lsp_helpers::RequirementResolution for IdentityFormatter {}
2981 impl deps_core::lsp_helpers::DiagnosticMessages for IdentityFormatter {}
2982 impl deps_core::lsp_helpers::DiagnosticPolicy for IdentityFormatter {}
2983 impl deps_core::lsp_helpers::SourcePolicy for IdentityFormatter {}
2984 impl deps_core::lsp_helpers::OsvNaming for IdentityFormatter {}
2985
2986 #[test]
2990 fn test_drop_cache_for_forced_refetch_marks_pending_deps_not_attempted() {
2991 let mut doc =
2992 DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
2993 doc.update_cached_versions(HashMap::from([(
2994 PackageName::new("serde"),
2995 PackageVersions::latest_only("1.0.0"),
2996 )]));
2997 doc.update_resolved_versions(HashMap::from([(
2998 PackageName::new("serde"),
2999 ConcreteVersion::new("1.0.0"),
3000 )]));
3001 doc.replace_outcomes(
3002 DependencyOutcomes::new()
3003 .with_fetch_failure("serde", FetchFailure::Transient)
3004 .with_yanked(
3005 "other",
3006 (ConcreteVersion::new("2.0.0"), RemovalStatus::Yanked),
3007 ),
3008 );
3009
3010 drop_cache_for_forced_refetch(
3011 &mut doc,
3012 &[PackageName::new("serde")],
3013 &IdentityFormatter,
3014 );
3015
3016 assert!(
3017 doc.cached_versions.is_empty(),
3018 "cached_versions must be dropped"
3019 );
3020 assert_eq!(
3021 doc.outcomes.fetch_failure("serde"),
3022 Some(&FetchFailure::NotAttempted),
3023 "the stale fetch-failure finding must be replaced with a NotAttempted \
3024 placeholder, not left absent (S1: an absent entry surviving into a \
3025 concurrent empty-diff commit renders as the misleading 'Unknown package')"
3026 );
3027 assert!(
3028 doc.outcomes.yanked("other").is_some(),
3029 "a yanked finding on a different package must survive untouched"
3030 );
3031 assert_eq!(
3032 doc.resolved_versions.len(),
3033 1,
3034 "resolved_versions (lockfile-derived, registry-independent) must survive"
3035 );
3036 }
3037
3038 #[test]
3041 fn test_drop_cache_for_forced_refetch_does_not_mark_deps_outside_the_fetch_list() {
3042 let mut doc =
3043 DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
3044 doc.update_cached_versions(HashMap::from([(
3045 PackageName::new("serde"),
3046 PackageVersions::latest_only("1.0.0"),
3047 )]));
3048
3049 drop_cache_for_forced_refetch(&mut doc, &[], &IdentityFormatter);
3050
3051 assert!(doc.cached_versions.is_empty());
3052 assert!(
3053 doc.outcomes.fetch_failure("serde").is_none(),
3054 "a dependency outside deps_to_fetch must not be given a placeholder"
3055 );
3056 }
3057
3058 #[cfg(feature = "cargo")]
3063 #[tokio::test]
3064 async fn test_forced_refetch_total_failure_renders_lookup_failed_not_unknown_package() {
3065 let state = Arc::new(ServerState::new());
3066 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
3067 let content = "[dependencies]\nserde = \"1.0\"\n".to_string();
3068
3069 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
3070 let parse_result = ecosystem.parse_manifest(&content, &uri).await.unwrap();
3071 let mut doc_state =
3072 DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
3073 doc_state.set_version(Some(1));
3074 doc_state.update_cached_versions(HashMap::from([(
3078 PackageName::new("serde"),
3079 PackageVersions::latest_only("1.0.999"),
3080 )]));
3081 state.update_document(uri.clone(), doc_state);
3082
3083 if let Some(mut doc) = state.documents.get_mut(&uri) {
3085 drop_cache_for_forced_refetch(
3086 &mut doc,
3087 &[PackageName::new("serde")],
3088 ecosystem.formatter(),
3089 );
3090 }
3091
3092 let fetch_result = FetchResult {
3096 versions: HashMap::new(),
3097 yanked_versions: HashMap::new(),
3098 fetch_failed: HashMap::from([(PackageName::new("serde"), FetchFailure::Transient)]),
3099 deprecations: HashMap::new(),
3100 no_comparable_versions: HashSet::new(),
3101 failed_count: 1,
3102 first_error: Some("network down".to_string()),
3103 };
3104
3105 let (failed_count, _) = merge_registry_fetch_result(
3106 &state,
3107 &uri,
3108 ecosystem.formatter(),
3109 fetch_result,
3110 &[PackageName::new("serde")],
3111 HashSet::new(),
3112 false,
3113 );
3114 assert_eq!(failed_count, 1);
3115
3116 let diags = diagnostics::generate_diagnostics_internal(
3117 Arc::clone(&state),
3118 &uri,
3119 deps_core::FreshnessSettings::default(),
3120 deps_core::DiagnosticSeverities::default(),
3121 false,
3122 )
3123 .await;
3124
3125 assert!(
3126 diags
3127 .iter()
3128 .any(|d| d.message.contains("Registry lookup failed")),
3129 "expected a 'Registry lookup failed' diagnostic, got: {diags:?}"
3130 );
3131 assert!(
3132 diags.iter().all(|d| !d.message.contains("Unknown package")),
3133 "must never render 'Unknown package' when the cache was dropped by a \
3134 forced refetch, got: {diags:?}"
3135 );
3136 }
3137
3138 #[cfg(feature = "cargo")]
3152 #[tokio::test]
3153 async fn test_concurrent_diff_edit_after_forced_refetch_drop_does_not_render_unknown_package()
3154 {
3155 let state = Arc::new(ServerState::new());
3156 let uri = deps_core::test_util::test_uri("/test/no-lockfile/Cargo.toml");
3161 let content = "[dependencies]\nserde = \"1.0\"\n".to_string();
3162
3163 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
3164 let parse_result = ecosystem.parse_manifest(&content, &uri).await.unwrap();
3165 let mut doc_state = DocumentState::new_from_parse_result(
3166 EcosystemId::Cargo,
3167 content.clone(),
3168 parse_result,
3169 );
3170 doc_state.set_version(Some(1));
3171 doc_state.update_cached_versions(HashMap::from([(
3172 PackageName::new("serde"),
3173 PackageVersions::latest_only("1.0.999"),
3174 )]));
3175 state.update_document(uri.clone(), doc_state);
3176
3177 if let Some(mut doc) = state.documents.get_mut(&uri) {
3181 drop_cache_for_forced_refetch(
3182 &mut doc,
3183 &[PackageName::new("serde")],
3184 ecosystem.formatter(),
3185 );
3186 }
3187
3188 let (client, config) = crate::test_utils::test_helpers::create_test_client_and_config();
3193 let task = handle_document_change_guarded(
3194 uri.clone(),
3195 content,
3196 Some(2),
3197 CommitGuard::Unconditional,
3198 RefetchPolicy::Diff,
3199 Arc::clone(&state),
3200 client,
3201 config,
3202 )
3203 .await
3204 .unwrap()
3205 .expect("CommitGuard::Unconditional never skips the commit");
3206 task.await.unwrap();
3207
3208 let diags = diagnostics::generate_diagnostics_internal(
3209 Arc::clone(&state),
3210 &uri,
3211 deps_core::FreshnessSettings::default(),
3212 deps_core::DiagnosticSeverities::default(),
3213 false,
3214 )
3215 .await;
3216
3217 assert!(
3218 diags.iter().all(|d| !d.message.contains("Unknown package")),
3219 "S1 regression: a dropped-cache entry surviving preserve_cache into an \
3220 empty-diff commit must never render as 'Unknown package', got: {diags:?}"
3221 );
3222 }
3223 }
3224
3225 mod open_path_semaphore_e2e_tests {
3232 use super::*;
3233 use deps_core::ecosystem::BoxFuture;
3234 use deps_core::ecosystem::private::Sealed;
3235 use deps_core::{
3236 Dependency, DiagnosticSeverities, EcosystemConfig, EcosystemFormatter,
3237 FreshnessSettings, Metadata, OsvNaming, PackageNaming, PackageRendering,
3238 RequirementResolution, SourcePolicy, Version, VersionData, completion::Completions,
3239 };
3240 use std::any::Any;
3241 use std::path::Path;
3242 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
3243 use tokio::sync::Barrier;
3244 use tower_lsp_server::ls_types::{CodeLens, Diagnostic, InlayHint, Position, Range};
3245
3246 struct NoopFormatter;
3247 impl PackageNaming for NoopFormatter {}
3248 impl PackageRendering for NoopFormatter {
3249 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
3250 version.to_string()
3251 }
3252 fn package_url(&self, name: &PackageName) -> String {
3253 format!("https://example.com/{name}")
3254 }
3255 }
3256 impl RequirementResolution for NoopFormatter {}
3257 impl deps_core::lsp_helpers::DiagnosticMessages for NoopFormatter {}
3258 impl deps_core::lsp_helpers::DiagnosticPolicy for NoopFormatter {}
3259 impl SourcePolicy for NoopFormatter {}
3260 impl OsvNaming for NoopFormatter {}
3261
3262 struct FakeDependency {
3263 name: PackageName,
3264 version_requirement: VersionReq,
3265 }
3266 impl Dependency for FakeDependency {
3267 fn name(&self) -> &PackageName {
3268 &self.name
3269 }
3270 fn name_range(&self) -> Range {
3271 Range::new(Position::new(0, 0), Position::new(0, 1))
3272 }
3273 fn version_requirement(&self) -> Option<&VersionReq> {
3274 Some(&self.version_requirement)
3275 }
3276 fn version_range(&self) -> Option<Range> {
3277 None
3278 }
3279 fn source(&self) -> deps_core::parser::DependencySource {
3280 deps_core::parser::DependencySource::Registry
3281 }
3282 fn as_any(&self) -> &dyn Any {
3283 self
3284 }
3285 }
3286
3287 struct FakeParseResult {
3288 uri: Uri,
3289 dep: FakeDependency,
3290 }
3291 impl deps_core::ParseResult for FakeParseResult {
3292 fn dependencies(&self) -> Vec<&dyn Dependency> {
3293 vec![&self.dep]
3294 }
3295 fn workspace_root(&self) -> Option<&Path> {
3296 None
3297 }
3298 fn uri(&self) -> &Uri {
3299 &self.uri
3300 }
3301 fn as_any(&self) -> &dyn Any {
3302 self
3303 }
3304 }
3305
3306 struct SlowRegistry {
3312 current: Arc<AtomicUsize>,
3313 max_seen: Arc<AtomicUsize>,
3314 delay: Duration,
3315 }
3316 impl Registry for SlowRegistry {
3317 fn get_versions<'a>(
3318 &'a self,
3319 _name: &'a PackageName,
3320 ) -> BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>> {
3321 Box::pin(async move {
3322 let now = self.current.fetch_add(1, Ordering::SeqCst) + 1;
3323 self.max_seen.fetch_max(now, Ordering::SeqCst);
3324 tokio::time::sleep(self.delay).await;
3325 self.current.fetch_sub(1, Ordering::SeqCst);
3326 Ok(vec![])
3327 })
3328 }
3329 fn get_latest_matching<'a>(
3330 &'a self,
3331 _name: &'a PackageName,
3332 _req: &'a VersionReq,
3333 ) -> BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>> {
3334 Box::pin(async move {
3335 let now = self.current.fetch_add(1, Ordering::SeqCst) + 1;
3336 self.max_seen.fetch_max(now, Ordering::SeqCst);
3337 tokio::time::sleep(self.delay).await;
3338 self.current.fetch_sub(1, Ordering::SeqCst);
3339 Ok(None)
3340 })
3341 }
3342 fn search<'a>(
3343 &'a self,
3344 _query: &'a str,
3345 _limit: usize,
3346 ) -> BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>> {
3347 Box::pin(async move { Ok(vec![]) })
3348 }
3349 fn as_any(&self) -> &dyn Any {
3350 self
3351 }
3352 }
3353
3354 struct SlowFetchEcosystem {
3355 registry: Arc<SlowRegistry>,
3356 }
3357 impl Sealed for SlowFetchEcosystem {}
3358 impl Ecosystem for SlowFetchEcosystem {
3359 fn id(&self) -> &'static str {
3360 "cargo"
3361 }
3362 fn display_name(&self) -> &'static str {
3363 "cargo"
3364 }
3365 fn manifest_filenames(&self) -> &[&'static str] {
3366 &["Cargo.toml"]
3367 }
3368 fn parse_manifest<'a>(
3369 &'a self,
3370 _content: &'a str,
3371 uri: &'a Uri,
3372 ) -> BoxFuture<'a, deps_core::Result<Box<dyn deps_core::ParseResult>>> {
3373 let uri = uri.clone();
3374 Box::pin(async move {
3375 let parse_result: Box<dyn deps_core::ParseResult> = Box::new(FakeParseResult {
3376 uri: uri.clone(),
3377 dep: FakeDependency {
3378 name: PackageName::new("pkg"),
3379 version_requirement: VersionReq::new("*"),
3380 },
3381 });
3382 Ok(parse_result)
3383 })
3384 }
3385 fn registry(&self) -> Arc<dyn Registry> {
3386 Arc::clone(&self.registry) as Arc<dyn Registry>
3387 }
3388 fn formatter(&self) -> &dyn EcosystemFormatter {
3389 &NoopFormatter
3390 }
3391 fn generate_inlay_hints<'a>(
3392 &'a self,
3393 _parse_result: &'a dyn deps_core::ParseResult,
3394 _versions: VersionData<'a>,
3395 _loading_state: deps_core::LoadingState,
3396 _config: &'a EcosystemConfig,
3397 ) -> BoxFuture<'a, Vec<InlayHint>> {
3398 Box::pin(async move { vec![] })
3399 }
3400 fn generate_diagnostics<'a>(
3401 &'a self,
3402 _parse_result: &'a dyn deps_core::ParseResult,
3403 _versions: VersionData<'a>,
3404 _uri: &'a Uri,
3405 _freshness: FreshnessSettings,
3406 _severities: DiagnosticSeverities,
3407 ) -> BoxFuture<'a, Vec<Diagnostic>> {
3408 Box::pin(async move { vec![] })
3409 }
3410 fn generate_code_lenses<'a>(
3411 &'a self,
3412 _parse_result: &'a dyn deps_core::ParseResult,
3413 _content: &'a str,
3414 _versions: VersionData<'a>,
3415 _uri: &'a Uri,
3416 _command_id: &'a str,
3417 ) -> BoxFuture<'a, Vec<CodeLens>> {
3418 Box::pin(async move { vec![] })
3419 }
3420 fn generate_completions<'a>(
3421 &'a self,
3422 _parse_result: &'a dyn deps_core::ParseResult,
3423 _position: Position,
3424 _content: &'a str,
3425 _freshness: FreshnessSettings,
3426 ) -> BoxFuture<'a, Completions> {
3427 Box::pin(async move { Completions::default() })
3428 }
3429 fn as_any(&self) -> &dyn Any {
3430 self
3431 }
3432 }
3433
3434 #[cfg(feature = "cargo")]
3435 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
3436 #[allow(
3437 clippy::async_yields_async,
3438 reason = "each spawned task deliberately returns handle_document_open's own \
3439 JoinHandle so the test can await the outer spawn (proving the burst was \
3440 actually launched) and the inner background task (its real fetch) \
3441 separately, in two passes below"
3442 )]
3443 async fn test_fetch_permits_bound_holds_through_open_entry_point_with_no_premature_loading()
3444 {
3445 const N: usize = 8;
3446 const FETCH_PERMITS: usize = 4; let state = Arc::new(ServerState::new());
3449 let current = Arc::new(AtomicUsize::new(0));
3450 let max_seen = Arc::new(AtomicUsize::new(0));
3451 let registry = Arc::new(SlowRegistry {
3452 current: Arc::clone(¤t),
3453 max_seen: Arc::clone(&max_seen),
3454 delay: Duration::from_millis(80),
3455 });
3456 state
3460 .ecosystem_registry
3461 .register(Arc::new(SlowFetchEcosystem { registry }));
3462
3463 let (client, config) = crate::test_utils::test_helpers::create_test_client_and_config();
3464
3465 let uris: Vec<Uri> = (0..N)
3466 .map(|i| deps_core::test_util::test_uri(&format!("/test/pkg{i}/Cargo.toml")))
3467 .collect();
3468
3469 let max_loading = Arc::new(AtomicUsize::new(0));
3474 let stop = Arc::new(AtomicBool::new(false));
3475 let poller = tokio::spawn({
3476 let state = Arc::clone(&state);
3477 let uris = uris.clone();
3478 let max_loading = Arc::clone(&max_loading);
3479 let stop = Arc::clone(&stop);
3480 async move {
3481 while !stop.load(Ordering::SeqCst) {
3482 let loading_now = uris
3483 .iter()
3484 .filter(|uri| {
3485 state.get_document(uri).is_some_and(|d| {
3486 d.loading_state == deps_core::LoadingState::Loading
3487 })
3488 })
3489 .count();
3490 max_loading.fetch_max(loading_now, Ordering::SeqCst);
3491 tokio::time::sleep(Duration::from_millis(2)).await;
3492 }
3493 }
3494 });
3495
3496 let barrier = Arc::new(Barrier::new(N));
3497 let mut handles = Vec::new();
3498 for uri in &uris {
3499 let uri = uri.clone();
3500 let state = Arc::clone(&state);
3501 let client = client.clone();
3502 let config = Arc::clone(&config);
3503 let barrier = Arc::clone(&barrier);
3504 handles.push(tokio::spawn(async move {
3505 barrier.wait().await;
3506 handle_document_open(
3507 uri,
3508 "irrelevant-content".to_string(),
3509 Some(1),
3510 state,
3511 client,
3512 config,
3513 )
3514 .await
3515 .unwrap()
3516 }));
3517 }
3518
3519 let mut bg_tasks = Vec::new();
3520 for handle in handles {
3521 bg_tasks.push(handle.await.unwrap());
3522 }
3523 for task in bg_tasks {
3524 task.await.unwrap();
3525 }
3526
3527 stop.store(true, Ordering::SeqCst);
3528 poller.await.unwrap();
3529
3530 assert_eq!(
3531 max_seen.load(Ordering::SeqCst),
3532 FETCH_PERMITS,
3533 "fetch_permits must bound real concurrent registry calls through \
3534 run_document_open_background_task to exactly P=4, neither more (unbounded) \
3535 nor less (under-contended, meaning this test isn't exercising the bound)"
3536 );
3537 assert!(
3538 max_loading.load(Ordering::SeqCst) <= FETCH_PERMITS,
3539 "M1 regression: at most FETCH_PERMITS documents may be Loading at once — a \
3540 cold-start burst must not flip every queued document to Loading before its \
3541 permit arrives (observed peak: {})",
3542 max_loading.load(Ordering::SeqCst)
3543 );
3544 for uri in &uris {
3545 let doc = state.get_document(uri).unwrap();
3546 assert_ne!(
3547 doc.loading_state,
3548 deps_core::LoadingState::Loading,
3549 "no document may be left stuck in Loading once every fetch has completed"
3550 );
3551 }
3552 }
3553 }
3554
3555 mod dedup_by_source_collision_tests {
3561 use super::*;
3562 use deps_core::Dependency;
3563 use deps_core::lsp_helpers::{
3564 DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
3565 RequirementResolution, SourcePolicy,
3566 };
3567 use std::any::Any;
3568 use tower_lsp_server::ls_types::{Position, Range};
3569
3570 struct AlternateAwareFormatter;
3574 impl PackageNaming for AlternateAwareFormatter {}
3575
3576 impl PackageRendering for AlternateAwareFormatter {
3577 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
3578 version.to_string()
3579 }
3580
3581 fn package_url(&self, name: &PackageName) -> String {
3582 format!("https://example.com/{name}")
3583 }
3584 }
3585
3586 impl RequirementResolution for AlternateAwareFormatter {}
3587
3588 impl DiagnosticMessages for AlternateAwareFormatter {}
3589
3590 impl DiagnosticPolicy for AlternateAwareFormatter {}
3591
3592 impl SourcePolicy for AlternateAwareFormatter {
3593 fn can_resolve_source(&self, source: &DependencySource) -> bool {
3594 matches!(
3595 source,
3596 DependencySource::Registry | DependencySource::AlternateRegistry { .. }
3597 )
3598 }
3599 }
3600
3601 impl OsvNaming for AlternateAwareFormatter {}
3602
3603 struct MockDep {
3604 name: PackageName,
3605 source: DependencySource,
3606 addr_tag: u32,
3607 }
3608
3609 impl Dependency for MockDep {
3610 fn name(&self) -> &PackageName {
3611 &self.name
3612 }
3613 fn name_range(&self) -> Range {
3614 Range::new(
3615 Position::new(0, self.addr_tag),
3616 Position::new(0, self.addr_tag + 1),
3617 )
3618 }
3619 fn version_requirement(&self) -> Option<&VersionReq> {
3620 None
3621 }
3622 fn version_range(&self) -> Option<Range> {
3623 None
3624 }
3625 fn source(&self) -> DependencySource {
3626 self.source.clone()
3627 }
3628 fn as_any(&self) -> &dyn Any {
3629 self
3630 }
3631 }
3632
3633 struct MockParseResult {
3634 deps: Vec<MockDep>,
3635 }
3636
3637 impl deps_core::ParseResult for MockParseResult {
3638 fn dependencies(&self) -> Vec<&dyn Dependency> {
3639 self.deps.iter().map(|d| d as &dyn Dependency).collect()
3640 }
3641 fn workspace_root(&self) -> Option<&std::path::Path> {
3642 None
3643 }
3644 fn uri(&self) -> &Uri {
3645 static URI: std::sync::OnceLock<Uri> = std::sync::OnceLock::new();
3646 URI.get_or_init(|| deps_core::test_util::test_uri("/test/Cargo.toml"))
3647 }
3648 fn as_any(&self) -> &dyn Any {
3649 self
3650 }
3651 }
3652
3653 #[test]
3654 fn test_two_different_resolvable_sources_collide_and_are_dropped() {
3655 let parse_result = MockParseResult {
3656 deps: vec![
3657 MockDep {
3658 name: PackageName::new("shared-name"),
3659 source: DependencySource::Registry,
3660 addr_tag: 0,
3661 },
3662 MockDep {
3663 name: PackageName::new("shared-name"),
3664 source: DependencySource::AlternateRegistry {
3665 index: "https://index.mycorp.dev".into(),
3666 mirrors_crates_io: false,
3667 },
3668 addr_tag: 1,
3669 },
3670 ],
3671 };
3672
3673 let (sources, collided) =
3674 dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
3675
3676 assert!(
3677 !sources.contains_key(&PackageName::new("shared-name")),
3678 "a colliding name must not be fetched under either source"
3679 );
3680 assert!(
3681 collided.contains(&PackageName::new("shared-name")),
3682 "the collision must be recorded so the caller can mark it fetch_failed"
3683 );
3684 }
3685
3686 #[test]
3687 fn test_identical_sources_do_not_collide() {
3688 let parse_result = MockParseResult {
3689 deps: vec![
3690 MockDep {
3691 name: PackageName::new("shared-name"),
3692 source: DependencySource::Registry,
3693 addr_tag: 0,
3694 },
3695 MockDep {
3696 name: PackageName::new("shared-name"),
3697 source: DependencySource::Registry,
3698 addr_tag: 1,
3699 },
3700 ],
3701 };
3702
3703 let (sources, collided) =
3704 dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
3705
3706 assert!(collided.is_empty());
3707 assert_eq!(
3708 sources.get(&PackageName::new("shared-name")),
3709 Some(&DependencySource::Registry)
3710 );
3711 }
3712
3713 #[test]
3718 fn test_non_resolvable_source_is_dropped_not_fetched() {
3719 let parse_result = MockParseResult {
3720 deps: vec![MockDep {
3721 name: PackageName::new("local-fork"),
3722 source: DependencySource::Path {
3723 path: "../local-fork".into(),
3724 },
3725 addr_tag: 0,
3726 }],
3727 };
3728
3729 let (sources, collided) =
3730 dedup_dependencies_by_source(&parse_result, &AlternateAwareFormatter);
3731
3732 assert!(sources.is_empty());
3733 assert!(collided.is_empty());
3734 }
3735 }
3736
3737 #[test]
3740 fn test_ecosystem_registry_unknown_file() {
3741 let state = ServerState::new();
3742 let unknown_uri = deps_core::test_util::test_uri("/test/unknown.txt");
3743 assert!(state.ecosystem_registry.get_for_uri(&unknown_uri).is_none());
3744 }
3745
3746 #[test]
3747 fn test_check_content_size_accepts_content_within_limit() {
3748 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
3749 let content = "a".repeat(MAX_FILE_SIZE as usize);
3750 assert!(check_content_size(&content, &uri).is_ok());
3751 }
3752
3753 #[test]
3754 fn test_check_content_size_rejects_content_over_limit() {
3755 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
3756 let content = "a".repeat(MAX_FILE_SIZE as usize + 1);
3757 let result = check_content_size(&content, &uri);
3758 match result {
3759 Err(deps_core::error::DepsError::CacheError(msg)) => {
3760 assert!(msg.contains("too large"), "unexpected message: {msg}");
3761 }
3762 other => panic!("Expected CacheError, got {other:?}"),
3763 }
3764 }
3765
3766 #[test]
3774 fn test_cached_versions_from_lockfile_has_empty_available() {
3775 let mut resolved = HashMap::new();
3776 resolved.insert(PackageName::new("serde"), "1.0.195".into());
3777 resolved.insert(PackageName::new("tokio"), "1.35.0".into());
3778
3779 let cached = cached_versions_from_lockfile(&resolved);
3780
3781 assert_eq!(cached.len(), 2);
3782 let serde = cached.get(&PackageName::new("serde")).unwrap();
3783 assert_eq!(serde.latest, "1.0.195");
3784 assert!(
3785 serde.available.is_empty(),
3786 "lock-file-populated entries must have an empty available list, got: {:?}",
3787 serde.available
3788 );
3789 assert_eq!(serde.published_at, None);
3794 let tokio = cached.get(&PackageName::new("tokio")).unwrap();
3795 assert_eq!(tokio.latest, "1.35.0");
3796 assert!(tokio.available.is_empty());
3797 assert_eq!(tokio.published_at, None);
3798 }
3799
3800 #[test]
3801 fn test_cached_versions_from_lockfile_empty_input_is_empty_output() {
3802 let resolved = HashMap::new();
3803 assert!(cached_versions_from_lockfile(&resolved).is_empty());
3804 }
3805
3806 #[tokio::test]
3807 async fn test_ensure_document_loaded_unsupported_file_check() {
3808 let state = Arc::new(ServerState::new());
3810 let uri = deps_core::test_util::test_uri("/test/README.md");
3811
3812 assert!(
3814 state.ecosystem_registry.get_for_uri(&uri).is_none(),
3815 "README.md should not have an ecosystem handler"
3816 );
3817
3818 }
3821
3822 #[tokio::test]
3823 async fn test_ensure_document_loaded_file_not_found_check() {
3824 use super::load_document_from_disk;
3826
3827 let uri = deps_core::test_util::test_uri("/nonexistent/Cargo.toml");
3828 let result = load_document_from_disk(&uri).await;
3829
3830 assert!(result.is_err(), "Should fail for missing files");
3831
3832 }
3834
3835 #[tokio::test]
3836 async fn test_fetch_latest_versions_parallel_with_timeout() {
3837 use deps_core::{Metadata, Registry, Version};
3838 use std::any::Any;
3839 use std::time::Duration;
3840
3841 struct TimeoutRegistry;
3843
3844 impl Registry for TimeoutRegistry {
3845 fn get_versions<'a>(
3846 &'a self,
3847 _name: &'a deps_core::PackageName,
3848 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3849 {
3850 Box::pin(async move {
3851 tokio::time::sleep(Duration::from_secs(10)).await;
3853 Ok(vec![])
3854 })
3855 }
3856
3857 fn get_latest_matching<'a>(
3858 &'a self,
3859 _name: &'a deps_core::PackageName,
3860 _req: &'a deps_core::VersionReq,
3861 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3862 {
3863 Box::pin(async move {
3864 tokio::time::sleep(Duration::from_secs(10)).await;
3866 Ok(None)
3867 })
3868 }
3869
3870 fn search<'a>(
3871 &'a self,
3872 _query: &'a str,
3873 _limit: usize,
3874 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3875 {
3876 Box::pin(async move { Ok(vec![]) })
3877 }
3878
3879 fn as_any(&self) -> &dyn Any {
3880 self
3881 }
3882 }
3883
3884 let registry: Arc<dyn Registry> = Arc::new(TimeoutRegistry);
3885 let packages = vec![PackageName::new("slow-package")];
3886
3887 let result = fetch_latest_versions_parallel(
3889 registry,
3890 with_registry_source(packages),
3891 &HashMap::new(),
3892 None,
3893 deps_core::freshness::FreshnessSettings::default(),
3894 1,
3895 10,
3896 None,
3897 )
3898 .await;
3899
3900 assert!(result.versions.is_empty(), "Slow package should timeout");
3902 assert_eq!(result.failed_count, 1, "Should track 1 failed package");
3903 assert_eq!(
3906 result.fetch_failed,
3907 HashMap::from([(PackageName::new("slow-package"), FetchFailure::Transient)]),
3908 "timed-out package must be recorded in fetch_failed"
3909 );
3910 }
3911
3912 #[tokio::test]
3913 async fn test_fetch_latest_versions_parallel_fast_packages_not_blocked() {
3914 use deps_core::{Metadata, Registry, Version};
3915 use std::any::Any;
3916 use std::time::Duration;
3917
3918 struct MixedRegistry;
3920
3921 impl Registry for MixedRegistry {
3922 fn get_versions<'a>(
3923 &'a self,
3924 name: &'a deps_core::PackageName,
3925 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
3926 {
3927 Box::pin(async move {
3928 if name == "slow-package" {
3929 tokio::time::sleep(Duration::from_secs(10)).await;
3931 }
3932 Ok(vec![])
3934 })
3935 }
3936
3937 fn get_latest_matching<'a>(
3938 &'a self,
3939 name: &'a deps_core::PackageName,
3940 _req: &'a deps_core::VersionReq,
3941 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
3942 {
3943 Box::pin(async move {
3944 if name == "slow-package" {
3945 tokio::time::sleep(Duration::from_secs(10)).await;
3947 }
3948 Ok(None)
3950 })
3951 }
3952
3953 fn search<'a>(
3954 &'a self,
3955 _query: &'a str,
3956 _limit: usize,
3957 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
3958 {
3959 Box::pin(async move { Ok(vec![]) })
3960 }
3961
3962 fn as_any(&self) -> &dyn Any {
3963 self
3964 }
3965 }
3966
3967 let registry: Arc<dyn Registry> = Arc::new(MixedRegistry);
3968 let packages = vec![
3969 PackageName::new("slow-package"),
3970 PackageName::new("fast-package"),
3971 ];
3972
3973 let start = std::time::Instant::now();
3974 let result = fetch_latest_versions_parallel(
3975 registry,
3976 with_registry_source(packages),
3977 &HashMap::new(),
3978 None,
3979 deps_core::freshness::FreshnessSettings::default(),
3980 1,
3981 10,
3982 None,
3983 )
3984 .await;
3985 let elapsed = start.elapsed();
3986
3987 assert!(
3989 elapsed < Duration::from_secs(3),
3990 "Should not wait for slow package: {:?}",
3991 elapsed
3992 );
3993
3994 assert!(
3996 result.versions.is_empty(),
3997 "No versions returned (test registry returns empty)"
3998 );
3999 assert_eq!(
4000 result.failed_count, 1,
4001 "Slow package should be marked as failed"
4002 );
4003 }
4004
4005 #[tokio::test]
4006 async fn test_fetch_latest_versions_parallel_concurrency_limit() {
4007 use deps_core::{Metadata, Registry, Version};
4008 use std::any::Any;
4009 use std::sync::atomic::{AtomicUsize, Ordering};
4010 use std::time::Duration;
4011
4012 struct ConcurrencyTrackingRegistry {
4014 current: Arc<AtomicUsize>,
4015 max_seen: Arc<AtomicUsize>,
4016 }
4017
4018 impl Registry for ConcurrencyTrackingRegistry {
4019 fn get_versions<'a>(
4020 &'a self,
4021 _name: &'a deps_core::PackageName,
4022 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4023 {
4024 Box::pin(async move {
4025 let current = self.current.fetch_add(1, Ordering::SeqCst) + 1;
4027
4028 self.max_seen.fetch_max(current, Ordering::SeqCst);
4030
4031 tokio::time::sleep(Duration::from_millis(50)).await;
4033
4034 self.current.fetch_sub(1, Ordering::SeqCst);
4036
4037 Ok(vec![])
4038 })
4039 }
4040
4041 fn get_latest_matching<'a>(
4042 &'a self,
4043 _name: &'a deps_core::PackageName,
4044 _req: &'a deps_core::VersionReq,
4045 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4046 {
4047 Box::pin(async move {
4048 let current = self.current.fetch_add(1, Ordering::SeqCst) + 1;
4050
4051 self.max_seen.fetch_max(current, Ordering::SeqCst);
4053
4054 tokio::time::sleep(Duration::from_millis(50)).await;
4056
4057 self.current.fetch_sub(1, Ordering::SeqCst);
4059
4060 Ok(None)
4061 })
4062 }
4063
4064 fn search<'a>(
4065 &'a self,
4066 _query: &'a str,
4067 _limit: usize,
4068 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4069 {
4070 Box::pin(async move { Ok(vec![]) })
4071 }
4072
4073 fn as_any(&self) -> &dyn Any {
4074 self
4075 }
4076 }
4077
4078 let current = Arc::new(AtomicUsize::new(0));
4079 let max_seen = Arc::new(AtomicUsize::new(0));
4080
4081 let registry: Arc<dyn Registry> = Arc::new(ConcurrencyTrackingRegistry {
4082 current: Arc::clone(¤t),
4083 max_seen: Arc::clone(&max_seen),
4084 });
4085
4086 let packages: Vec<PackageName> = (0..50)
4088 .map(|i| PackageName::new(format!("package-{}", i)))
4089 .collect();
4090
4091 fetch_latest_versions_parallel(
4092 registry,
4093 with_registry_source(packages),
4094 &HashMap::new(),
4095 None,
4096 deps_core::freshness::FreshnessSettings::default(),
4097 5,
4098 20,
4099 None,
4100 )
4101 .await;
4102
4103 let max = max_seen.load(Ordering::SeqCst);
4105 assert!(
4106 max <= 22,
4107 "Concurrency limit violated: {} concurrent requests (limit: 20)",
4108 max
4109 );
4110 }
4111
4112 #[tokio::test]
4113 async fn test_fetch_partial_success_with_mixed_outcomes() {
4114 use deps_core::{Metadata, Registry, Version};
4115 use std::any::Any;
4116 use std::time::Duration;
4117
4118 #[derive(Debug)]
4120 struct MockVersion {
4121 version: ConcreteVersion,
4122 }
4123
4124 impl Version for MockVersion {
4125 fn version_string(&self) -> &ConcreteVersion {
4126 &self.version
4127 }
4128
4129 fn is_prerelease(&self) -> bool {
4130 false
4131 }
4132
4133 fn as_any(&self) -> &dyn Any {
4134 self
4135 }
4136 }
4137
4138 struct MixedOutcomeRegistry;
4143
4144 impl Registry for MixedOutcomeRegistry {
4145 fn get_versions<'a>(
4146 &'a self,
4147 name: &'a deps_core::PackageName,
4148 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4149 {
4150 Box::pin(async move {
4151 match name.as_str() {
4152 "package-fast" => {
4153 Ok(vec![Box::new(MockVersion {
4155 version: "1.0.0".into(),
4156 }) as Box<dyn Version>])
4157 }
4158 "package-slow" => {
4159 tokio::time::sleep(Duration::from_secs(10)).await;
4161 Ok(vec![])
4162 }
4163 "package-error" => {
4164 Err(deps_core::error::DepsError::CacheError(
4166 "Mock registry error".to_string(),
4167 ))
4168 }
4169 _ => Ok(vec![]),
4170 }
4171 })
4172 }
4173
4174 fn get_latest_matching<'a>(
4175 &'a self,
4176 name: &'a deps_core::PackageName,
4177 _req: &'a deps_core::VersionReq,
4178 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4179 {
4180 Box::pin(async move {
4181 match name.as_str() {
4182 "package-fast" => Ok(Some(Box::new(MockVersion {
4183 version: "1.0.0".into(),
4184 }) as Box<dyn Version>)),
4185 "package-slow" => {
4186 tokio::time::sleep(Duration::from_secs(10)).await;
4187 Ok(None)
4188 }
4189 "package-error" => Err(deps_core::error::DepsError::CacheError(
4190 "Mock registry error".to_string(),
4191 )),
4192 _ => Ok(None),
4193 }
4194 })
4195 }
4196
4197 fn search<'a>(
4198 &'a self,
4199 _query: &'a str,
4200 _limit: usize,
4201 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4202 {
4203 Box::pin(async move { Ok(vec![]) })
4204 }
4205
4206 fn select_latest_matching(
4207 &self,
4208 versions: &[Box<dyn Version>],
4209 _req: &deps_core::VersionReq,
4210 ) -> Option<usize> {
4211 if versions.is_empty() { None } else { Some(0) }
4216 }
4217
4218 fn as_any(&self) -> &dyn Any {
4219 self
4220 }
4221 }
4222
4223 let registry: Arc<dyn Registry> = Arc::new(MixedOutcomeRegistry);
4224 let packages = vec![
4225 PackageName::new("package-fast"),
4226 PackageName::new("package-slow"),
4227 PackageName::new("package-error"),
4228 ];
4229
4230 let result = fetch_latest_versions_parallel(
4232 registry,
4233 with_registry_source(packages),
4234 &HashMap::new(),
4235 None,
4236 deps_core::freshness::FreshnessSettings::default(),
4237 1,
4238 10,
4239 None,
4240 )
4241 .await;
4242
4243 assert_eq!(
4245 result.versions.len(),
4246 1,
4247 "Should have exactly 1 successful package"
4248 );
4249 assert_eq!(
4250 result
4251 .versions
4252 .get("package-fast")
4253 .map(|v| v.latest.as_str()),
4254 Some("1.0.0"),
4255 "Fast package should have correct version"
4256 );
4257 assert!(
4258 !result.versions.contains_key("package-slow"),
4259 "Slow package should not be in results (timeout)"
4260 );
4261 assert!(
4262 !result.versions.contains_key("package-error"),
4263 "Error package should not be in results"
4264 );
4265 }
4266
4267 #[tokio::test]
4272 async fn test_fetch_latest_versions_parallel_carries_yanked_flag_into_cache() {
4273 use deps_core::{Metadata, Registry, Version};
4274 use std::any::Any;
4275
4276 #[derive(Debug)]
4277 struct MockVersion {
4278 version: ConcreteVersion,
4279 yanked: bool,
4280 }
4281
4282 impl Version for MockVersion {
4283 fn version_string(&self) -> &ConcreteVersion {
4284 &self.version
4285 }
4286 fn removal_status(&self) -> deps_core::RemovalStatus {
4287 deps_core::RemovalStatus::from_yanked(self.yanked)
4288 }
4289 fn as_any(&self) -> &dyn Any {
4290 self
4291 }
4292 }
4293
4294 struct YankedRegistry;
4295
4296 impl Registry for YankedRegistry {
4297 fn get_versions<'a>(
4298 &'a self,
4299 _name: &'a deps_core::PackageName,
4300 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4301 {
4302 Box::pin(async move {
4303 Ok(vec![
4304 Box::new(MockVersion {
4305 version: "1.0.214".into(),
4306 yanked: false,
4307 }) as Box<dyn Version>,
4308 Box::new(MockVersion {
4309 version: "1.0.213".into(),
4310 yanked: true,
4311 }) as Box<dyn Version>,
4312 ])
4313 })
4314 }
4315
4316 fn get_latest_matching<'a>(
4317 &'a self,
4318 _name: &'a deps_core::PackageName,
4319 _req: &'a deps_core::VersionReq,
4320 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4321 {
4322 Box::pin(async move { Ok(None) })
4323 }
4324
4325 fn search<'a>(
4326 &'a self,
4327 _query: &'a str,
4328 _limit: usize,
4329 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4330 {
4331 Box::pin(async move { Ok(vec![]) })
4332 }
4333
4334 fn select_latest_matching(
4335 &self,
4336 versions: &[Box<dyn Version>],
4337 _req: &deps_core::VersionReq,
4338 ) -> Option<usize> {
4339 versions
4340 .iter()
4341 .position(|v| !v.removal_status().blocks_resolution())
4342 }
4343
4344 fn as_any(&self) -> &dyn Any {
4345 self
4346 }
4347 }
4348
4349 let registry: Arc<dyn Registry> = Arc::new(YankedRegistry);
4350 let packages = vec![PackageName::new("serde")];
4351
4352 let result = fetch_latest_versions_parallel(
4353 registry,
4354 with_registry_source(packages),
4355 &HashMap::new(),
4356 None,
4357 deps_core::freshness::FreshnessSettings::default(),
4358 10,
4359 10,
4360 None,
4361 )
4362 .await;
4363
4364 let serde = result
4365 .versions
4366 .get("serde")
4367 .expect("serde should be fetched");
4368 assert_eq!(serde.latest, "1.0.214", "latest must skip the yanked entry");
4369 assert_eq!(
4370 &*serde.available,
4371 &[
4372 ConcreteVersion::new("1.0.214"),
4373 ConcreteVersion::new("1.0.213")
4374 ],
4375 "available must remain unfiltered"
4376 );
4377 assert_eq!(
4378 &*serde.yanked,
4379 &[(
4380 ConcreteVersion::new("1.0.213"),
4381 deps_core::RemovalStatus::Yanked
4382 )],
4383 "yanked must carry only the entries reported as yanked, paired with their status"
4384 );
4385 }
4386
4387 #[tokio::test]
4394 async fn test_fetch_latest_versions_parallel_carries_published_at_for_latest_only() {
4395 use deps_core::freshness::PublishTime;
4396 use deps_core::{Metadata, Registry, Version};
4397 use std::any::Any;
4398
4399 #[derive(Debug)]
4400 struct MockVersion {
4401 version: ConcreteVersion,
4402 yanked: bool,
4403 published_at: Option<PublishTime>,
4404 }
4405
4406 impl Version for MockVersion {
4407 fn version_string(&self) -> &ConcreteVersion {
4408 &self.version
4409 }
4410 fn removal_status(&self) -> deps_core::RemovalStatus {
4411 deps_core::RemovalStatus::from_yanked(self.yanked)
4412 }
4413 fn published_at(&self) -> Option<PublishTime> {
4414 self.published_at
4415 }
4416 fn as_any(&self) -> &dyn Any {
4417 self
4418 }
4419 }
4420
4421 struct DatedRegistry;
4422
4423 impl Registry for DatedRegistry {
4424 fn get_versions<'a>(
4425 &'a self,
4426 _name: &'a deps_core::PackageName,
4427 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4428 {
4429 Box::pin(async move {
4430 Ok(vec![
4431 Box::new(MockVersion {
4432 version: "1.0.214".into(),
4433 yanked: false,
4434 published_at: Some(PublishTime::from_unix_secs(2_000)),
4435 }) as Box<dyn Version>,
4436 Box::new(MockVersion {
4437 version: "1.0.213".into(),
4438 yanked: true,
4439 published_at: Some(PublishTime::from_unix_secs(1_000)),
4442 }) as Box<dyn Version>,
4443 ])
4444 })
4445 }
4446
4447 fn get_latest_matching<'a>(
4448 &'a self,
4449 _name: &'a deps_core::PackageName,
4450 _req: &'a deps_core::VersionReq,
4451 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4452 {
4453 Box::pin(async move { Ok(None) })
4454 }
4455
4456 fn search<'a>(
4457 &'a self,
4458 _query: &'a str,
4459 _limit: usize,
4460 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4461 {
4462 Box::pin(async move { Ok(vec![]) })
4463 }
4464
4465 fn select_latest_matching(
4466 &self,
4467 versions: &[Box<dyn Version>],
4468 _req: &deps_core::VersionReq,
4469 ) -> Option<usize> {
4470 versions
4471 .iter()
4472 .position(|v| !v.removal_status().blocks_resolution())
4473 }
4474
4475 fn as_any(&self) -> &dyn Any {
4476 self
4477 }
4478 }
4479
4480 let registry: Arc<dyn Registry> = Arc::new(DatedRegistry);
4481 let packages = vec![PackageName::new("serde")];
4482
4483 let result = fetch_latest_versions_parallel(
4484 registry,
4485 with_registry_source(packages),
4486 &HashMap::new(),
4487 None,
4488 deps_core::freshness::FreshnessSettings::default(),
4489 10,
4490 10,
4491 None,
4492 )
4493 .await;
4494
4495 let serde = result
4496 .versions
4497 .get("serde")
4498 .expect("serde should be fetched");
4499 assert_eq!(serde.latest, "1.0.214");
4500 assert_eq!(
4501 serde.published_at,
4502 Some(PublishTime::from_unix_secs(2_000)),
4503 "published_at must be 1.0.214's own timestamp, not the yanked 1.0.213 entry's"
4504 );
4505 }
4506
4507 #[tokio::test]
4513 async fn test_fetch_latest_versions_parallel_uses_get_versions_with_for_freshness() {
4514 use deps_core::freshness::{FreshnessSettings, PublishTime};
4515 use deps_core::{Metadata, Registry, Version};
4516 use std::any::Any;
4517
4518 #[derive(Debug)]
4519 struct MockVersion {
4520 version: ConcreteVersion,
4521 published_at: Option<PublishTime>,
4522 }
4523
4524 impl Version for MockVersion {
4525 fn version_string(&self) -> &ConcreteVersion {
4526 &self.version
4527 }
4528 fn published_at(&self) -> Option<PublishTime> {
4529 self.published_at
4530 }
4531 fn as_any(&self) -> &dyn Any {
4532 self
4533 }
4534 }
4535
4536 struct FreshnessAwareRegistry;
4537
4538 impl Registry for FreshnessAwareRegistry {
4539 fn get_versions<'a>(
4540 &'a self,
4541 _name: &'a deps_core::PackageName,
4542 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4543 {
4544 Box::pin(async move {
4547 Ok(vec![Box::new(MockVersion {
4548 version: "1.0.0".into(),
4549 published_at: None,
4550 }) as Box<dyn Version>])
4551 })
4552 }
4553
4554 fn get_versions_with<'a>(
4555 &'a self,
4556 _name: &'a deps_core::PackageName,
4557 freshness: FreshnessSettings,
4558 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4559 {
4560 Box::pin(async move {
4561 Ok(vec![Box::new(MockVersion {
4562 version: "1.0.0".into(),
4563 published_at: freshness
4564 .enabled
4565 .then(|| PublishTime::from_unix_secs(5_000)),
4566 }) as Box<dyn Version>])
4567 })
4568 }
4569
4570 fn get_latest_matching<'a>(
4571 &'a self,
4572 _name: &'a deps_core::PackageName,
4573 _req: &'a deps_core::VersionReq,
4574 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4575 {
4576 Box::pin(async move { Ok(None) })
4577 }
4578
4579 fn search<'a>(
4580 &'a self,
4581 _query: &'a str,
4582 _limit: usize,
4583 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4584 {
4585 Box::pin(async move { Ok(vec![]) })
4586 }
4587
4588 fn select_latest_matching(
4589 &self,
4590 versions: &[Box<dyn Version>],
4591 _req: &deps_core::VersionReq,
4592 ) -> Option<usize> {
4593 if versions.is_empty() { None } else { Some(0) }
4594 }
4595
4596 fn as_any(&self) -> &dyn Any {
4597 self
4598 }
4599 }
4600
4601 let registry: Arc<dyn Registry> = Arc::new(FreshnessAwareRegistry);
4602 let packages = vec![PackageName::new("widget")];
4603
4604 let result = fetch_latest_versions_parallel(
4605 registry,
4606 with_registry_source(packages),
4607 &HashMap::new(),
4608 None,
4609 FreshnessSettings::default(),
4610 10,
4611 10,
4612 None,
4613 )
4614 .await;
4615
4616 let widget = result
4617 .versions
4618 .get("widget")
4619 .expect("widget should be fetched");
4620 assert_eq!(
4621 widget.published_at,
4622 Some(PublishTime::from_unix_secs(5_000)),
4623 "published_at must come from get_versions_with, not the freshness-blind \
4624 get_versions (#339)"
4625 );
4626 }
4627
4628 #[tokio::test]
4634 async fn test_fetch_latest_versions_parallel_threads_minimum_stability_into_select_latest_matching_with_context()
4635 {
4636 use deps_core::{Metadata, Registry, Version};
4637 use std::any::Any;
4638 use std::sync::Mutex;
4639
4640 #[derive(Debug)]
4641 struct MockVersion {
4642 version: ConcreteVersion,
4643 }
4644
4645 impl Version for MockVersion {
4646 fn version_string(&self) -> &ConcreteVersion {
4647 &self.version
4648 }
4649 fn as_any(&self) -> &dyn Any {
4650 self
4651 }
4652 }
4653
4654 struct ContextAwareRegistry {
4655 seen_minimum_stability: Mutex<Vec<Option<String>>>,
4658 }
4659
4660 impl Registry for ContextAwareRegistry {
4661 fn get_versions<'a>(
4662 &'a self,
4663 _name: &'a deps_core::PackageName,
4664 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4665 {
4666 Box::pin(async move {
4667 Ok(vec![Box::new(MockVersion {
4668 version: "1.0.0".into(),
4669 }) as Box<dyn Version>])
4670 })
4671 }
4672
4673 fn get_latest_matching<'a>(
4674 &'a self,
4675 _name: &'a deps_core::PackageName,
4676 _req: &'a deps_core::VersionReq,
4677 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4678 {
4679 Box::pin(async move { Ok(None) })
4680 }
4681
4682 fn search<'a>(
4683 &'a self,
4684 _query: &'a str,
4685 _limit: usize,
4686 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4687 {
4688 Box::pin(async move { Ok(vec![]) })
4689 }
4690
4691 fn select_latest_matching_with_context(
4696 &self,
4697 versions: &[Box<dyn Version>],
4698 _req: &deps_core::VersionReq,
4699 minimum_stability: Option<&str>,
4700 ) -> Option<usize> {
4701 self.seen_minimum_stability
4702 .lock()
4703 .unwrap_or_else(|p| p.into_inner())
4704 .push(minimum_stability.map(str::to_string));
4705 if versions.is_empty() { None } else { Some(0) }
4706 }
4707
4708 fn as_any(&self) -> &dyn Any {
4709 self
4710 }
4711 }
4712
4713 let registry = Arc::new(ContextAwareRegistry {
4714 seen_minimum_stability: Mutex::new(Vec::new()),
4715 });
4716 let packages = vec![PackageName::new("vendor/pkg")];
4717
4718 let result = fetch_latest_versions_parallel(
4719 Arc::clone(®istry) as Arc<dyn Registry>,
4720 with_registry_source(packages),
4721 &HashMap::new(),
4722 None,
4723 deps_core::freshness::FreshnessSettings::default(),
4724 10,
4725 10,
4726 Some("beta"),
4727 )
4728 .await;
4729
4730 assert_eq!(
4731 *registry
4732 .seen_minimum_stability
4733 .lock()
4734 .unwrap_or_else(|p| p.into_inner()),
4735 vec![Some("beta".to_string())],
4736 "select_latest_matching_with_context must receive the caller's minimum_stability"
4737 );
4738 assert!(
4739 result.versions.contains_key("vendor/pkg"),
4740 "the pick must still succeed via the _with_context path"
4741 );
4742 }
4743
4744 #[tokio::test]
4748 async fn test_fetch_latest_versions_parallel_threads_minimum_stability_into_get_latest_matching_with_context()
4749 {
4750 use deps_core::{Metadata, Registry, Version};
4751 use std::any::Any;
4752 use std::sync::Mutex;
4753
4754 #[derive(Debug)]
4755 struct MockVersion {
4756 version: ConcreteVersion,
4757 }
4758
4759 impl Version for MockVersion {
4760 fn version_string(&self) -> &ConcreteVersion {
4761 &self.version
4762 }
4763 fn as_any(&self) -> &dyn Any {
4764 self
4765 }
4766 }
4767
4768 struct FallbackContextAwareRegistry {
4769 seen_minimum_stability: Mutex<Vec<Option<String>>>,
4772 }
4773
4774 impl Registry for FallbackContextAwareRegistry {
4775 fn get_versions<'a>(
4776 &'a self,
4777 _name: &'a deps_core::PackageName,
4778 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4779 {
4780 Box::pin(async move { Ok(vec![]) })
4783 }
4784
4785 fn get_latest_matching<'a>(
4786 &'a self,
4787 _name: &'a deps_core::PackageName,
4788 _req: &'a deps_core::VersionReq,
4789 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4790 {
4791 Box::pin(async move { Ok(None) })
4792 }
4793
4794 fn get_latest_matching_with_context<'a>(
4795 &'a self,
4796 _name: &'a deps_core::PackageName,
4797 _req: &'a deps_core::VersionReq,
4798 minimum_stability: Option<&'a str>,
4799 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4800 {
4801 self.seen_minimum_stability
4802 .lock()
4803 .unwrap_or_else(|p| p.into_inner())
4804 .push(minimum_stability.map(str::to_string));
4805 Box::pin(async move {
4806 Ok(Some(Box::new(MockVersion {
4807 version: "2.0.0-beta1".into(),
4808 }) as Box<dyn Version>))
4809 })
4810 }
4811
4812 fn search<'a>(
4813 &'a self,
4814 _query: &'a str,
4815 _limit: usize,
4816 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4817 {
4818 Box::pin(async move { Ok(vec![]) })
4819 }
4820
4821 fn as_any(&self) -> &dyn Any {
4822 self
4823 }
4824 }
4825
4826 let registry = Arc::new(FallbackContextAwareRegistry {
4827 seen_minimum_stability: Mutex::new(Vec::new()),
4828 });
4829 let packages = vec![PackageName::new("vendor/pkg")];
4830
4831 let result = fetch_latest_versions_parallel(
4832 Arc::clone(®istry) as Arc<dyn Registry>,
4833 with_registry_source(packages),
4834 &HashMap::new(),
4835 None,
4836 deps_core::freshness::FreshnessSettings::default(),
4837 10,
4838 10,
4839 Some("beta"),
4840 )
4841 .await;
4842
4843 assert_eq!(
4844 *registry
4845 .seen_minimum_stability
4846 .lock()
4847 .unwrap_or_else(|p| p.into_inner()),
4848 vec![Some("beta".to_string())],
4849 "get_latest_matching_with_context must receive the caller's minimum_stability"
4850 );
4851 let widget = result
4852 .versions
4853 .get("vendor/pkg")
4854 .expect("fallback pick should succeed");
4855 assert_eq!(widget.latest, "2.0.0-beta1");
4856 }
4857
4858 #[tokio::test]
4864 async fn test_fetch_falls_back_to_get_latest_matching_when_list_based_pick_finds_nothing() {
4865 use deps_core::{Metadata, Registry, Version};
4866 use std::any::Any;
4867
4868 #[derive(Debug)]
4869 struct MockVersion {
4870 version: ConcreteVersion,
4871 }
4872
4873 impl Version for MockVersion {
4874 fn version_string(&self) -> &ConcreteVersion {
4875 &self.version
4876 }
4877 fn as_any(&self) -> &dyn Any {
4878 self
4879 }
4880 }
4881
4882 struct UntaggedModuleRegistry;
4888
4889 impl Registry for UntaggedModuleRegistry {
4890 fn get_versions<'a>(
4891 &'a self,
4892 _name: &'a deps_core::PackageName,
4893 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4894 {
4895 Box::pin(async move { Ok(vec![]) })
4896 }
4897
4898 fn get_latest_matching<'a>(
4899 &'a self,
4900 _name: &'a deps_core::PackageName,
4901 _req: &'a deps_core::VersionReq,
4902 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4903 {
4904 Box::pin(async move {
4905 Ok(Some(Box::new(MockVersion {
4906 version: "v0.0.0-20191109021931-daa7c04131f5".into(),
4907 }) as Box<dyn Version>))
4908 })
4909 }
4910
4911 fn search<'a>(
4912 &'a self,
4913 _query: &'a str,
4914 _limit: usize,
4915 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4916 {
4917 Box::pin(async move { Ok(vec![]) })
4918 }
4919
4920 fn as_any(&self) -> &dyn Any {
4921 self
4922 }
4923 }
4924
4925 let registry: Arc<dyn Registry> = Arc::new(UntaggedModuleRegistry);
4926 let packages = vec![PackageName::new("golang.org/x/exp")];
4927
4928 let result = fetch_latest_versions_parallel(
4929 registry,
4930 with_registry_source(packages),
4931 &HashMap::new(),
4932 None,
4933 deps_core::freshness::FreshnessSettings::default(),
4934 5,
4935 10,
4936 None,
4937 )
4938 .await;
4939
4940 assert_eq!(
4941 result
4942 .versions
4943 .get("golang.org/x/exp")
4944 .map(|v| v.latest.as_str()),
4945 Some("v0.0.0-20191109021931-daa7c04131f5"),
4946 "must fall back to get_latest_matching instead of reporting no version found"
4947 );
4948 }
4949
4950 #[tokio::test]
4951 async fn test_fetch_registry_error_handled() {
4952 use deps_core::{Metadata, Registry, Version};
4953 use std::any::Any;
4954
4955 struct ErrorRegistry;
4957
4958 impl Registry for ErrorRegistry {
4959 fn get_versions<'a>(
4960 &'a self,
4961 name: &'a deps_core::PackageName,
4962 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
4963 {
4964 Box::pin(async move {
4965 Err(deps_core::error::DepsError::CacheError(format!(
4966 "Failed to fetch package: {}",
4967 name
4968 )))
4969 })
4970 }
4971
4972 fn get_latest_matching<'a>(
4973 &'a self,
4974 name: &'a deps_core::PackageName,
4975 _req: &'a deps_core::VersionReq,
4976 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
4977 {
4978 Box::pin(async move {
4979 Err(deps_core::error::DepsError::CacheError(format!(
4980 "Failed to fetch package: {}",
4981 name
4982 )))
4983 })
4984 }
4985
4986 fn search<'a>(
4987 &'a self,
4988 _query: &'a str,
4989 _limit: usize,
4990 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
4991 {
4992 Box::pin(async move { Ok(vec![]) })
4993 }
4994
4995 fn as_any(&self) -> &dyn Any {
4996 self
4997 }
4998 }
4999
5000 let registry: Arc<dyn Registry> = Arc::new(ErrorRegistry);
5001 let packages = vec![
5002 PackageName::new("package-1"),
5003 PackageName::new("package-2"),
5004 PackageName::new("package-3"),
5005 ];
5006
5007 let result = fetch_latest_versions_parallel(
5009 registry,
5010 with_registry_source(packages),
5011 &HashMap::new(),
5012 None,
5013 deps_core::freshness::FreshnessSettings::default(),
5014 5,
5015 10,
5016 None,
5017 )
5018 .await;
5019
5020 assert!(
5022 result.versions.is_empty(),
5023 "All packages with errors should be omitted from results"
5024 );
5025 assert_eq!(
5026 result.failed_count, 3,
5027 "All 3 packages should be marked as failed"
5028 );
5029 assert_eq!(
5033 result.fetch_failed,
5034 HashMap::from([
5035 (PackageName::new("package-1"), FetchFailure::Transient),
5036 (PackageName::new("package-2"), FetchFailure::Transient),
5037 (PackageName::new("package-3"), FetchFailure::Transient),
5038 ]),
5039 "every errored package must be recorded in fetch_failed"
5040 );
5041 }
5042
5043 #[tokio::test]
5044 async fn test_fetch_not_found_is_not_recorded_as_fetch_failed() {
5045 use deps_core::{Metadata, Registry, Version};
5053 use std::any::Any;
5054
5055 struct NotFoundRegistry;
5056
5057 impl Registry for NotFoundRegistry {
5058 fn get_versions<'a>(
5059 &'a self,
5060 name: &'a deps_core::PackageName,
5061 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5062 {
5063 Box::pin(async move {
5064 Err(deps_core::error::DepsError::PackageNotFound {
5065 package: name.to_string(),
5066 registry: "mock",
5067 })
5068 })
5069 }
5070
5071 fn get_latest_matching<'a>(
5072 &'a self,
5073 name: &'a deps_core::PackageName,
5074 _req: &'a deps_core::VersionReq,
5075 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5076 {
5077 Box::pin(async move {
5078 Err(deps_core::error::DepsError::PackageNotFound {
5079 package: name.to_string(),
5080 registry: "mock",
5081 })
5082 })
5083 }
5084
5085 fn search<'a>(
5086 &'a self,
5087 _query: &'a str,
5088 _limit: usize,
5089 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
5090 {
5091 Box::pin(async move { Ok(vec![]) })
5092 }
5093
5094 fn as_any(&self) -> &dyn Any {
5095 self
5096 }
5097 }
5098
5099 let registry: Arc<dyn Registry> = Arc::new(NotFoundRegistry);
5100 let packages = vec![PackageName::new("typo-pkg")];
5101
5102 let result = fetch_latest_versions_parallel(
5103 registry,
5104 with_registry_source(packages),
5105 &HashMap::new(),
5106 None,
5107 deps_core::freshness::FreshnessSettings::default(),
5108 5,
5109 10,
5110 None,
5111 )
5112 .await;
5113
5114 assert!(result.versions.is_empty());
5115 assert!(
5116 result.fetch_failed.is_empty(),
5117 "a genuine not-found must not be recorded in fetch_failed, or \
5118 generate_diagnostics_from_cache would report it as a registry \
5119 error instead of Unknown package"
5120 );
5121 }
5122
5123 #[tokio::test]
5131 async fn test_fetch_success_with_zero_versions_is_recorded_as_no_comparable_versions() {
5132 use deps_core::{Metadata, Registry, Version};
5133 use std::any::Any;
5134
5135 struct EmptyButRealRegistry;
5136
5137 impl Registry for EmptyButRealRegistry {
5138 fn get_versions<'a>(
5139 &'a self,
5140 _name: &'a deps_core::PackageName,
5141 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5142 {
5143 Box::pin(async move { Ok(vec![]) })
5144 }
5145
5146 fn get_latest_matching<'a>(
5147 &'a self,
5148 _name: &'a deps_core::PackageName,
5149 _req: &'a deps_core::VersionReq,
5150 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5151 {
5152 Box::pin(async move { Ok(None) })
5153 }
5154
5155 fn search<'a>(
5156 &'a self,
5157 _query: &'a str,
5158 _limit: usize,
5159 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
5160 {
5161 Box::pin(async move { Ok(vec![]) })
5162 }
5163
5164 fn as_any(&self) -> &dyn Any {
5165 self
5166 }
5167 }
5168
5169 let registry: Arc<dyn Registry> = Arc::new(EmptyButRealRegistry);
5170 let packages = vec![PackageName::new("dtolnay/rust-toolchain")];
5171
5172 let result = fetch_latest_versions_parallel(
5173 registry,
5174 with_registry_source(packages),
5175 &HashMap::new(),
5176 None,
5177 deps_core::freshness::FreshnessSettings::default(),
5178 5,
5179 10,
5180 None,
5181 )
5182 .await;
5183
5184 assert!(result.versions.is_empty());
5185 assert!(
5186 result.fetch_failed.is_empty(),
5187 "a genuine empty-but-successful fetch must not be recorded as a fetch \
5188 failure, or generate_diagnostics_from_cache would report a registry \
5189 error instead of nothing"
5190 );
5191 assert!(
5192 result
5193 .no_comparable_versions
5194 .contains(&PackageName::new("dtolnay/rust-toolchain")),
5195 "a package whose fetch succeeded with zero comparable versions must be \
5196 recorded in no_comparable_versions, or R5 would misreport it as Unknown \
5197 package; got: {:?}",
5198 result.no_comparable_versions
5199 );
5200 }
5201
5202 #[tokio::test]
5203 async fn test_fetch_http_404_is_not_recorded_as_fetch_failed() {
5204 use deps_core::{Metadata, Registry, Version};
5209 use std::any::Any;
5210
5211 struct Http404Registry;
5212
5213 impl Registry for Http404Registry {
5214 fn get_versions<'a>(
5215 &'a self,
5216 name: &'a deps_core::PackageName,
5217 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5218 {
5219 Box::pin(async move {
5220 Err(deps_core::error::DepsError::HttpStatus {
5221 url: format!("https://example.com/{name}"),
5222 status: 404,
5223 })
5224 })
5225 }
5226
5227 fn get_latest_matching<'a>(
5228 &'a self,
5229 name: &'a deps_core::PackageName,
5230 _req: &'a deps_core::VersionReq,
5231 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5232 {
5233 Box::pin(async move {
5234 Err(deps_core::error::DepsError::HttpStatus {
5235 url: format!("https://example.com/{name}"),
5236 status: 404,
5237 })
5238 })
5239 }
5240
5241 fn search<'a>(
5242 &'a self,
5243 _query: &'a str,
5244 _limit: usize,
5245 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
5246 {
5247 Box::pin(async move { Ok(vec![]) })
5248 }
5249
5250 fn as_any(&self) -> &dyn Any {
5251 self
5252 }
5253 }
5254
5255 let registry: Arc<dyn Registry> = Arc::new(Http404Registry);
5256 let packages = vec![PackageName::new("typo-pkg")];
5257
5258 let result = fetch_latest_versions_parallel(
5259 registry,
5260 with_registry_source(packages),
5261 &HashMap::new(),
5262 None,
5263 deps_core::freshness::FreshnessSettings::default(),
5264 5,
5265 10,
5266 None,
5267 )
5268 .await;
5269
5270 assert!(result.versions.is_empty());
5271 assert!(
5272 result.fetch_failed.is_empty(),
5273 "a bare HTTP 404 must not be recorded in fetch_failed either"
5274 );
5275 }
5276
5277 #[tokio::test]
5278 async fn test_fetch_fallback_error_recorded_as_fetch_failed_unless_not_found() {
5279 use deps_core::{Metadata, Registry, Version};
5286 use std::any::Any;
5287
5288 struct FallbackErrorRegistry;
5289
5290 impl Registry for FallbackErrorRegistry {
5291 fn get_versions<'a>(
5292 &'a self,
5293 _name: &'a deps_core::PackageName,
5294 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5295 {
5296 Box::pin(async move { Ok(vec![]) })
5297 }
5298
5299 fn get_latest_matching<'a>(
5300 &'a self,
5301 name: &'a deps_core::PackageName,
5302 _req: &'a deps_core::VersionReq,
5303 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5304 {
5305 let name = name.clone();
5306 Box::pin(async move {
5307 if name.as_str() == "not-found" {
5308 Err(deps_core::error::DepsError::PackageNotFound {
5309 package: name.to_string(),
5310 registry: "mock",
5311 })
5312 } else {
5313 Err(deps_core::error::DepsError::CacheError(
5314 "mock fallback failure".to_string(),
5315 ))
5316 }
5317 })
5318 }
5319
5320 fn search<'a>(
5321 &'a self,
5322 _query: &'a str,
5323 _limit: usize,
5324 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
5325 {
5326 Box::pin(async move { Ok(vec![]) })
5327 }
5328
5329 fn as_any(&self) -> &dyn Any {
5330 self
5331 }
5332 }
5333
5334 let registry: Arc<dyn Registry> = Arc::new(FallbackErrorRegistry);
5335 let packages = vec![PackageName::new("flaky"), PackageName::new("not-found")];
5336
5337 let result = fetch_latest_versions_parallel(
5338 registry,
5339 with_registry_source(packages),
5340 &HashMap::new(),
5341 None,
5342 deps_core::freshness::FreshnessSettings::default(),
5343 5,
5344 10,
5345 None,
5346 )
5347 .await;
5348
5349 assert!(result.versions.is_empty());
5350 assert_eq!(
5351 result.fetch_failed,
5352 HashMap::from([(PackageName::new("flaky"), FetchFailure::Transient)]),
5353 "the fallback's own non-not-found error must be recorded in fetch_failed, \
5354 but its not-found error must not"
5355 );
5356 assert_eq!(
5357 result.failed_count, 2,
5358 "both fallback failures count toward failed_count regardless of cause (S2)"
5359 );
5360 }
5361
5362 #[tokio::test]
5371 async fn test_fetch_failure_toast_wording_for_mixed_not_found_and_genuine_failure_batch() {
5372 use deps_core::{Metadata, Registry, Version};
5373 use std::any::Any;
5374
5375 struct FallbackErrorRegistry;
5376
5377 impl Registry for FallbackErrorRegistry {
5378 fn get_versions<'a>(
5379 &'a self,
5380 _name: &'a deps_core::PackageName,
5381 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5382 {
5383 Box::pin(async move { Ok(vec![]) })
5384 }
5385
5386 fn get_latest_matching<'a>(
5387 &'a self,
5388 name: &'a deps_core::PackageName,
5389 _req: &'a deps_core::VersionReq,
5390 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5391 {
5392 let name = name.clone();
5393 Box::pin(async move {
5394 if name.as_str() == "not-found" {
5395 Err(deps_core::error::DepsError::PackageNotFound {
5396 package: name.to_string(),
5397 registry: "mock",
5398 })
5399 } else {
5400 Err(deps_core::error::DepsError::CacheError(
5401 "mock fallback failure".to_string(),
5402 ))
5403 }
5404 })
5405 }
5406
5407 fn search<'a>(
5408 &'a self,
5409 _query: &'a str,
5410 _limit: usize,
5411 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
5412 {
5413 Box::pin(async move { Ok(vec![]) })
5414 }
5415
5416 fn as_any(&self) -> &dyn Any {
5417 self
5418 }
5419 }
5420
5421 let registry: Arc<dyn Registry> = Arc::new(FallbackErrorRegistry);
5422 let packages = vec![PackageName::new("flaky"), PackageName::new("not-found")];
5423
5424 let result = fetch_latest_versions_parallel(
5425 registry,
5426 with_registry_source(packages),
5427 &HashMap::new(),
5428 None,
5429 deps_core::freshness::FreshnessSettings::default(),
5430 5,
5431 10,
5432 None,
5433 )
5434 .await;
5435
5436 assert_eq!(result.failed_count, 2);
5437 assert_eq!(
5438 result.fetch_failed.len(),
5439 1,
5440 "not-found must not be in fetch_failed"
5441 );
5442
5443 let toast = fetch_failure_toast(result.failed_count, result.first_error.as_deref(), false)
5444 .expect("failed_count > 0 and not offline, so a toast must be produced");
5445 assert!(
5446 toast.starts_with("deps-lsp: 2 package(s) could not be resolved:"),
5447 "got: {toast}"
5448 );
5449 assert!(
5450 !toast.contains("lookup failed"),
5451 "must not reuse the 'Registry lookup failed' diagnostic wording, which \
5452 excludes not-found and would misrepresent this mixed batch: {toast}"
5453 );
5454 }
5455
5456 #[tokio::test]
5457 async fn test_fetch_fallback_timeout_recorded_as_fetch_failed() {
5458 use deps_core::{Metadata, Registry, Version};
5462 use std::any::Any;
5463 use std::time::Duration;
5464
5465 struct FallbackTimeoutRegistry;
5466
5467 impl Registry for FallbackTimeoutRegistry {
5468 fn get_versions<'a>(
5469 &'a self,
5470 _name: &'a deps_core::PackageName,
5471 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5472 {
5473 Box::pin(async move { Ok(vec![]) })
5474 }
5475
5476 fn get_latest_matching<'a>(
5477 &'a self,
5478 _name: &'a deps_core::PackageName,
5479 _req: &'a deps_core::VersionReq,
5480 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5481 {
5482 Box::pin(async move {
5483 tokio::time::sleep(Duration::from_secs(10)).await;
5484 Ok(None)
5485 })
5486 }
5487
5488 fn search<'a>(
5489 &'a self,
5490 _query: &'a str,
5491 _limit: usize,
5492 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
5493 {
5494 Box::pin(async move { Ok(vec![]) })
5495 }
5496
5497 fn as_any(&self) -> &dyn Any {
5498 self
5499 }
5500 }
5501
5502 let registry: Arc<dyn Registry> = Arc::new(FallbackTimeoutRegistry);
5503 let packages = vec![PackageName::new("slow-fallback")];
5504
5505 let result = fetch_latest_versions_parallel(
5507 registry,
5508 with_registry_source(packages),
5509 &HashMap::new(),
5510 None,
5511 deps_core::freshness::FreshnessSettings::default(),
5512 1,
5513 10,
5514 None,
5515 )
5516 .await;
5517
5518 assert!(result.versions.is_empty());
5519 assert_eq!(
5520 result.fetch_failed,
5521 HashMap::from([(PackageName::new("slow-fallback"), FetchFailure::Transient)])
5522 );
5523 assert_eq!(result.failed_count, 1);
5524 }
5525
5526 #[tokio::test]
5527 async fn test_first_error_prefers_actionable_error_over_not_found_regardless_of_race_order() {
5528 use deps_core::{Metadata, Registry, Version};
5536 use std::any::Any;
5537 use std::time::Duration;
5538
5539 struct MixedErrorRegistry;
5540
5541 impl Registry for MixedErrorRegistry {
5542 fn get_versions<'a>(
5543 &'a self,
5544 name: &'a deps_core::PackageName,
5545 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5546 {
5547 Box::pin(async move {
5548 if name.as_str() == "typo-pkg" {
5549 Err(deps_core::error::DepsError::PackageNotFound {
5550 package: name.to_string(),
5551 registry: "mock",
5552 })
5553 } else {
5554 tokio::time::sleep(Duration::from_millis(50)).await;
5555 Err(deps_core::error::DepsError::CacheError(
5556 "rate limit exceeded".to_string(),
5557 ))
5558 }
5559 })
5560 }
5561
5562 fn get_latest_matching<'a>(
5563 &'a self,
5564 _name: &'a deps_core::PackageName,
5565 _req: &'a deps_core::VersionReq,
5566 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5567 {
5568 Box::pin(async move { Ok(None) })
5569 }
5570
5571 fn search<'a>(
5572 &'a self,
5573 _query: &'a str,
5574 _limit: usize,
5575 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
5576 {
5577 Box::pin(async move { Ok(vec![]) })
5578 }
5579
5580 fn as_any(&self) -> &dyn Any {
5581 self
5582 }
5583 }
5584
5585 let registry: Arc<dyn Registry> = Arc::new(MixedErrorRegistry);
5586 let packages = vec![
5587 PackageName::new("typo-pkg"),
5588 PackageName::new("rate-limited"),
5589 ];
5590
5591 let result = fetch_latest_versions_parallel(
5592 registry,
5593 with_registry_source(packages),
5594 &HashMap::new(),
5595 None,
5596 deps_core::freshness::FreshnessSettings::default(),
5597 5,
5598 10,
5599 None,
5600 )
5601 .await;
5602
5603 let err = result
5604 .first_error
5605 .expect("an actionable failure occurred and must be reported");
5606 assert!(
5607 err.contains("rate limit exceeded"),
5608 "the actionable error must win the toast over the faster-finishing not-found, \
5609 got: {err}"
5610 );
5611 assert!(
5612 !err.contains("not found"),
5613 "a not-found error must never outrank an actionable error, got: {err}"
5614 );
5615 }
5616
5617 #[tokio::test]
5618 async fn test_first_error_falls_back_to_not_found_when_no_actionable_error_occurred() {
5619 use deps_core::{Metadata, Registry, Version};
5624 use std::any::Any;
5625
5626 struct AllNotFoundRegistry;
5627
5628 impl Registry for AllNotFoundRegistry {
5629 fn get_versions<'a>(
5630 &'a self,
5631 name: &'a deps_core::PackageName,
5632 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5633 {
5634 Box::pin(async move {
5635 Err(deps_core::error::DepsError::PackageNotFound {
5636 package: name.to_string(),
5637 registry: "mock",
5638 })
5639 })
5640 }
5641
5642 fn get_latest_matching<'a>(
5643 &'a self,
5644 _name: &'a deps_core::PackageName,
5645 _req: &'a deps_core::VersionReq,
5646 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5647 {
5648 Box::pin(async move { Ok(None) })
5649 }
5650
5651 fn search<'a>(
5652 &'a self,
5653 _query: &'a str,
5654 _limit: usize,
5655 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
5656 {
5657 Box::pin(async move { Ok(vec![]) })
5658 }
5659
5660 fn as_any(&self) -> &dyn Any {
5661 self
5662 }
5663 }
5664
5665 let registry: Arc<dyn Registry> = Arc::new(AllNotFoundRegistry);
5666 let packages = vec![
5667 PackageName::new("typo-pkg-1"),
5668 PackageName::new("typo-pkg-2"),
5669 ];
5670
5671 let result = fetch_latest_versions_parallel(
5672 registry,
5673 with_registry_source(packages),
5674 &HashMap::new(),
5675 None,
5676 deps_core::freshness::FreshnessSettings::default(),
5677 5,
5678 10,
5679 None,
5680 )
5681 .await;
5682
5683 assert!(
5684 result.fetch_failed.is_empty(),
5685 "not-found errors must never be recorded in fetch_failed"
5686 );
5687 let err = result
5688 .first_error
5689 .expect("a not-found-only batch must still fall back to reporting one via first_error");
5690 assert!(err.contains("not found"), "got: {err}");
5691 }
5692
5693 #[tokio::test]
5694 async fn test_timeout_only_batch_reports_first_error_alongside_failed_count() {
5695 use deps_core::{Metadata, Registry, Version};
5706 use std::any::Any;
5707 use std::time::Duration;
5708
5709 struct AlwaysTimesOutRegistry;
5710
5711 impl Registry for AlwaysTimesOutRegistry {
5712 fn get_versions<'a>(
5713 &'a self,
5714 _name: &'a deps_core::PackageName,
5715 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
5716 {
5717 Box::pin(async move {
5718 tokio::time::sleep(Duration::from_secs(10)).await;
5719 Ok(vec![])
5720 })
5721 }
5722
5723 fn get_latest_matching<'a>(
5724 &'a self,
5725 _name: &'a deps_core::PackageName,
5726 _req: &'a deps_core::VersionReq,
5727 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
5728 {
5729 Box::pin(async move {
5730 tokio::time::sleep(Duration::from_secs(10)).await;
5731 Ok(None)
5732 })
5733 }
5734
5735 fn search<'a>(
5736 &'a self,
5737 _query: &'a str,
5738 _limit: usize,
5739 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
5740 {
5741 Box::pin(async move { Ok(vec![]) })
5742 }
5743
5744 fn as_any(&self) -> &dyn Any {
5745 self
5746 }
5747 }
5748
5749 let registry: Arc<dyn Registry> = Arc::new(AlwaysTimesOutRegistry);
5750 let packages = vec![
5751 PackageName::new("slow-1"),
5752 PackageName::new("slow-2"),
5753 PackageName::new("slow-3"),
5754 ];
5755
5756 let result = fetch_latest_versions_parallel(
5757 registry,
5758 with_registry_source(packages),
5759 &HashMap::new(),
5760 None,
5761 deps_core::freshness::FreshnessSettings::default(),
5762 1,
5763 10,
5764 None,
5765 )
5766 .await;
5767
5768 assert_eq!(
5769 result.failed_count, 3,
5770 "all 3 packages must count toward failed_count"
5771 );
5772 let err = result
5773 .first_error
5774 .expect("a timeout is actionable and must populate first_error, not just failed_count");
5775 assert!(
5776 err.contains("timed out"),
5777 "first_error must be the actionable timeout message, got: {err}"
5778 );
5779 }
5780
5781 #[cfg(feature = "composer")]
5783 mod composer_tests {
5784 use super::*;
5785
5786 #[tokio::test]
5791 async fn test_composer_minimum_stability_extracts_from_real_parse_result() {
5792 let json = r#"{
5793 "minimum-stability": "beta",
5794 "require": {
5795 "symfony/console": "^6.0"
5796 }
5797}"#;
5798 let uri = deps_core::test_util::test_uri("/test/composer.json");
5799 let parse_result = crate::parse_composer_json(json, &uri).unwrap();
5800
5801 assert_eq!(
5802 composer_minimum_stability(&parse_result as &dyn deps_core::ParseResult),
5803 Some("beta".to_string())
5804 );
5805 }
5806
5807 #[tokio::test]
5810 async fn test_composer_minimum_stability_none_when_absent() {
5811 let json = r#"{"require": {"symfony/console": "^6.0"}}"#;
5812 let uri = deps_core::test_util::test_uri("/test/composer.json");
5813 let parse_result = crate::parse_composer_json(json, &uri).unwrap();
5814
5815 assert_eq!(
5816 composer_minimum_stability(&parse_result as &dyn deps_core::ParseResult),
5817 None
5818 );
5819 }
5820
5821 #[test]
5825 fn test_composer_minimum_stability_none_for_non_composer_parse_result() {
5826 struct OtherParseResult;
5827 impl deps_core::ParseResult for OtherParseResult {
5828 fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
5829 vec![]
5830 }
5831 fn workspace_root(&self) -> Option<&std::path::Path> {
5832 None
5833 }
5834 fn uri(&self) -> &Uri {
5835 unimplemented!("not exercised by this test")
5836 }
5837 fn as_any(&self) -> &dyn std::any::Any {
5838 self
5839 }
5840 }
5841
5842 assert_eq!(
5843 composer_minimum_stability(&OtherParseResult as &dyn deps_core::ParseResult),
5844 None
5845 );
5846 }
5847 }
5848
5849 #[cfg(feature = "cargo")]
5851 mod cargo_tests {
5852 use super::*;
5853
5854 #[test]
5855 fn test_ecosystem_registry_lookup() {
5856 let state = ServerState::new();
5857 let cargo_uri = deps_core::test_util::test_uri("/test/Cargo.toml");
5858 assert!(state.ecosystem_registry.get_for_uri(&cargo_uri).is_some());
5859 }
5860
5861 #[tokio::test]
5862 async fn test_document_parsing() {
5863 let state = Arc::new(ServerState::new());
5864 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
5865 let content = r#"[dependencies]
5866serde = "1.0"
5867"#;
5868
5869 let ecosystem = state
5870 .ecosystem_registry
5871 .get_for_uri(&uri)
5872 .expect("Cargo ecosystem not found");
5873
5874 let parse_result = ecosystem.parse_manifest(content, &uri).await;
5875 assert!(parse_result.is_ok());
5876
5877 let doc_state = DocumentState::new_from_parse_result(
5878 EcosystemId::Cargo,
5879 content.to_string(),
5880 parse_result.unwrap(),
5881 );
5882 state.update_document(uri.clone(), doc_state);
5883
5884 assert_eq!(state.document_count(), 1);
5885 let doc = state.get_document(&uri).unwrap();
5886 assert_eq!(doc.ecosystem_id(), "cargo");
5887 }
5888
5889 #[tokio::test]
5890 async fn test_document_stored_even_when_parsing_fails() {
5891 let state = Arc::new(ServerState::new());
5892 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
5893 let content = r#"[dependencies
5895serde = "1.0"
5896"#;
5897
5898 let ecosystem = state
5899 .ecosystem_registry
5900 .get_for_uri(&uri)
5901 .expect("Cargo ecosystem not found");
5902
5903 let parse_result = ecosystem.parse_manifest(content, &uri).await.ok();
5905 assert!(
5906 parse_result.is_none(),
5907 "Parsing should fail for invalid TOML"
5908 );
5909
5910 let doc_state = if let Some(pr) = parse_result {
5912 DocumentState::new_from_parse_result(EcosystemId::Cargo, content.to_string(), pr)
5913 } else {
5914 DocumentState::new_without_parse_result(EcosystemId::Cargo, content.to_string())
5915 };
5916
5917 state.update_document(uri.clone(), doc_state);
5918
5919 let doc = state.get_document(&uri);
5921 assert!(
5922 doc.is_some(),
5923 "Document should be stored even when parsing fails"
5924 );
5925
5926 let doc = doc.unwrap();
5927 assert_eq!(doc.ecosystem_id(), "cargo");
5928 assert_eq!(doc.content, content);
5929 assert!(
5930 doc.parse_result().is_none(),
5931 "Parse result should be None for failed parse"
5932 );
5933 }
5934
5935 #[tokio::test]
5936 async fn test_ensure_document_loaded_fast_path() {
5937 let state = Arc::new(ServerState::new());
5939 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
5940 let content = r#"[dependencies]
5941serde = "1.0""#;
5942
5943 let ecosystem = state
5945 .ecosystem_registry
5946 .get_for_uri(&uri)
5947 .expect("Cargo ecosystem");
5948 let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
5949 let doc_state = DocumentState::new_from_parse_result(
5950 EcosystemId::Cargo,
5951 content.to_string(),
5952 parse_result,
5953 );
5954 state.update_document(uri.clone(), doc_state);
5955
5956 assert!(
5958 state.get_document(&uri).is_some(),
5959 "Document should exist in state"
5960 );
5961 assert_eq!(state.document_count(), 1, "Document count should be 1");
5962
5963 }
5967
5968 #[tokio::test]
5969 async fn test_ensure_document_loaded_successful_disk_load() {
5970 use super::super::load_document_from_disk;
5972 use std::fs;
5973 use tempfile::TempDir;
5974
5975 let temp_dir = TempDir::new().unwrap();
5977 let cargo_toml_path = temp_dir.path().join("Cargo.toml");
5978 let content = r#"[package]
5979name = "test"
5980version = "0.1.0"
5981
5982[dependencies]
5983serde = "1.0"
5984"#;
5985 fs::write(&cargo_toml_path, content).unwrap();
5986
5987 let uri = Uri::from_file_path(&cargo_toml_path).unwrap();
5988
5989 let loaded_content = load_document_from_disk(&uri).await.unwrap();
5991 assert_eq!(loaded_content, content);
5992
5993 let state = Arc::new(ServerState::new());
5995 let ecosystem = state
5996 .ecosystem_registry
5997 .get_for_uri(&uri)
5998 .expect("Cargo ecosystem");
5999 let parse_result = ecosystem.parse_manifest(&loaded_content, &uri).await;
6000 assert!(parse_result.is_ok(), "Should parse successfully");
6001
6002 }
6004
6005 #[tokio::test]
6006 async fn test_ensure_document_loaded_idempotent_check() {
6007 let state = Arc::new(ServerState::new());
6009 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
6010 let content = r#"[dependencies]
6011serde = "1.0""#;
6012
6013 let ecosystem = state
6014 .ecosystem_registry
6015 .get_for_uri(&uri)
6016 .expect("Cargo ecosystem");
6017
6018 let parse_result1 = ecosystem.parse_manifest(content, &uri).await.unwrap();
6020 let parse_result2 = ecosystem.parse_manifest(content, &uri).await.unwrap();
6021
6022 let doc_state1 = DocumentState::new_from_parse_result(
6024 EcosystemId::Cargo,
6025 content.to_string(),
6026 parse_result1,
6027 );
6028 state.update_document(uri.clone(), doc_state1);
6029 assert_eq!(state.document_count(), 1);
6030
6031 let doc_state2 = DocumentState::new_from_parse_result(
6033 EcosystemId::Cargo,
6034 content.to_string(),
6035 parse_result2,
6036 );
6037 state.update_document(uri.clone(), doc_state2);
6038 assert_eq!(
6039 state.document_count(),
6040 1,
6041 "Should still have only 1 document"
6042 );
6043 }
6044
6045 #[tokio::test]
6046 async fn test_handle_document_open_rejects_oversized_content() {
6047 use crate::test_utils::test_helpers::create_test_client_and_config;
6048
6049 let state = Arc::new(ServerState::new());
6050 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
6051 let oversized_content = "a".repeat(MAX_FILE_SIZE as usize + 1);
6052 let (client, config) = create_test_client_and_config();
6053
6054 let result = handle_document_open(
6055 uri.clone(),
6056 oversized_content,
6057 Some(1),
6058 state.clone(),
6059 client,
6060 config,
6061 )
6062 .await;
6063
6064 assert!(result.is_err(), "Oversized content should be rejected");
6065 match result {
6066 Err(deps_core::error::DepsError::CacheError(msg)) => {
6067 assert!(
6068 msg.contains("too large"),
6069 "Error message should indicate size issue: {msg}"
6070 );
6071 }
6072 other => panic!("Expected CacheError for oversized content, got {other:?}"),
6073 }
6074 assert_eq!(
6075 state.document_count(),
6076 0,
6077 "Oversized content must not be stored/parsed"
6078 );
6079 }
6080
6081 #[tokio::test]
6082 async fn test_handle_document_open_accepts_normal_sized_content() {
6083 use crate::test_utils::test_helpers::create_test_client_and_config;
6084
6085 let state = Arc::new(ServerState::new());
6086 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
6087 let content = r#"[dependencies]
6088serde = "1.0"
6089"#
6090 .to_string();
6091 let (client, config) = create_test_client_and_config();
6092
6093 let result =
6094 handle_document_open(uri.clone(), content, Some(1), state.clone(), client, config)
6095 .await;
6096
6097 assert!(result.is_ok(), "Normal-sized content should be accepted");
6098 assert_eq!(state.document_count(), 1);
6099 }
6100
6101 #[tokio::test]
6102 async fn test_handle_document_change_rejects_oversized_content() {
6103 use crate::test_utils::test_helpers::create_test_client_and_config;
6104
6105 let state = Arc::new(ServerState::new());
6106 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
6107 let oversized_content = "a".repeat(MAX_FILE_SIZE as usize + 1);
6108 let (client, config) = create_test_client_and_config();
6109
6110 let result = handle_document_change(
6111 uri.clone(),
6112 oversized_content,
6113 Some(2),
6114 state.clone(),
6115 client,
6116 config,
6117 )
6118 .await;
6119
6120 assert!(result.is_err(), "Oversized content should be rejected");
6121 match result {
6122 Err(deps_core::error::DepsError::CacheError(msg)) => {
6123 assert!(
6124 msg.contains("too large"),
6125 "Error message should indicate size issue: {msg}"
6126 );
6127 }
6128 other => panic!("Expected CacheError for oversized content, got {other:?}"),
6129 }
6130 assert_eq!(
6131 state.document_count(),
6132 0,
6133 "Oversized content must not be stored/parsed"
6134 );
6135 }
6136
6137 #[tokio::test]
6138 async fn test_handle_document_change_rejects_oversized_content_preserves_existing_document()
6139 {
6140 use crate::test_utils::test_helpers::create_test_client_and_config;
6141
6142 let state = Arc::new(ServerState::new());
6143 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
6144 let original_content = r#"[dependencies]
6145serde = "1.0"
6146"#
6147 .to_string();
6148
6149 let (client, config) = create_test_client_and_config();
6151 handle_document_open(
6152 uri.clone(),
6153 original_content.clone(),
6154 Some(1),
6155 state.clone(),
6156 client,
6157 config,
6158 )
6159 .await
6160 .expect("initial open should succeed");
6161 assert_eq!(state.document_count(), 1);
6162
6163 let oversized_content = "a".repeat(MAX_FILE_SIZE as usize + 1);
6165 let (client, config) = create_test_client_and_config();
6166 let result = handle_document_change(
6167 uri.clone(),
6168 oversized_content,
6169 Some(2),
6170 state.clone(),
6171 client,
6172 config,
6173 )
6174 .await;
6175
6176 assert!(result.is_err(), "Oversized change should be rejected");
6177 assert_eq!(
6178 state.document_count(),
6179 1,
6180 "The previously stored document must survive a rejected change"
6181 );
6182 let doc = state
6183 .get_document(&uri)
6184 .expect("original document should still be present");
6185 assert_eq!(
6186 doc.content, original_content,
6187 "Document content must be unchanged by the rejected change"
6188 );
6189 }
6190
6191 #[tokio::test]
6199 async fn test_handle_document_open_completes_promptly_with_refresh_support_enabled() {
6200 use crate::test_utils::test_helpers::create_test_client_and_config;
6201
6202 let state = Arc::new(ServerState::new());
6203 state.set_inlay_hint_refresh_supported(true);
6204 state.set_code_lens_refresh_supported(true);
6205
6206 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
6207 let content = r#"[dependencies]
6208serde = "1.0"
6209"#
6210 .to_string();
6211 let (client, config) = create_test_client_and_config();
6212
6213 let task =
6214 handle_document_open(uri.clone(), content, Some(1), state.clone(), client, config)
6215 .await
6216 .expect("normal-sized content should be accepted");
6217
6218 tokio::time::timeout(Duration::from_secs(10), task)
6219 .await
6220 .expect(
6221 "background task must complete promptly even with refresh support \
6222 enabled (issue #493 regression: an inline refresh await could hang here)",
6223 )
6224 .expect("background task must not panic");
6225
6226 let doc = state.get_document(&uri).expect("document should be stored");
6227 assert!(
6228 matches!(
6229 doc.loading_state,
6230 deps_core::LoadingState::Loaded | deps_core::LoadingState::Failed
6231 ),
6232 "document loading must reach a terminal state, proving the pipeline ran \
6233 past the refresh call sites to commit OSV results and diagnostics: {:?}",
6234 doc.loading_state
6235 );
6236 }
6237
6238 #[tokio::test]
6239 async fn test_handle_document_change_completes_promptly_with_refresh_support_enabled() {
6240 use crate::test_utils::test_helpers::create_test_client_and_config;
6241
6242 let state = Arc::new(ServerState::new());
6243 state.set_inlay_hint_refresh_supported(true);
6244 state.set_code_lens_refresh_supported(true);
6245
6246 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
6247 let original_content = r#"[dependencies]
6248serde = "1.0"
6249"#
6250 .to_string();
6251 let (client, config) = create_test_client_and_config();
6252 handle_document_open(
6253 uri.clone(),
6254 original_content,
6255 Some(1),
6256 state.clone(),
6257 client,
6258 config,
6259 )
6260 .await
6261 .expect("initial open should succeed")
6262 .await
6263 .expect("initial open's background task must not panic");
6264
6265 let changed_content = r#"[dependencies]
6266serde = "1.0"
6267tokio = "1.0"
6268"#
6269 .to_string();
6270 let (client, config) = create_test_client_and_config();
6271 let task = handle_document_change(
6272 uri.clone(),
6273 changed_content,
6274 Some(2),
6275 state.clone(),
6276 client,
6277 config,
6278 )
6279 .await
6280 .expect("normal-sized change should be accepted");
6281
6282 tokio::time::timeout(Duration::from_secs(10), task)
6283 .await
6284 .expect(
6285 "background task must complete promptly even with refresh support \
6286 enabled (issue #493 regression: an inline refresh await could hang here)",
6287 )
6288 .expect("background task must not panic");
6289
6290 let doc = state.get_document(&uri).expect("document should be stored");
6291 assert!(
6292 matches!(
6293 doc.loading_state,
6294 deps_core::LoadingState::Loaded | deps_core::LoadingState::Failed
6295 ),
6296 "document loading must reach a terminal state, proving the pipeline ran \
6297 past the refresh call sites to commit OSV results and diagnostics: {:?}",
6298 doc.loading_state
6299 );
6300 }
6301 }
6302
6303 #[cfg(feature = "npm")]
6305 mod npm_tests {
6306 use super::*;
6307
6308 #[test]
6309 fn test_ecosystem_registry_lookup() {
6310 let state = ServerState::new();
6311 let npm_uri = deps_core::test_util::test_uri("/test/package.json");
6312 assert!(state.ecosystem_registry.get_for_uri(&npm_uri).is_some());
6313 }
6314
6315 #[tokio::test]
6316 async fn test_document_parsing() {
6317 let state = Arc::new(ServerState::new());
6318 let uri = deps_core::test_util::test_uri("/test/package.json");
6319 let content = r#"{"dependencies": {"express": "^4.18.0"}}"#;
6320
6321 let ecosystem = state
6322 .ecosystem_registry
6323 .get_for_uri(&uri)
6324 .expect("npm ecosystem not found");
6325
6326 let parse_result = ecosystem.parse_manifest(content, &uri).await;
6327 assert!(parse_result.is_ok());
6328
6329 let doc_state = DocumentState::new_from_parse_result(
6330 EcosystemId::Npm,
6331 content.to_string(),
6332 parse_result.unwrap(),
6333 );
6334 state.update_document(uri.clone(), doc_state);
6335
6336 let doc = state.get_document(&uri).unwrap();
6337 assert_eq!(doc.ecosystem_id(), "npm");
6338 }
6339
6340 #[tokio::test]
6345 async fn test_handle_document_change_guarded_skips_commit_when_version_changed() {
6346 use crate::test_utils::test_helpers::create_test_client_and_config;
6347
6348 let state = Arc::new(ServerState::new());
6349 let uri = deps_core::test_util::test_uri("/test/package.json");
6350 let original_content = r#"{"dependencies": {"express": "^4.18.0"}}"#.to_string();
6351 let (client, config) = create_test_client_and_config();
6352
6353 handle_document_open(
6354 uri.clone(),
6355 original_content,
6356 Some(1),
6357 Arc::clone(&state),
6358 client.clone(),
6359 Arc::clone(&config),
6360 )
6361 .await
6362 .unwrap();
6363
6364 let concurrent_content = r#"{"dependencies": {"express": "^4.19.0"}}"#.to_string();
6368 handle_document_change(
6369 uri.clone(),
6370 concurrent_content.clone(),
6371 Some(2),
6372 Arc::clone(&state),
6373 client.clone(),
6374 Arc::clone(&config),
6375 )
6376 .await
6377 .unwrap();
6378
6379 let stale_content =
6382 r#"{"dependencies": {"express": "^4.18.0", "lodash": "^4.0.0"}}"#.to_string();
6383 let task = handle_document_change_guarded(
6384 uri.clone(),
6385 stale_content,
6386 Some(3),
6387 CommitGuard::ExpectVersion(Some(1)),
6388 RefetchPolicy::Diff,
6389 Arc::clone(&state),
6390 client,
6391 config,
6392 )
6393 .await
6394 .unwrap();
6395 assert!(
6396 task.is_none(),
6397 "a version mismatch must skip the commit and return None, never a sentinel \
6398 task (impl-critic S3)"
6399 );
6400
6401 let doc = state.get_document(&uri).unwrap();
6402 assert_eq!(
6403 doc.content, concurrent_content,
6404 "a stale guarded reparse must not overwrite content committed after its snapshot"
6405 );
6406 assert_eq!(doc.version, Some(2));
6407 }
6408
6409 #[tokio::test]
6412 async fn test_handle_document_change_guarded_commits_when_version_matches() {
6413 use crate::test_utils::test_helpers::create_test_client_and_config;
6414
6415 let state = Arc::new(ServerState::new());
6416 let uri = deps_core::test_util::test_uri("/test/package.json");
6417 let original_content = r#"{"dependencies": {"express": "^4.18.0"}}"#.to_string();
6418 let (client, config) = create_test_client_and_config();
6419
6420 handle_document_open(
6421 uri.clone(),
6422 original_content,
6423 Some(1),
6424 Arc::clone(&state),
6425 client.clone(),
6426 Arc::clone(&config),
6427 )
6428 .await
6429 .unwrap();
6430
6431 let new_content = r#"{"dependencies": {"express": "^4.19.0"}}"#.to_string();
6432 let task = handle_document_change_guarded(
6433 uri.clone(),
6434 new_content.clone(),
6435 Some(1),
6436 CommitGuard::ExpectVersion(Some(1)),
6437 RefetchPolicy::Diff,
6438 Arc::clone(&state),
6439 client,
6440 config,
6441 )
6442 .await
6443 .unwrap();
6444 task.expect("a matching version must commit and spawn a real task")
6445 .await
6446 .unwrap();
6447
6448 let doc = state.get_document(&uri).unwrap();
6449 assert_eq!(doc.content, new_content);
6450 }
6451
6452 #[tokio::test]
6459 async fn test_guarded_reparse_skip_does_not_abort_pre_existing_background_task() {
6460 use crate::test_utils::test_helpers::create_test_client_and_config;
6461 use std::sync::atomic::{AtomicBool, Ordering};
6462
6463 let state = Arc::new(ServerState::new());
6464 let uri = deps_core::test_util::test_uri("/test/package.json");
6465 let original_content = r#"{"dependencies": {"express": "^4.18.0"}}"#.to_string();
6466 let (client, config) = create_test_client_and_config();
6467
6468 handle_document_open(
6469 uri.clone(),
6470 original_content,
6471 Some(1),
6472 Arc::clone(&state),
6473 client.clone(),
6474 Arc::clone(&config),
6475 )
6476 .await
6477 .unwrap();
6478
6479 let ran_to_completion = Arc::new(AtomicBool::new(false));
6482 let flag = Arc::clone(&ran_to_completion);
6483 let pre_existing_task = tokio::spawn(async move {
6484 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
6485 flag.store(true, Ordering::SeqCst);
6486 });
6487 state
6488 .spawn_background_task(uri.clone(), pre_existing_task)
6489 .await;
6490
6491 let stale_content = r#"{"dependencies": {"express": "^4.19.0"}}"#.to_string();
6495 let result = handle_document_change_guarded(
6496 uri.clone(),
6497 stale_content,
6498 Some(2),
6499 CommitGuard::ExpectVersion(Some(999)),
6500 RefetchPolicy::Diff,
6501 Arc::clone(&state),
6502 client,
6503 config,
6504 )
6505 .await
6506 .unwrap();
6507 assert!(result.is_none(), "a version mismatch must skip the commit");
6508 if let Some(task) = result {
6509 state.spawn_background_task(uri.clone(), task).await;
6510 }
6511
6512 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
6516 assert!(
6517 ran_to_completion.load(Ordering::SeqCst),
6518 "the pre-existing background task must not be aborted by a skipped guarded reparse"
6519 );
6520 }
6521 }
6522
6523 #[cfg(feature = "pypi")]
6525 mod pypi_tests {
6526 use super::*;
6527
6528 #[test]
6529 fn test_ecosystem_registry_lookup() {
6530 let state = ServerState::new();
6531 let pypi_uri = deps_core::test_util::test_uri("/test/pyproject.toml");
6532 assert!(state.ecosystem_registry.get_for_uri(&pypi_uri).is_some());
6533 }
6534
6535 #[tokio::test]
6536 async fn test_document_parsing() {
6537 let state = Arc::new(ServerState::new());
6538 let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
6539 let content = r#"[project]
6540dependencies = ["requests>=2.0.0"]
6541"#;
6542
6543 let ecosystem = state
6544 .ecosystem_registry
6545 .get_for_uri(&uri)
6546 .expect("pypi ecosystem not found");
6547
6548 let parse_result = ecosystem.parse_manifest(content, &uri).await;
6549 assert!(parse_result.is_ok());
6550
6551 let doc_state = DocumentState::new_from_parse_result(
6552 EcosystemId::Pypi,
6553 content.to_string(),
6554 parse_result.unwrap(),
6555 );
6556 state.update_document(uri.clone(), doc_state);
6557
6558 let doc = state.get_document(&uri).unwrap();
6559 assert_eq!(doc.ecosystem_id(), "pypi");
6560 }
6561 }
6562
6563 #[cfg(feature = "pypi")]
6583 mod pypi_yanked_key_guard_tests {
6584 use super::*;
6585 use deps_core::{DiagnosticSeverities, Metadata, Version, VersionData};
6586 use std::any::Any;
6587
6588 #[derive(Debug, Clone)]
6589 struct MockYankVersion {
6590 version: ConcreteVersion,
6591 yanked: bool,
6592 }
6593
6594 impl Version for MockYankVersion {
6595 fn version_string(&self) -> &ConcreteVersion {
6596 &self.version
6597 }
6598 fn removal_status(&self) -> deps_core::RemovalStatus {
6599 deps_core::RemovalStatus::from_yanked(self.yanked)
6600 }
6601 fn as_any(&self) -> &dyn Any {
6602 self
6603 }
6604 }
6605
6606 struct MockYankedRegistry {
6610 pinned_version: &'static str,
6611 }
6612
6613 impl Registry for MockYankedRegistry {
6614 fn get_versions<'a>(
6615 &'a self,
6616 _name: &'a PackageName,
6617 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
6618 {
6619 let versions = vec![
6620 Box::new(MockYankVersion {
6621 version: "9.9.9".into(),
6622 yanked: false,
6623 }) as Box<dyn Version>,
6624 Box::new(MockYankVersion {
6625 version: self.pinned_version.into(),
6626 yanked: true,
6627 }) as Box<dyn Version>,
6628 ];
6629 Box::pin(async move { Ok(versions) })
6630 }
6631
6632 fn get_latest_matching<'a>(
6633 &'a self,
6634 _name: &'a PackageName,
6635 _req: &'a VersionReq,
6636 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
6637 {
6638 let latest = Box::new(MockYankVersion {
6639 version: "9.9.9".into(),
6640 yanked: false,
6641 }) as Box<dyn Version>;
6642 Box::pin(async move { Ok(Some(latest)) })
6643 }
6644
6645 fn search<'a>(
6646 &'a self,
6647 _query: &'a str,
6648 _limit: usize,
6649 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
6650 {
6651 Box::pin(async move { Ok(vec![]) })
6652 }
6653
6654 fn as_any(&self) -> &dyn Any {
6655 self
6656 }
6657 }
6658
6659 async fn run_pipeline(
6664 raw_name: &str,
6665 pinned_version: &'static str,
6666 ) -> (
6667 Vec<tower_lsp_server::ls_types::Diagnostic>,
6668 HashMap<String, (ConcreteVersion, RemovalStatus)>,
6669 ) {
6670 let state = Arc::new(ServerState::new());
6671 let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
6672 let content =
6676 format!("[tool.poetry.dependencies]\n\"{raw_name}\" = \"=={pinned_version}\"\n");
6677
6678 let ecosystem = state
6679 .ecosystem_registry
6680 .get_for_uri(&uri)
6681 .expect("pypi ecosystem not found");
6682 let formatter = ecosystem.formatter();
6683
6684 let parse_result = ecosystem
6685 .parse_manifest(&content, &uri)
6686 .await
6687 .expect("a single Poetry table-key dependency must parse");
6688 assert_eq!(
6689 parse_result
6690 .dependencies()
6691 .iter()
6692 .map(|d| d.name().to_string())
6693 .collect::<Vec<_>>(),
6694 vec![raw_name.to_string()],
6695 "Poetry table-key parsing must keep the manifest-declared name as-is"
6696 );
6697
6698 let resolved_versions = HashMap::new();
6699 let dep_names: Vec<PackageName> = parse_result
6700 .dependencies()
6701 .into_iter()
6702 .map(|d| d.name().clone())
6703 .collect();
6704 let in_use = collect_in_use_versions(
6705 parse_result.as_ref(),
6706 &resolved_versions,
6707 formatter,
6708 EcosystemId::Pypi,
6709 );
6710 assert_eq!(
6713 in_use.get(&PackageName::new(raw_name)),
6714 Some(&vec![pinned_version.to_string()])
6715 );
6716
6717 let registry: Arc<dyn Registry> = Arc::new(MockYankedRegistry { pinned_version });
6718 let fetch_result = fetch_latest_versions_parallel(
6719 registry,
6720 with_registry_source(dep_names),
6721 &in_use,
6722 None,
6723 deps_core::freshness::FreshnessSettings::default(),
6724 5,
6725 10,
6726 None,
6727 )
6728 .await;
6729
6730 let yanked_versions: HashMap<String, (ConcreteVersion, RemovalStatus)> = fetch_result
6731 .yanked_versions
6732 .into_iter()
6733 .map(|(name, v)| (formatter.normalize_package_name(&name), v))
6734 .collect();
6735
6736 let mut doc_state = DocumentState::new_from_parse_result(
6737 EcosystemId::Pypi,
6738 content.clone(),
6739 parse_result,
6740 );
6741 doc_state.update_cached_versions(fetch_result.versions);
6742 let mut outcomes = DependencyOutcomes::new();
6743 for (name, v) in yanked_versions.clone() {
6744 outcomes.set_yanked(name, v);
6745 }
6746 doc_state.replace_outcomes(outcomes);
6747 state.update_document(uri.clone(), doc_state);
6748
6749 let doc = state.get_document(&uri).unwrap();
6750 let diagnostics = deps_core::lsp_helpers::generate_diagnostics_from_cache(
6751 doc.parse_result().unwrap(),
6752 VersionData::new(&doc.cached_versions, &doc.resolved_versions)
6753 .with_outcomes(&doc.outcomes),
6754 formatter,
6755 &uri,
6756 deps_core::freshness::FreshnessSettings::default(),
6757 DiagnosticSeverities::default(),
6758 deps_core::PublishTime::now(),
6759 );
6760
6761 (diagnostics, yanked_versions)
6762 }
6763
6764 #[tokio::test]
6765 async fn typing_extensions_underscore_name_resolves_via_normalized_key() {
6766 let (diagnostics, yanked_versions) = run_pipeline("typing_extensions", "4.9.0").await;
6767
6768 assert_eq!(
6769 yanked_versions.get("typing-extensions"),
6770 Some(&(ConcreteVersion::new("4.9.0"), RemovalStatus::Yanked)),
6771 "must be keyed by the normalized (dash) name, not the raw manifest name"
6772 );
6773 assert!(
6774 diagnostics.iter().any(|d| d.message.contains("4.9.0")),
6775 "yanked diagnostic must reach the generated output: {diagnostics:?}"
6776 );
6777 }
6778
6779 #[tokio::test]
6780 async fn zope_interface_dotted_name_resolves_via_normalized_key() {
6781 let (diagnostics, yanked_versions) = run_pipeline("zope.interface", "5.0.0").await;
6782
6783 assert_eq!(
6784 yanked_versions.get("zope-interface"),
6785 Some(&(ConcreteVersion::new("5.0.0"), RemovalStatus::Yanked)),
6786 "must be keyed by the normalized (dotted -> dash) name"
6787 );
6788 assert!(
6789 diagnostics.iter().any(|d| d.message.contains("5.0.0")),
6790 "yanked diagnostic must reach the generated output: {diagnostics:?}"
6791 );
6792 }
6793 }
6794
6795 #[cfg(feature = "go")]
6797 mod go_tests {
6798 use super::*;
6799
6800 #[test]
6801 fn test_ecosystem_registry_lookup() {
6802 let state = ServerState::new();
6803 let go_uri = deps_core::test_util::test_uri("/test/go.mod");
6804 assert!(state.ecosystem_registry.get_for_uri(&go_uri).is_some());
6805 }
6806
6807 #[tokio::test]
6808 async fn test_document_parsing() {
6809 let state = Arc::new(ServerState::new());
6810 let uri = deps_core::test_util::test_uri("/test/go.mod");
6811 let content = r"module example.com/mymodule
6812
6813go 1.21
6814
6815require github.com/gorilla/mux v1.8.0
6816";
6817
6818 let ecosystem = state
6819 .ecosystem_registry
6820 .get_for_uri(&uri)
6821 .expect("go ecosystem not found");
6822
6823 let parse_result = ecosystem.parse_manifest(content, &uri).await;
6824 assert!(parse_result.is_ok());
6825
6826 let doc_state = DocumentState::new_from_parse_result(
6827 EcosystemId::Go,
6828 content.to_string(),
6829 parse_result.unwrap(),
6830 );
6831 state.update_document(uri.clone(), doc_state);
6832
6833 let doc = state.get_document(&uri).unwrap();
6834 assert_eq!(doc.ecosystem_id(), "go");
6835 }
6836
6837 #[tokio::test]
6846 async fn test_handle_document_open_go_instant_cache_excludes_stale_require_version() {
6847 use crate::test_utils::test_helpers::create_test_client_and_config;
6848 use std::fs;
6849 use tempfile::TempDir;
6850 use tokio::time::{Duration, sleep};
6851
6852 let temp_dir = TempDir::new().unwrap();
6853 let go_mod_path = temp_dir.path().join("go.mod");
6854 let go_sum_path = temp_dir.path().join("go.sum");
6855
6856 let go_mod_content = r"module example.com/mymodule
6858
6859go 1.21
6860
6861require github.com/gorilla/mux v1.8.0
6862";
6863 fs::write(&go_mod_path, go_mod_content).unwrap();
6864
6865 let go_sum_content = r"github.com/gorilla/mux v1.8.0 h1:hash1=
6869github.com/gorilla/mux v1.8.1 h1:hash2=
6870";
6871 fs::write(&go_sum_path, go_sum_content).unwrap();
6872
6873 let uri = Uri::from_file_path(&go_mod_path).unwrap();
6874 let state = Arc::new(ServerState::new());
6875 let (client, config) = create_test_client_and_config();
6876
6877 handle_document_open(
6878 uri.clone(),
6879 go_mod_content.to_string(),
6880 Some(1),
6881 state.clone(),
6882 client,
6883 config,
6884 )
6885 .await
6886 .expect("go.mod should open successfully");
6887
6888 let dep_name = PackageName::new("github.com/gorilla/mux");
6889
6890 let mut resolved_seen = false;
6894 for _ in 0..200 {
6895 if state
6896 .get_document(&uri)
6897 .is_some_and(|doc| doc.resolved_versions.contains_key(&dep_name))
6898 {
6899 resolved_seen = true;
6900 break;
6901 }
6902 sleep(Duration::from_millis(5)).await;
6903 }
6904 assert!(
6905 resolved_seen,
6906 "resolved_versions should be seeded from go.sum shortly after open"
6907 );
6908
6909 let doc = state.get_document(&uri).unwrap();
6910 assert_eq!(
6911 doc.resolved_versions.get(&dep_name),
6912 Some(&ConcreteVersion::new("v1.8.1")),
6913 "sanity check: go.sum's last-occurrence-wins parsing does surface the stale version"
6914 );
6915 assert!(
6916 !doc.cached_versions.contains_key(&dep_name),
6917 "S1: a Go `require` dependency's stale go.sum version must not be seeded into \
6918 cached_versions (the 'latest' comparison operand) during the cold-open window — \
6919 doing so would desync it against the go.mod-accurate resolved value and produce \
6920 a false 'outdated, update to the version you downgraded away from' signal"
6921 );
6922 }
6923 }
6924
6925 #[cfg(feature = "cargo")]
6927 mod incremental_fetch_tests {
6928 use super::*;
6929
6930 #[tokio::test]
6931 async fn test_preserve_cached_versions_on_change() {
6932 let state = Arc::new(ServerState::new());
6933 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
6934
6935 let content1 = r#"[dependencies]
6937serde = "1.0"
6938tokio = "1.0"
6939"#;
6940
6941 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
6942 let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
6943 let doc_state1 = DocumentState::new_from_parse_result(
6944 EcosystemId::Cargo,
6945 content1.to_string(),
6946 parse_result1,
6947 );
6948 state.update_document(uri.clone(), doc_state1);
6949
6950 {
6952 let mut doc = state.documents.get_mut(&uri).unwrap();
6953 doc.cached_versions
6954 .insert("serde".into(), PackageVersions::latest_only("1.0.210"));
6955 doc.cached_versions
6956 .insert("tokio".into(), PackageVersions::latest_only("1.40.0"));
6957 doc.resolved_versions
6958 .insert("serde".into(), "1.0.195".into());
6959 doc.resolved_versions
6960 .insert("tokio".into(), "1.35.0".into());
6961 }
6962
6963 {
6965 let doc = state.get_document(&uri).unwrap();
6966 assert_eq!(doc.cached_versions.len(), 2);
6967 assert_eq!(doc.resolved_versions.len(), 2);
6968 }
6969
6970 let content2 = r#"[dependencies]
6972serde = "1.0.210"
6973tokio = "1.0"
6974"#;
6975
6976 let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
6977 let mut doc_state2 = DocumentState::new_from_parse_result(
6978 EcosystemId::Cargo,
6979 content2.to_string(),
6980 parse_result2,
6981 );
6982
6983 if let Some(old_doc) = state.get_document(&uri) {
6984 preserve_cache(&mut doc_state2, &old_doc);
6985 }
6986
6987 state.update_document(uri.clone(), doc_state2);
6988
6989 {
6991 let doc = state.get_document(&uri).unwrap();
6992 assert_eq!(
6993 doc.cached_versions.len(),
6994 2,
6995 "Cached versions should be preserved"
6996 );
6997 assert_eq!(
6998 doc.cached_versions.get("serde").map(|v| v.latest.as_str()),
6999 Some("1.0.210"),
7000 "serde cache preserved"
7001 );
7002 assert_eq!(
7003 doc.cached_versions.get("tokio").map(|v| v.latest.as_str()),
7004 Some("1.40.0"),
7005 "tokio cache preserved"
7006 );
7007 assert_eq!(
7008 doc.resolved_versions.len(),
7009 2,
7010 "Resolved versions should be preserved"
7011 );
7012 }
7013 }
7014
7015 #[tokio::test]
7016 async fn test_preserve_cache_carries_vulnerabilities_across_edit() {
7017 use deps_core::osv::{ScanOutcome, VulnerabilityMap};
7018
7019 let state = Arc::new(ServerState::new());
7020 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7021
7022 let content1 = r#"[dependencies]
7023time = "0.1.43"
7024"#;
7025 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7026 let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
7027 let doc_state1 = DocumentState::new_from_parse_result(
7028 EcosystemId::Cargo,
7029 content1.to_string(),
7030 parse_result1,
7031 );
7032 state.update_document(uri.clone(), doc_state1);
7033
7034 let mut vulns = VulnerabilityMap::new();
7035 vulns.insert("time".to_string(), ScanOutcome::Clean);
7036 {
7037 let mut doc = state.documents.get_mut(&uri).unwrap();
7038 doc.update_vulnerabilities(vulns);
7039 }
7040
7041 let content2 = r#"[dependencies]
7045time = "0.1.43"
7046
7047"#;
7048 let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
7049 let mut doc_state2 = DocumentState::new_from_parse_result(
7050 EcosystemId::Cargo,
7051 content2.to_string(),
7052 parse_result2,
7053 );
7054
7055 if let Some(old_doc) = state.get_document(&uri) {
7056 preserve_cache(&mut doc_state2, &old_doc);
7057 }
7058 state.update_document(uri.clone(), doc_state2);
7059
7060 let doc = state.get_document(&uri).unwrap();
7061 assert_matches!(doc.vulnerabilities.get("time"), Some(ScanOutcome::Clean));
7062 }
7063
7064 #[tokio::test]
7065 async fn test_preserve_cache_carries_yanked_versions_across_edit() {
7066 let state = Arc::new(ServerState::new());
7067 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7068
7069 let content1 = r#"[dependencies]
7070time = "0.1.43"
7071"#;
7072 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7073 let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
7074 let doc_state1 = DocumentState::new_from_parse_result(
7075 EcosystemId::Cargo,
7076 content1.to_string(),
7077 parse_result1,
7078 );
7079 state.update_document(uri.clone(), doc_state1);
7080
7081 {
7082 let mut doc = state.documents.get_mut(&uri).unwrap();
7083 doc.replace_outcomes(DependencyOutcomes::new().with_yanked(
7084 "time",
7085 (ConcreteVersion::new("0.1.43"), RemovalStatus::Yanked),
7086 ));
7087 }
7088
7089 let content2 = r#"[dependencies]
7093time = "0.1.43"
7094
7095"#;
7096 let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
7097 let mut doc_state2 = DocumentState::new_from_parse_result(
7098 EcosystemId::Cargo,
7099 content2.to_string(),
7100 parse_result2,
7101 );
7102
7103 if let Some(old_doc) = state.get_document(&uri) {
7104 preserve_cache(&mut doc_state2, &old_doc);
7105 }
7106 state.update_document(uri.clone(), doc_state2);
7107
7108 let doc = state.get_document(&uri).unwrap();
7109 assert_eq!(
7110 doc.outcomes.yanked("time"),
7111 Some(&(ConcreteVersion::new("0.1.43"), RemovalStatus::Yanked))
7112 );
7113 }
7114
7115 #[tokio::test]
7116 async fn test_preserve_cache_carries_deprecations_across_edit() {
7117 let state = Arc::new(ServerState::new());
7118 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7119
7120 let content1 = r#"[dependencies]
7121time = "0.1.43"
7122"#;
7123 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7124 let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
7125 let doc_state1 = DocumentState::new_from_parse_result(
7126 EcosystemId::Cargo,
7127 content1.to_string(),
7128 parse_result1,
7129 );
7130 state.update_document(uri.clone(), doc_state1);
7131
7132 {
7133 let mut doc = state.documents.get_mut(&uri).unwrap();
7134 doc.replace_outcomes(DependencyOutcomes::new().with_deprecation(
7135 "time",
7136 Deprecation {
7137 reason: Some("archived".to_string()),
7138 replacement: None,
7139 },
7140 ));
7141 }
7142
7143 let content2 = r#"[dependencies]
7147time = "0.1.43"
7148
7149"#;
7150 let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
7151 let mut doc_state2 = DocumentState::new_from_parse_result(
7152 EcosystemId::Cargo,
7153 content2.to_string(),
7154 parse_result2,
7155 );
7156
7157 if let Some(old_doc) = state.get_document(&uri) {
7158 preserve_cache(&mut doc_state2, &old_doc);
7159 }
7160 state.update_document(uri.clone(), doc_state2);
7161
7162 let doc = state.get_document(&uri).unwrap();
7163 assert_eq!(
7164 doc.outcomes.deprecation("time"),
7165 Some(&Deprecation {
7166 reason: Some("archived".to_string()),
7167 replacement: None,
7168 })
7169 );
7170 }
7171
7172 #[tokio::test]
7173 async fn test_deprecations_pruned_on_dependency_removal_by_normalized_name() {
7174 let state = Arc::new(ServerState::new());
7175 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7176
7177 let content1 = r#"[dependencies]
7178serde = "1.0"
7179time = "0.1.43"
7180"#;
7181 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7182 let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
7183 let doc_state1 = DocumentState::new_from_parse_result(
7184 EcosystemId::Cargo,
7185 content1.to_string(),
7186 parse_result1,
7187 );
7188 state.update_document(uri.clone(), doc_state1);
7189
7190 {
7191 let mut doc = state.documents.get_mut(&uri).unwrap();
7192 doc.replace_outcomes(DependencyOutcomes::new().with_deprecation(
7193 "time",
7194 Deprecation {
7195 reason: Some("archived".to_string()),
7196 replacement: None,
7197 },
7198 ));
7199 }
7200
7201 let content2 = r#"[dependencies]
7202serde = "1.0"
7203"#;
7204 let old_deps: HashMap<PackageName, Vec<Option<VersionReq>>> =
7205 [("serde", None), ("time", None)]
7206 .into_iter()
7207 .map(|(n, r)| (PackageName::new(n), vec![r]))
7208 .collect();
7209 let new_deps: HashMap<PackageName, Vec<Option<VersionReq>>> =
7210 std::iter::once((PackageName::new("serde"), vec![None])).collect();
7211 let diff = DependencyDiff::compute(&old_deps, &new_deps);
7212 assert_eq!(diff.removed, vec![PackageName::new("time")]);
7213
7214 let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
7215 let mut doc_state2 = DocumentState::new_from_parse_result(
7216 EcosystemId::Cargo,
7217 content2.to_string(),
7218 parse_result2,
7219 );
7220
7221 if let Some(old_doc) = state.get_document(&uri) {
7222 preserve_cache(&mut doc_state2, &old_doc);
7223 }
7224
7225 let formatter = ecosystem.formatter();
7226 for removed_dep in &diff.removed {
7227 doc_state2
7228 .outcomes
7229 .remove(&formatter.normalize_package_name(removed_dep));
7230 }
7231
7232 state.update_document(uri.clone(), doc_state2);
7233
7234 let doc = state.get_document(&uri).unwrap();
7235 assert!(
7236 doc.outcomes.deprecation("time").is_none(),
7237 "removed dependency's deprecation entry must be pruned"
7238 );
7239 }
7240
7241 #[tokio::test]
7246 async fn test_deprecations_survive_version_change_unlike_yanked_versions() {
7247 let state = Arc::new(ServerState::new());
7248 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7249
7250 let content1 = r#"[dependencies]
7251time = "0.1.44"
7252"#;
7253 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7254 let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
7255 let doc_state1 = DocumentState::new_from_parse_result(
7256 EcosystemId::Cargo,
7257 content1.to_string(),
7258 parse_result1,
7259 );
7260 state.update_document(uri.clone(), doc_state1);
7261
7262 {
7263 let mut doc = state.documents.get_mut(&uri).unwrap();
7264 doc.replace_outcomes(
7265 DependencyOutcomes::new()
7266 .with_yanked("time", ("0.1.44".into(), RemovalStatus::Yanked))
7267 .with_deprecation(
7268 "time",
7269 Deprecation {
7270 reason: Some("archived".to_string()),
7271 replacement: None,
7272 },
7273 ),
7274 );
7275 }
7276
7277 let content2 = r#"[dependencies]
7281time = "0.1.43"
7282"#;
7283 let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
7284 let mut doc_state2 = DocumentState::new_from_parse_result(
7285 EcosystemId::Cargo,
7286 content2.to_string(),
7287 parse_result2,
7288 );
7289
7290 if let Some(old_doc) = state.get_document(&uri) {
7291 preserve_cache(&mut doc_state2, &old_doc);
7292 }
7293 doc_state2.outcomes.clear_yanked("time");
7294
7295 state.update_document(uri.clone(), doc_state2);
7296
7297 let doc = state.get_document(&uri).unwrap();
7298 assert!(
7299 doc.outcomes.yanked("time").is_none(),
7300 "the stale version-level yanked finding must be dropped"
7301 );
7302 assert_eq!(
7303 doc.outcomes.deprecation("time"),
7304 Some(&Deprecation {
7305 reason: Some("archived".to_string()),
7306 replacement: None,
7307 }),
7308 "the package-level deprecation finding must survive a version-only edit"
7309 );
7310 }
7311
7312 #[tokio::test]
7319 async fn test_fetch_failed_and_yanked_cleared_but_deprecation_survives_version_change() {
7320 let state = Arc::new(ServerState::new());
7321 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7322
7323 let content1 = r#"[dependencies]
7324time = "0.1.44"
7325"#;
7326 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7327 let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
7328 let doc_state1 = DocumentState::new_from_parse_result(
7329 EcosystemId::Cargo,
7330 content1.to_string(),
7331 parse_result1,
7332 );
7333 state.update_document(uri.clone(), doc_state1);
7334
7335 {
7336 let mut doc = state.documents.get_mut(&uri).unwrap();
7337 doc.replace_outcomes(
7338 DependencyOutcomes::new()
7339 .with_yanked("time", ("0.1.44".into(), RemovalStatus::Yanked))
7340 .with_fetch_failure("time", FetchFailure::Transient)
7341 .with_deprecation(
7342 "time",
7343 Deprecation {
7344 reason: Some("archived".to_string()),
7345 replacement: None,
7346 },
7347 ),
7348 );
7349 }
7350
7351 let content2 = r#"[dependencies]
7352time = "0.1.43"
7353"#;
7354 let old_deps = dependency_version_map(
7355 ecosystem
7356 .parse_manifest(content1, &uri)
7357 .await
7358 .unwrap()
7359 .as_ref(),
7360 );
7361 let new_deps = dependency_version_map(
7362 ecosystem
7363 .parse_manifest(content2, &uri)
7364 .await
7365 .unwrap()
7366 .as_ref(),
7367 );
7368 let diff = DependencyDiff::compute(&old_deps, &new_deps);
7369 assert_eq!(diff.version_changed, vec![PackageName::new("time")]);
7370
7371 let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
7372 let mut doc_state2 = DocumentState::new_from_parse_result(
7373 EcosystemId::Cargo,
7374 content2.to_string(),
7375 parse_result2,
7376 );
7377
7378 if let Some(old_doc) = state.get_document(&uri) {
7379 preserve_cache(&mut doc_state2, &old_doc);
7380 }
7381
7382 let formatter = ecosystem.formatter();
7383 for changed_dep in &diff.version_changed {
7384 let normalized = formatter.normalize_package_name(changed_dep);
7385 doc_state2.outcomes.clear_yanked(&normalized);
7386 doc_state2.outcomes.clear_fetch_failure(&normalized);
7387 }
7388
7389 state.update_document(uri.clone(), doc_state2);
7390
7391 let doc = state.get_document(&uri).unwrap();
7392 assert!(
7393 doc.outcomes.yanked("time").is_none(),
7394 "the stale version-level yanked finding must be dropped"
7395 );
7396 assert!(
7397 doc.outcomes.fetch_failure("time").is_none(),
7398 "the stale fetch-failure finding must be dropped"
7399 );
7400 assert_eq!(
7401 doc.outcomes.deprecation("time"),
7402 Some(&Deprecation {
7403 reason: Some("archived".to_string()),
7404 replacement: None,
7405 }),
7406 "the package-level deprecation finding must survive a version-only edit"
7407 );
7408 }
7409
7410 #[test]
7413 fn test_merge_deprecations_after_fetch_retains_finding_for_name_not_refetched() {
7414 let state = ServerState::new();
7415 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7416 let formatter = ecosystem.formatter();
7417 let mut doc =
7418 DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
7419 doc.outcomes.set_deprecation(
7420 "vendor/a".to_string(),
7421 Deprecation {
7422 reason: None,
7423 replacement: Some("vendor/a2".to_string()),
7424 },
7425 );
7426
7427 let mut fetched = HashMap::new();
7430 fetched.insert(
7431 PackageName::new("vendor/b"),
7432 Deprecation {
7433 reason: Some("abandoned".to_string()),
7434 replacement: None,
7435 },
7436 );
7437 merge_deprecations_after_fetch(
7438 &mut doc,
7439 &[PackageName::new("vendor/b")],
7440 fetched,
7441 formatter,
7442 );
7443
7444 assert_eq!(
7445 doc.outcomes.deprecation("vendor/a"),
7446 Some(&Deprecation {
7447 reason: None,
7448 replacement: Some("vendor/a2".to_string()),
7449 }),
7450 "a finding for a name not in this round's fetch must survive untouched"
7451 );
7452 assert_eq!(
7453 doc.outcomes.deprecation("vendor/b"),
7454 Some(&Deprecation {
7455 reason: Some("abandoned".to_string()),
7456 replacement: None,
7457 })
7458 );
7459 }
7460
7461 #[test]
7465 fn test_merge_deprecations_after_fetch_clears_finding_when_refetched_clean() {
7466 let state = ServerState::new();
7467 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7468 let formatter = ecosystem.formatter();
7469 let mut doc =
7470 DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
7471 doc.outcomes.set_deprecation(
7472 "vendor/a".to_string(),
7473 Deprecation {
7474 reason: None,
7475 replacement: None,
7476 },
7477 );
7478
7479 merge_deprecations_after_fetch(
7481 &mut doc,
7482 &[PackageName::new("vendor/a")],
7483 HashMap::new(),
7484 formatter,
7485 );
7486
7487 assert!(
7488 doc.outcomes.deprecation("vendor/a").is_none(),
7489 "a name that was fetched and produced no finding must be cleared"
7490 );
7491 }
7492
7493 #[test]
7498 fn test_merge_deprecations_after_fetch_is_order_independent_across_normalization_collision()
7499 {
7500 let state = ServerState::new();
7501 let ecosystem = state.ecosystem_registry.get("composer").unwrap();
7502 let formatter = ecosystem.formatter();
7503
7504 for names in [
7505 [
7506 PackageName::new("vendor/package"),
7507 PackageName::new("Vendor/Package"),
7508 ],
7509 [
7510 PackageName::new("Vendor/Package"),
7511 PackageName::new("vendor/package"),
7512 ],
7513 ] {
7514 let mut doc =
7515 DocumentState::new_without_parse_result(EcosystemId::Composer, String::new());
7516
7517 let mut fetched = HashMap::new();
7518 fetched.insert(
7519 PackageName::new("Vendor/Package"),
7520 Deprecation {
7521 reason: None,
7522 replacement: Some("vendor/other".to_string()),
7523 },
7524 );
7525 merge_deprecations_after_fetch(&mut doc, &names, fetched, formatter);
7528
7529 assert_eq!(
7530 doc.outcomes.deprecation("vendor/package"),
7531 Some(&Deprecation {
7532 reason: None,
7533 replacement: Some("vendor/other".to_string()),
7534 }),
7535 "a finding under either raw name sharing a normalized key must survive, \
7536 regardless of fetch order: {names:?}"
7537 );
7538 }
7539 }
7540
7541 #[test]
7548 fn test_merge_no_comparable_versions_after_fetch_sets_finding_for_newly_flagged_name() {
7549 let state = ServerState::new();
7550 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7551 let formatter = ecosystem.formatter();
7552 let mut doc =
7553 DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
7554
7555 let mut fetched = HashSet::new();
7559 fetched.insert(PackageName::new("vendor/b"));
7560 merge_no_comparable_versions_after_fetch(
7561 &mut doc,
7562 &[PackageName::new("vendor/b")],
7563 fetched,
7564 formatter,
7565 );
7566
7567 assert!(
7568 doc.outcomes.no_comparable_versions("vendor/b"),
7569 "a package whose fetch was attempted and found zero comparable versions \
7570 this round must be recorded"
7571 );
7572 assert!(
7573 !doc.outcomes.no_comparable_versions("vendor/a"),
7574 "a package never attempted this round must not be flagged"
7575 );
7576 }
7577
7578 #[test]
7583 fn test_merge_no_comparable_versions_after_fetch_clears_finding_when_refetched_with_versions()
7584 {
7585 let state = ServerState::new();
7586 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7587 let formatter = ecosystem.formatter();
7588 let mut doc =
7589 DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
7590 doc.outcomes
7591 .set_no_comparable_versions("vendor/a".to_string());
7592
7593 merge_no_comparable_versions_after_fetch(
7596 &mut doc,
7597 &[PackageName::new("vendor/a")],
7598 HashSet::new(),
7599 formatter,
7600 );
7601
7602 assert!(
7603 !doc.outcomes.no_comparable_versions("vendor/a"),
7604 "a package that was attempted and this time resolved a real version must \
7605 have its stale marker cleared, or R5e would keep suppressing Unknown \
7606 package diagnostics for a name that could now legitimately need one"
7607 );
7608 }
7609
7610 #[test]
7614 fn test_merge_no_comparable_versions_after_fetch_retains_finding_for_name_not_attempted() {
7615 let state = ServerState::new();
7616 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7617 let formatter = ecosystem.formatter();
7618 let mut doc =
7619 DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
7620 doc.outcomes
7621 .set_no_comparable_versions("vendor/a".to_string());
7622
7623 merge_no_comparable_versions_after_fetch(
7625 &mut doc,
7626 &[PackageName::new("vendor/b")],
7627 HashSet::new(),
7628 formatter,
7629 );
7630
7631 assert!(
7632 doc.outcomes.no_comparable_versions("vendor/a"),
7633 "a finding for a name not attempted this round must survive untouched"
7634 );
7635 }
7636
7637 #[tokio::test]
7638 async fn test_yanked_versions_pruned_on_dependency_removal_by_normalized_name() {
7639 let state = Arc::new(ServerState::new());
7640 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7641
7642 let content1 = r#"[dependencies]
7643serde = "1.0"
7644time = "0.1.43"
7645"#;
7646 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7647 let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
7648 let doc_state1 = DocumentState::new_from_parse_result(
7649 EcosystemId::Cargo,
7650 content1.to_string(),
7651 parse_result1,
7652 );
7653 state.update_document(uri.clone(), doc_state1);
7654
7655 {
7656 let mut doc = state.documents.get_mut(&uri).unwrap();
7657 doc.replace_outcomes(DependencyOutcomes::new().with_yanked(
7658 "time",
7659 (ConcreteVersion::new("0.1.43"), RemovalStatus::Yanked),
7660 ));
7661 }
7662
7663 let content2 = r#"[dependencies]
7664serde = "1.0"
7665"#;
7666 let old_deps: HashMap<PackageName, Vec<Option<VersionReq>>> =
7667 [("serde", None), ("time", None)]
7668 .into_iter()
7669 .map(|(n, r)| (PackageName::new(n), vec![r]))
7670 .collect();
7671 let new_deps: HashMap<PackageName, Vec<Option<VersionReq>>> =
7672 std::iter::once((PackageName::new("serde"), vec![None])).collect();
7673 let diff = DependencyDiff::compute(&old_deps, &new_deps);
7674 assert_eq!(diff.removed, vec![PackageName::new("time")]);
7675
7676 let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
7677 let mut doc_state2 = DocumentState::new_from_parse_result(
7678 EcosystemId::Cargo,
7679 content2.to_string(),
7680 parse_result2,
7681 );
7682
7683 if let Some(old_doc) = state.get_document(&uri) {
7684 preserve_cache(&mut doc_state2, &old_doc);
7685 }
7686
7687 let formatter = ecosystem.formatter();
7688 for removed_dep in &diff.removed {
7689 doc_state2
7690 .outcomes
7691 .remove(&formatter.normalize_package_name(removed_dep));
7692 }
7693
7694 state.update_document(uri.clone(), doc_state2);
7695
7696 let doc = state.get_document(&uri).unwrap();
7697 assert!(
7698 doc.outcomes.yanked("time").is_none(),
7699 "removed dependency's yanked entry must be pruned"
7700 );
7701 }
7702
7703 #[tokio::test]
7704 async fn test_yanked_versions_pruned_on_version_change_by_normalized_name() {
7705 let state = Arc::new(ServerState::new());
7713 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7714
7715 let content1 = r#"[dependencies]
7716time = "=0.1.43"
7717"#;
7718 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7719 let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
7720 let doc_state1 = DocumentState::new_from_parse_result(
7721 EcosystemId::Cargo,
7722 content1.to_string(),
7723 parse_result1,
7724 );
7725 state.update_document(uri.clone(), doc_state1);
7726
7727 {
7729 let mut doc = state.documents.get_mut(&uri).unwrap();
7730 doc.replace_outcomes(DependencyOutcomes::new().with_yanked(
7731 "time",
7732 (ConcreteVersion::new("0.1.43"), RemovalStatus::Yanked),
7733 ));
7734 }
7735
7736 let content2 = r#"[dependencies]
7738time = "=0.1.44"
7739"#;
7740 let old_deps = dependency_version_map(
7741 ecosystem
7742 .parse_manifest(content1, &uri)
7743 .await
7744 .unwrap()
7745 .as_ref(),
7746 );
7747 let new_deps = dependency_version_map(
7748 ecosystem
7749 .parse_manifest(content2, &uri)
7750 .await
7751 .unwrap()
7752 .as_ref(),
7753 );
7754 let diff = DependencyDiff::compute(&old_deps, &new_deps);
7755 assert!(diff.added.is_empty());
7756 assert!(diff.removed.is_empty());
7757 assert_eq!(diff.version_changed, vec![PackageName::new("time")]);
7758
7759 let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
7760 let mut doc_state2 = DocumentState::new_from_parse_result(
7761 EcosystemId::Cargo,
7762 content2.to_string(),
7763 parse_result2,
7764 );
7765
7766 if let Some(old_doc) = state.get_document(&uri) {
7767 preserve_cache(&mut doc_state2, &old_doc);
7768 }
7769
7770 let formatter = ecosystem.formatter();
7771 for changed_dep in &diff.version_changed {
7772 doc_state2
7773 .outcomes
7774 .clear_yanked(&formatter.normalize_package_name(changed_dep));
7775 }
7776
7777 state.update_document(uri.clone(), doc_state2);
7778
7779 let doc = state.get_document(&uri).unwrap();
7780 assert!(
7781 doc.outcomes.yanked("time").is_none(),
7782 "stale yanked entry against the OLD version must not survive an in-place edit"
7783 );
7784 }
7785
7786 #[tokio::test]
7787 async fn test_fetch_failed_pruned_on_dependency_removal_by_normalized_name() {
7788 let state = Arc::new(ServerState::new());
7792 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7793
7794 let content1 = r#"[dependencies]
7795serde = "1.0"
7796time = "0.1.43"
7797"#;
7798 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7799 let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
7800 let doc_state1 = DocumentState::new_from_parse_result(
7801 EcosystemId::Cargo,
7802 content1.to_string(),
7803 parse_result1,
7804 );
7805 state.update_document(uri.clone(), doc_state1);
7806
7807 {
7808 let mut doc = state.documents.get_mut(&uri).unwrap();
7809 doc.replace_outcomes(
7810 DependencyOutcomes::new().with_fetch_failure("time", FetchFailure::Transient),
7811 );
7812 }
7813
7814 let content2 = r#"[dependencies]
7815serde = "1.0"
7816"#;
7817 let old_deps: HashMap<PackageName, Vec<Option<VersionReq>>> =
7818 [("serde", None), ("time", None)]
7819 .into_iter()
7820 .map(|(n, r)| (PackageName::new(n), vec![r]))
7821 .collect();
7822 let new_deps: HashMap<PackageName, Vec<Option<VersionReq>>> =
7823 std::iter::once((PackageName::new("serde"), vec![None])).collect();
7824 let diff = DependencyDiff::compute(&old_deps, &new_deps);
7825 assert_eq!(diff.removed, vec![PackageName::new("time")]);
7826
7827 let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
7828 let mut doc_state2 = DocumentState::new_from_parse_result(
7829 EcosystemId::Cargo,
7830 content2.to_string(),
7831 parse_result2,
7832 );
7833
7834 if let Some(old_doc) = state.get_document(&uri) {
7835 preserve_cache(&mut doc_state2, &old_doc);
7836 }
7837
7838 assert!(
7842 doc_state2.outcomes.fetch_failure("time").is_some(),
7843 "preserve_cache must carry fetch_failed across an edit"
7844 );
7845
7846 let formatter = ecosystem.formatter();
7847 for removed_dep in &diff.removed {
7848 doc_state2
7849 .outcomes
7850 .remove(&formatter.normalize_package_name(removed_dep));
7851 }
7852
7853 state.update_document(uri.clone(), doc_state2);
7854
7855 let doc = state.get_document(&uri).unwrap();
7856 assert!(
7857 doc.outcomes.fetch_failure("time").is_none(),
7858 "removed dependency's fetch_failed entry must be pruned"
7859 );
7860 }
7861
7862 #[test]
7863 fn test_deps_to_fetch_includes_version_changed_dependencies() {
7864 let old = versions(&[("time", Some("=0.1.44"))]);
7871 let new = versions(&[("time", Some("=0.1.43"))]);
7872
7873 let diff = DependencyDiff::compute(&old, &new);
7874 assert!(diff.added.is_empty());
7875 assert_eq!(diff.version_changed, vec![PackageName::new("time")]);
7876 assert!(
7877 diff.needs_fetch(),
7878 "a version-only edit must trigger the registry fetch"
7879 );
7880
7881 let mut deps_to_fetch = diff.added;
7884 deps_to_fetch.extend(diff.version_changed);
7885 assert_eq!(
7886 deps_to_fetch,
7887 vec![PackageName::new("time")],
7888 "the version-changed dependency must be included in the fetch list"
7889 );
7890 }
7891
7892 #[tokio::test]
7893 async fn test_preserve_cache_yanked_versions_stale_after_lockfile_only_change() {
7894 let state = Arc::new(ServerState::new());
7902 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7903
7904 let content = r#"[dependencies]
7905time = "0.1.43"
7906"#;
7907 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7908 let parse_result1 = ecosystem.parse_manifest(content, &uri).await.unwrap();
7909 let doc_state1 = DocumentState::new_from_parse_result(
7910 EcosystemId::Cargo,
7911 content.to_string(),
7912 parse_result1,
7913 );
7914 state.update_document(uri.clone(), doc_state1);
7915
7916 {
7918 let mut doc = state.documents.get_mut(&uri).unwrap();
7919 doc.replace_outcomes(DependencyOutcomes::new().with_yanked(
7920 "time",
7921 (ConcreteVersion::new("0.1.43"), RemovalStatus::Yanked),
7922 ));
7923 }
7924
7925 let parse_result2 = ecosystem.parse_manifest(content, &uri).await.unwrap();
7931 let mut doc_state2 = DocumentState::new_from_parse_result(
7932 EcosystemId::Cargo,
7933 content.to_string(),
7934 parse_result2,
7935 );
7936 if let Some(old_doc) = state.get_document(&uri) {
7937 preserve_cache(&mut doc_state2, &old_doc);
7938 }
7939 state.update_document(uri.clone(), doc_state2);
7940
7941 let doc = state.get_document(&uri).unwrap();
7945 assert_eq!(
7946 doc.outcomes.yanked("time"),
7947 Some(&(ConcreteVersion::new("0.1.43"), RemovalStatus::Yanked))
7948 );
7949 }
7950
7951 #[tokio::test]
7952 async fn test_first_open_has_empty_cache() {
7953 let state = Arc::new(ServerState::new());
7954 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7955
7956 let content = r#"[dependencies]
7957serde = "1.0"
7958"#;
7959
7960 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7961 let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
7962 let doc_state = DocumentState::new_from_parse_result(
7963 EcosystemId::Cargo,
7964 content.to_string(),
7965 parse_result,
7966 );
7967 state.update_document(uri.clone(), doc_state);
7968
7969 let doc = state.get_document(&uri).unwrap();
7971 assert_eq!(
7972 doc.cached_versions.len(),
7973 0,
7974 "First open should have empty cache"
7975 );
7976 }
7977
7978 #[tokio::test]
7979 async fn test_preserve_cache_on_parse_failure() {
7980 let state = Arc::new(ServerState::new());
7981 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
7982
7983 let content1 = r#"[dependencies]
7985serde = "1.0"
7986"#;
7987
7988 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
7989 let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
7990 let doc_state1 = DocumentState::new_from_parse_result(
7991 EcosystemId::Cargo,
7992 content1.to_string(),
7993 parse_result1,
7994 );
7995 state.update_document(uri.clone(), doc_state1);
7996
7997 {
7999 let mut doc = state.documents.get_mut(&uri).unwrap();
8000 doc.cached_versions
8001 .insert("serde".into(), PackageVersions::latest_only("1.0.210"));
8002 }
8003
8004 let content2 = r#"[dependencies
8006serde = "1.0"
8007"#;
8008
8009 let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.ok();
8010 assert!(
8011 parse_result2.is_none(),
8012 "Parse should fail for invalid TOML"
8013 );
8014
8015 let mut doc_state2 =
8016 DocumentState::new_without_parse_result(EcosystemId::Cargo, content2.to_string());
8017
8018 if let Some(old_doc) = state.get_document(&uri) {
8019 preserve_cache(&mut doc_state2, &old_doc);
8020 }
8021
8022 state.update_document(uri.clone(), doc_state2);
8023
8024 let doc = state.get_document(&uri).unwrap();
8026 assert_eq!(
8027 doc.cached_versions.len(),
8028 1,
8029 "Cache should be preserved on parse failure"
8030 );
8031 assert_eq!(
8032 doc.cached_versions.get("serde").map(|v| v.latest.as_str()),
8033 Some("1.0.210")
8034 );
8035 }
8036
8037 fn versions(
8038 pairs: &[(&str, Option<&str>)],
8039 ) -> HashMap<PackageName, Vec<Option<VersionReq>>> {
8040 pairs
8041 .iter()
8042 .map(|(name, req)| (PackageName::new(*name), vec![req.map(VersionReq::new)]))
8043 .collect()
8044 }
8045
8046 #[test]
8047 fn test_dependency_diff_detects_additions() {
8048 let old = versions(&[("serde", Some("1.0")), ("tokio", Some("1.0"))]);
8049 let new = versions(&[
8050 ("serde", Some("1.0")),
8051 ("tokio", Some("1.0")),
8052 ("anyhow", Some("1.0")),
8053 ]);
8054
8055 let diff = DependencyDiff::compute(&old, &new);
8056
8057 assert_eq!(diff.added.len(), 1);
8058 assert!(diff.added.contains(&PackageName::new("anyhow")));
8059 assert!(diff.removed.is_empty());
8060 assert!(diff.needs_fetch());
8061 assert!(diff.needs_osv_rescan());
8062 }
8063
8064 #[test]
8065 fn test_dependency_diff_detects_removals() {
8066 let old = versions(&[
8067 ("serde", Some("1.0")),
8068 ("tokio", Some("1.0")),
8069 ("anyhow", Some("1.0")),
8070 ]);
8071 let new = versions(&[("serde", Some("1.0")), ("tokio", Some("1.0"))]);
8072
8073 let diff = DependencyDiff::compute(&old, &new);
8074
8075 assert!(diff.added.is_empty());
8076 assert_eq!(diff.removed.len(), 1);
8077 assert!(diff.removed.contains(&PackageName::new("anyhow")));
8078 assert!(!diff.needs_fetch());
8079 assert!(!diff.needs_osv_rescan());
8080 }
8081
8082 #[test]
8083 fn test_dependency_diff_no_changes() {
8084 let old = versions(&[("serde", Some("1.0")), ("tokio", Some("1.0"))]);
8085 let new = versions(&[("serde", Some("1.0")), ("tokio", Some("1.0"))]);
8086
8087 let diff = DependencyDiff::compute(&old, &new);
8088
8089 assert!(diff.added.is_empty());
8090 assert!(diff.removed.is_empty());
8091 assert!(diff.version_changed.is_empty());
8092 assert!(!diff.needs_fetch());
8093 assert!(!diff.needs_osv_rescan());
8094 }
8095
8096 #[test]
8097 fn test_dependency_diff_empty_to_new() {
8098 let old: HashMap<PackageName, Vec<Option<VersionReq>>> = HashMap::new();
8099 let new = versions(&[("serde", Some("1.0")), ("tokio", Some("1.0"))]);
8100
8101 let diff = DependencyDiff::compute(&old, &new);
8102
8103 assert_eq!(diff.added.len(), 2);
8104 assert!(diff.removed.is_empty());
8105 assert!(diff.needs_fetch());
8106 }
8107
8108 #[test]
8109 fn test_dependency_diff_detects_version_change_without_name_set_change() {
8110 let old = versions(&[("time", Some("0.1.43"))]);
8113 let new = versions(&[("time", Some("0.1.44"))]);
8114
8115 let diff = DependencyDiff::compute(&old, &new);
8116
8117 assert!(diff.added.is_empty());
8118 assert!(diff.removed.is_empty());
8119 assert_eq!(diff.version_changed, vec![PackageName::new("time")]);
8120 assert!(
8121 diff.needs_fetch(),
8122 "a version-only edit must still trigger the registry fetch, \
8123 so the yanked probe re-runs against the new version"
8124 );
8125 assert!(
8126 diff.needs_osv_rescan(),
8127 "a version-only edit must still trigger an OSV rescan"
8128 );
8129 }
8130
8131 #[tokio::test]
8132 async fn test_dependency_version_map_tracks_both_occurrences_of_duplicate_name() {
8133 let state = Arc::new(ServerState::new());
8140 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
8141 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
8142
8143 let content = r#"[dependencies]
8144time = "0.1.43"
8145
8146[dev-dependencies]
8147time = "0.1.44"
8148"#;
8149 let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap();
8150 assert_eq!(
8151 parse_result.dependencies().len(),
8152 2,
8153 "both `[dependencies]` and `[dev-dependencies]` occurrences of `time` must parse"
8154 );
8155
8156 let deps = dependency_version_map(parse_result.as_ref());
8157 let time_reqs = deps
8158 .get(&PackageName::new("time"))
8159 .expect("duplicated name must still be present in the map");
8160 assert_eq!(
8161 time_reqs,
8162 &vec![
8163 Some(VersionReq::new("0.1.43")),
8164 Some(VersionReq::new("0.1.44")),
8165 ],
8166 "both occurrences' version requirements must be tracked, not just the last one"
8167 );
8168 }
8169
8170 #[tokio::test]
8171 async fn test_dependency_diff_detects_edit_to_first_occurrence_of_duplicate_name() {
8172 let state = Arc::new(ServerState::new());
8180 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
8181 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
8182
8183 let content1 = r#"[dependencies]
8184time = "0.1.43"
8185
8186[dev-dependencies]
8187time = "0.1.44"
8188"#;
8189 let old_deps = dependency_version_map(
8190 ecosystem
8191 .parse_manifest(content1, &uri)
8192 .await
8193 .unwrap()
8194 .as_ref(),
8195 );
8196
8197 let content2 = r#"[dependencies]
8198time = "0.1.50"
8199
8200[dev-dependencies]
8201time = "0.1.44"
8202"#;
8203 let new_deps = dependency_version_map(
8204 ecosystem
8205 .parse_manifest(content2, &uri)
8206 .await
8207 .unwrap()
8208 .as_ref(),
8209 );
8210
8211 let diff = DependencyDiff::compute(&old_deps, &new_deps);
8212 assert!(diff.added.is_empty());
8213 assert!(diff.removed.is_empty());
8214 assert_eq!(
8215 diff.version_changed,
8216 vec![PackageName::new("time")],
8217 "editing the losing (first) occurrence of a duplicated name must be detected"
8218 );
8219 assert!(diff.needs_fetch());
8220 assert!(diff.needs_osv_rescan());
8221 }
8222
8223 #[tokio::test]
8224 async fn test_dependency_diff_detects_edit_to_second_occurrence_of_duplicate_name() {
8225 let state = Arc::new(ServerState::new());
8230 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
8231 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
8232
8233 let content1 = r#"[dependencies]
8234time = "0.1.43"
8235
8236[dev-dependencies]
8237time = "0.1.44"
8238"#;
8239 let old_deps = dependency_version_map(
8240 ecosystem
8241 .parse_manifest(content1, &uri)
8242 .await
8243 .unwrap()
8244 .as_ref(),
8245 );
8246
8247 let content2 = r#"[dependencies]
8248time = "0.1.43"
8249
8250[dev-dependencies]
8251time = "0.1.60"
8252"#;
8253 let new_deps = dependency_version_map(
8254 ecosystem
8255 .parse_manifest(content2, &uri)
8256 .await
8257 .unwrap()
8258 .as_ref(),
8259 );
8260
8261 let diff = DependencyDiff::compute(&old_deps, &new_deps);
8262 assert!(diff.added.is_empty());
8263 assert!(diff.removed.is_empty());
8264 assert_eq!(
8265 diff.version_changed,
8266 vec![PackageName::new("time")],
8267 "editing the winning (second) occurrence of a duplicated name must be detected"
8268 );
8269 assert!(diff.needs_fetch());
8270 assert!(diff.needs_osv_rescan());
8271 }
8272
8273 #[tokio::test]
8274 async fn test_dependency_diff_detects_edit_to_duplicate_name_across_target_blocks() {
8275 let state = Arc::new(ServerState::new());
8285 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
8286 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
8287
8288 let content1 = r#"[target.'cfg(windows)'.dependencies]
8289time = "0.1.44"
8290
8291[target.'cfg(unix)'.dependencies]
8292time = "0.1.43"
8293"#;
8294 let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
8295 assert_eq!(
8296 parse_result1.dependencies().len(),
8297 2,
8298 "both target-block occurrences of `time` must parse"
8299 );
8300 let old_deps = dependency_version_map(parse_result1.as_ref());
8301 assert_eq!(
8302 old_deps.get(&PackageName::new("time")).map(Vec::len),
8303 Some(2),
8304 "duplicate-name occurrences under different target blocks must be tracked \
8305 per-occurrence, not collapsed to one entry"
8306 );
8307
8308 let content2 = r#"[target.'cfg(windows)'.dependencies]
8310time = "0.1.44"
8311
8312[target.'cfg(unix)'.dependencies]
8313time = "0.1.50"
8314"#;
8315 let new_deps = dependency_version_map(
8316 ecosystem
8317 .parse_manifest(content2, &uri)
8318 .await
8319 .unwrap()
8320 .as_ref(),
8321 );
8322
8323 let diff = DependencyDiff::compute(&old_deps, &new_deps);
8324 assert!(diff.added.is_empty());
8325 assert!(diff.removed.is_empty());
8326 assert_eq!(
8327 diff.version_changed,
8328 vec![PackageName::new("time")],
8329 "editing one target-block occurrence of a duplicated name must still \
8330 produce a non-empty diff, even though the other occurrence's \
8331 requirement (\"0.1.44\") is unchanged"
8332 );
8333 assert!(diff.needs_fetch());
8334 assert!(diff.needs_osv_rescan());
8335 }
8336
8337 #[tokio::test]
8338 async fn test_cache_pruned_on_dependency_removal() {
8339 let state = Arc::new(ServerState::new());
8340 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
8341
8342 let content1 = r#"[dependencies]
8344serde = "1.0"
8345tokio = "1.0"
8346anyhow = "1.0"
8347"#;
8348
8349 let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
8350 let parse_result1 = ecosystem.parse_manifest(content1, &uri).await.unwrap();
8351 let doc_state1 = DocumentState::new_from_parse_result(
8352 EcosystemId::Cargo,
8353 content1.to_string(),
8354 parse_result1,
8355 );
8356 state.update_document(uri.clone(), doc_state1);
8357
8358 {
8360 let mut doc = state.documents.get_mut(&uri).unwrap();
8361 doc.cached_versions.insert(
8362 PackageName::new("serde"),
8363 PackageVersions::latest_only("1.0.210"),
8364 );
8365 doc.cached_versions.insert(
8366 PackageName::new("tokio"),
8367 PackageVersions::latest_only("1.40.0"),
8368 );
8369 doc.cached_versions.insert(
8370 PackageName::new("anyhow"),
8371 PackageVersions::latest_only("1.0.89"),
8372 );
8373 }
8374
8375 let content2 = r#"[dependencies]
8377serde = "1.0"
8378tokio = "1.0"
8379"#;
8380
8381 let old_deps: HashMap<PackageName, Vec<Option<VersionReq>>> =
8383 ["serde", "tokio", "anyhow"]
8384 .iter()
8385 .map(|s| (PackageName::new(*s), vec![None]))
8386 .collect();
8387 let new_deps: HashMap<PackageName, Vec<Option<VersionReq>>> = ["serde", "tokio"]
8388 .iter()
8389 .map(|s| (PackageName::new(*s), vec![None]))
8390 .collect();
8391 let diff = DependencyDiff::compute(&old_deps, &new_deps);
8392
8393 let parse_result2 = ecosystem.parse_manifest(content2, &uri).await.unwrap();
8394 let mut doc_state2 = DocumentState::new_from_parse_result(
8395 EcosystemId::Cargo,
8396 content2.to_string(),
8397 parse_result2,
8398 );
8399
8400 if let Some(old_doc) = state.get_document(&uri) {
8401 preserve_cache(&mut doc_state2, &old_doc);
8402 }
8403
8404 for removed_dep in &diff.removed {
8406 doc_state2.cached_versions.remove(removed_dep);
8407 }
8408
8409 state.update_document(uri.clone(), doc_state2);
8410
8411 let doc = state.get_document(&uri).unwrap();
8413 assert_eq!(
8414 doc.cached_versions.len(),
8415 2,
8416 "anyhow should be removed from cache"
8417 );
8418 assert!(doc.cached_versions.contains_key("serde"));
8419 assert!(doc.cached_versions.contains_key("tokio"));
8420 assert!(!doc.cached_versions.contains_key("anyhow"));
8421 }
8422 }
8423
8424 mod osv_scan_target_tests {
8425 use super::*;
8426 use deps_core::Dependency;
8427 use deps_core::lsp_helpers::{
8428 DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
8429 RequirementResolution, SourcePolicy,
8430 };
8431 use deps_core::parser::DependencySource;
8432 use std::any::Any;
8433 use tower_lsp_server::ls_types::{Position, Range};
8434
8435 struct MockFormatter;
8436 impl PackageNaming for MockFormatter {}
8437
8438 impl PackageRendering for MockFormatter {
8439 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
8440 version.to_string()
8441 }
8442
8443 fn package_url(&self, name: &PackageName) -> String {
8444 format!("https://example.com/{name}")
8445 }
8446 }
8447
8448 impl RequirementResolution for MockFormatter {}
8449
8450 impl DiagnosticMessages for MockFormatter {}
8451
8452 impl DiagnosticPolicy for MockFormatter {}
8453
8454 impl SourcePolicy for MockFormatter {}
8455
8456 impl OsvNaming for MockFormatter {}
8457
8458 struct MockDep {
8459 name: PackageName,
8460 version_req: Option<VersionReq>,
8461 source: DependencySource,
8462 }
8463
8464 impl Dependency for MockDep {
8465 fn name(&self) -> &PackageName {
8466 &self.name
8467 }
8468 fn name_range(&self) -> Range {
8469 let addr = std::ptr::from_ref(self) as u32;
8476 Range::new(Position::new(0, addr), Position::new(0, addr + 1))
8477 }
8478 fn version_requirement(&self) -> Option<&VersionReq> {
8479 self.version_req.as_ref()
8480 }
8481 fn version_range(&self) -> Option<Range> {
8482 None
8483 }
8484 fn source(&self) -> DependencySource {
8485 self.source.clone()
8486 }
8487 fn as_any(&self) -> &dyn Any {
8488 self
8489 }
8490 }
8491
8492 struct MockParseResult {
8493 deps: Vec<MockDep>,
8494 }
8495
8496 impl deps_core::ParseResult for MockParseResult {
8497 fn dependencies(&self) -> Vec<&dyn Dependency> {
8498 self.deps.iter().map(|d| d as &dyn Dependency).collect()
8499 }
8500 fn workspace_root(&self) -> Option<&std::path::Path> {
8501 None
8502 }
8503 fn uri(&self) -> &Uri {
8504 static URI: std::sync::OnceLock<Uri> = std::sync::OnceLock::new();
8505 URI.get_or_init(|| deps_core::test_util::test_uri("/test/Cargo.toml"))
8506 }
8507 fn as_any(&self) -> &dyn Any {
8508 self
8509 }
8510 }
8511
8512 use deps_core::osv::{ScanOutcome, SkipReason};
8513
8514 #[test]
8519 fn build_scan_targets_step0_skips_non_registry_source_even_with_lockfile_version() {
8520 let parse_result = MockParseResult {
8524 deps: vec![MockDep {
8525 name: PackageName::new("time"),
8526 version_req: Some(VersionReq::new("0.1.43")),
8527 source: DependencySource::Git {
8528 url: "https://github.com/example/time".to_string(),
8529 rev: None,
8530 },
8531 }],
8532 };
8533 let mut resolved = HashMap::new();
8534 resolved.insert(PackageName::new("time"), "0.1.43".into());
8535
8536 let (targets, skipped) =
8537 build_scan_targets(&parse_result, &resolved, &MockFormatter, EcosystemId::Cargo);
8538 assert!(targets.is_empty());
8539 assert_matches!(
8540 skipped.get("time"),
8541 Some(ScanOutcome::Skipped(SkipReason::NonRegistrySource))
8542 );
8543 }
8544
8545 #[test]
8546 fn build_scan_targets_step1_prefers_lockfile_resolved_version() {
8547 let parse_result = MockParseResult {
8548 deps: vec![MockDep {
8549 name: PackageName::new("serde"),
8550 version_req: Some(VersionReq::new("^1.0")),
8551 source: DependencySource::Registry,
8552 }],
8553 };
8554 let mut resolved = HashMap::new();
8555 resolved.insert(PackageName::new("serde"), "1.0.195".into());
8556
8557 let (targets, skipped) =
8558 build_scan_targets(&parse_result, &resolved, &MockFormatter, EcosystemId::Cargo);
8559 assert_eq!(targets.len(), 1);
8560 assert_eq!(targets[0].version, "1.0.195");
8561 assert!(skipped.is_empty());
8562 }
8563
8564 struct MockGoFormatter;
8568 impl PackageNaming for MockGoFormatter {}
8569
8570 impl PackageRendering for MockGoFormatter {
8571 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
8572 version.to_string()
8573 }
8574
8575 fn package_url(&self, name: &PackageName) -> String {
8576 format!("https://pkg.go.dev/{name}")
8577 }
8578 }
8579
8580 impl RequirementResolution for MockGoFormatter {
8581 fn manifest_requirement_is_resolved_version(&self, _dep: &dyn Dependency) -> bool {
8582 true
8583 }
8584 }
8585
8586 impl DiagnosticMessages for MockGoFormatter {}
8587
8588 impl DiagnosticPolicy for MockGoFormatter {}
8589
8590 impl SourcePolicy for MockGoFormatter {}
8591
8592 impl OsvNaming for MockGoFormatter {}
8593
8594 struct MockVPrefixFormatter;
8595 impl PackageNaming for MockVPrefixFormatter {}
8596
8597 impl PackageRendering for MockVPrefixFormatter {
8598 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
8599 version.to_string()
8600 }
8601
8602 fn package_url(&self, name: &PackageName) -> String {
8603 format!("https://example.com/{name}")
8604 }
8605 }
8606
8607 impl RequirementResolution for MockVPrefixFormatter {}
8608
8609 impl DiagnosticMessages for MockVPrefixFormatter {}
8610
8611 impl DiagnosticPolicy for MockVPrefixFormatter {}
8612
8613 impl SourcePolicy for MockVPrefixFormatter {}
8614
8615 impl OsvNaming for MockVPrefixFormatter {
8616 fn osv_version(&self, version: &str) -> String {
8617 version.strip_prefix('v').unwrap_or(version).to_string()
8618 }
8619 }
8620
8621 #[test]
8622 fn build_scan_targets_normalizes_version_via_formatter_osv_version_hook() {
8623 let parse_result = MockParseResult {
8628 deps: vec![MockDep {
8629 name: PackageName::new("github.com/gin-gonic/gin"),
8630 version_req: Some(VersionReq::new("v1.9.0")),
8631 source: DependencySource::Registry,
8632 }],
8633 };
8634 let mut resolved = HashMap::new();
8635 resolved.insert(
8636 PackageName::new("github.com/gin-gonic/gin"),
8637 "v1.9.0".into(),
8638 );
8639
8640 let (targets, skipped) = build_scan_targets(
8641 &parse_result,
8642 &resolved,
8643 &MockVPrefixFormatter,
8644 EcosystemId::Go,
8645 );
8646 assert_eq!(targets.len(), 1);
8647 assert_eq!(targets[0].version, "1.9.0");
8648 assert_eq!(targets[0].display_version, "v1.9.0");
8651 assert!(skipped.is_empty());
8652 }
8653
8654 #[test]
8655 fn build_scan_targets_leaves_version_unaffected_for_default_identity_formatter() {
8656 let parse_result = MockParseResult {
8660 deps: vec![MockDep {
8661 name: PackageName::new("serde"),
8662 version_req: Some(VersionReq::new("^1.0")),
8663 source: DependencySource::Registry,
8664 }],
8665 };
8666 let mut resolved = HashMap::new();
8667 resolved.insert(PackageName::new("serde"), "1.0.195".into());
8668
8669 let (targets, skipped) =
8670 build_scan_targets(&parse_result, &resolved, &MockFormatter, EcosystemId::Cargo);
8671 assert_eq!(targets.len(), 1);
8672 assert_eq!(targets[0].version, "1.0.195");
8673 assert_eq!(targets[0].display_version, "1.0.195");
8674 assert!(skipped.is_empty());
8675 }
8676
8677 #[test]
8678 fn build_scan_targets_go_ignores_stale_lockfile_version_uses_go_mod_requirement() {
8679 let parse_result = MockParseResult {
8689 deps: vec![MockDep {
8690 name: PackageName::new("github.com/pkg/errors"),
8691 version_req: Some(VersionReq::new("v0.8.1")),
8692 source: DependencySource::Registry,
8693 }],
8694 };
8695 let mut resolved = HashMap::new();
8696 resolved.insert(PackageName::new("github.com/pkg/errors"), "v0.9.1".into());
8700
8701 let (targets, skipped) =
8702 build_scan_targets(&parse_result, &resolved, &MockGoFormatter, EcosystemId::Go);
8703 assert_eq!(targets.len(), 1);
8704 assert_eq!(targets[0].version, "v0.8.1");
8705 assert_eq!(targets[0].display_version, "v0.8.1");
8706 assert!(skipped.is_empty());
8707 }
8708
8709 #[test]
8710 fn build_scan_targets_step2_uses_concrete_requirement_verbatim() {
8711 let parse_result = MockParseResult {
8712 deps: vec![MockDep {
8713 name: PackageName::new("log4j-core"),
8714 version_req: Some(VersionReq::new("2.14.1")),
8715 source: DependencySource::Registry,
8716 }],
8717 };
8718 let resolved = HashMap::new();
8719
8720 let (targets, skipped) =
8721 build_scan_targets(&parse_result, &resolved, &MockFormatter, EcosystemId::Maven);
8722 assert_eq!(targets.len(), 1);
8723 assert_eq!(targets[0].version, "2.14.1");
8724 assert!(skipped.is_empty());
8725 }
8726
8727 #[test]
8728 fn build_scan_targets_step2_strips_pin_marker_for_operator_prefixed_requirements() {
8729 let cargo_result = MockParseResult {
8736 deps: vec![MockDep {
8737 name: PackageName::new("time"),
8738 version_req: Some(VersionReq::new("=1.2.3")),
8739 source: DependencySource::Registry,
8740 }],
8741 };
8742 let (targets, skipped) = build_scan_targets(
8743 &cargo_result,
8744 &HashMap::new(),
8745 &MockFormatter,
8746 EcosystemId::Cargo,
8747 );
8748 assert_eq!(targets.len(), 1);
8749 assert_eq!(targets[0].version, "1.2.3");
8750 assert!(skipped.is_empty());
8751
8752 let nuget_result = MockParseResult {
8753 deps: vec![MockDep {
8754 name: PackageName::new("Newtonsoft.Json"),
8755 version_req: Some(VersionReq::new("[1.0.0]")),
8756 source: DependencySource::Registry,
8757 }],
8758 };
8759 let (targets, skipped) = build_scan_targets(
8760 &nuget_result,
8761 &HashMap::new(),
8762 &MockFormatter,
8763 EcosystemId::NuGet,
8764 );
8765 assert_eq!(targets.len(), 1);
8766 assert_eq!(targets[0].version, "1.0.0");
8767 assert!(skipped.is_empty());
8768 }
8769
8770 #[test]
8771 fn build_scan_targets_step3_skips_caret_range_with_no_lockfile_entry() {
8772 let parse_result = MockParseResult {
8773 deps: vec![MockDep {
8774 name: PackageName::new("serde"),
8775 version_req: Some(VersionReq::new("^1.0")),
8776 source: DependencySource::Registry,
8777 }],
8778 };
8779 let resolved = HashMap::new();
8780
8781 let (targets, skipped) =
8782 build_scan_targets(&parse_result, &resolved, &MockFormatter, EcosystemId::Cargo);
8783 assert!(targets.is_empty());
8784 assert_matches!(
8785 skipped.get("serde"),
8786 Some(ScanOutcome::Skipped(SkipReason::NoConcreteVersion))
8787 );
8788 }
8789
8790 #[test]
8791 fn build_scan_targets_step3_skips_wildcard_with_no_lockfile_entry() {
8792 let parse_result = MockParseResult {
8793 deps: vec![MockDep {
8794 name: PackageName::new("serde"),
8795 version_req: Some(VersionReq::new("*")),
8796 source: DependencySource::Registry,
8797 }],
8798 };
8799 let resolved = HashMap::new();
8800
8801 let (targets, skipped) =
8802 build_scan_targets(&parse_result, &resolved, &MockFormatter, EcosystemId::Cargo);
8803 assert!(targets.is_empty());
8804 assert_matches!(
8805 skipped.get("serde"),
8806 Some(ScanOutcome::Skipped(SkipReason::NoConcreteVersion))
8807 );
8808 }
8809
8810 #[test]
8811 fn build_scan_targets_all_non_registry_sources_are_skipped() {
8812 let sources = vec![
8813 DependencySource::Path {
8814 path: "../local".to_string(),
8815 },
8816 DependencySource::Url {
8817 url: "https://example.com/pkg.tgz".to_string(),
8818 },
8819 DependencySource::Sdk {
8820 sdk: "flutter".to_string(),
8821 },
8822 DependencySource::Workspace,
8823 DependencySource::CustomRegistry {
8824 url: "https://private.example.com".to_string(),
8825 },
8826 ];
8827
8828 for source in sources {
8829 let parse_result = MockParseResult {
8830 deps: vec![MockDep {
8831 name: PackageName::new("pkg"),
8832 version_req: Some(VersionReq::new("1.0.0")),
8833 source: source.clone(),
8834 }],
8835 };
8836 let mut resolved = HashMap::new();
8837 resolved.insert(PackageName::new("pkg"), "1.0.0".into());
8838
8839 let (targets, skipped) = build_scan_targets(
8840 &parse_result,
8841 &resolved,
8842 &MockFormatter,
8843 EcosystemId::Cargo,
8844 );
8845 assert!(targets.is_empty(), "{source:?} must be skipped (step 0)");
8846 assert_matches!(
8847 skipped.get("pkg"),
8848 Some(ScanOutcome::Skipped(SkipReason::NonRegistrySource))
8849 );
8850 }
8851 }
8852
8853 #[test]
8854 fn build_scan_targets_never_drops_a_dependency_silently() {
8855 let parse_result = MockParseResult {
8858 deps: vec![
8859 MockDep {
8860 name: PackageName::new("concrete"),
8861 version_req: Some(VersionReq::new("2.14.1")),
8862 source: DependencySource::Registry,
8863 },
8864 MockDep {
8865 name: PackageName::new("range-only"),
8866 version_req: Some(VersionReq::new("^1.0")),
8867 source: DependencySource::Registry,
8868 },
8869 MockDep {
8870 name: PackageName::new("git-dep"),
8871 version_req: Some(VersionReq::new("1.0.0")),
8872 source: DependencySource::Git {
8873 url: "https://example.com/git-dep".to_string(),
8874 rev: None,
8875 },
8876 },
8877 ],
8878 };
8879 let resolved = HashMap::new();
8880
8881 let (targets, skipped) =
8882 build_scan_targets(&parse_result, &resolved, &MockFormatter, EcosystemId::Maven);
8883
8884 assert_eq!(targets.len(), 1);
8885 assert_eq!(targets[0].key, "concrete");
8886 assert_eq!(skipped.len(), 2);
8887 assert_matches!(
8888 skipped.get("range-only"),
8889 Some(ScanOutcome::Skipped(SkipReason::NoConcreteVersion))
8890 );
8891 assert_matches!(
8892 skipped.get("git-dep"),
8893 Some(ScanOutcome::Skipped(SkipReason::NonRegistrySource))
8894 );
8895 }
8896
8897 #[test]
8902 fn collect_in_use_versions_prefers_lockfile_resolved_version() {
8903 let parse_result = MockParseResult {
8904 deps: vec![MockDep {
8905 name: PackageName::new("serde"),
8906 version_req: Some(VersionReq::new("^1.0")),
8907 source: DependencySource::Registry,
8908 }],
8909 };
8910 let mut resolved = HashMap::new();
8911 resolved.insert(PackageName::new("serde"), "1.0.195".into());
8912
8913 let in_use = collect_in_use_versions(
8914 &parse_result,
8915 &resolved,
8916 &MockFormatter,
8917 EcosystemId::Cargo,
8918 );
8919 assert_eq!(
8920 in_use.get(&PackageName::new("serde")),
8921 Some(&vec!["1.0.195".to_string()])
8922 );
8923 }
8924
8925 #[test]
8926 fn collect_in_use_versions_concrete_pin_without_lockfile() {
8927 let parse_result = MockParseResult {
8930 deps: vec![MockDep {
8931 name: PackageName::new("log4j-core"),
8932 version_req: Some(VersionReq::new("2.14.1")),
8933 source: DependencySource::Registry,
8934 }],
8935 };
8936 let resolved = HashMap::new();
8937
8938 let in_use = collect_in_use_versions(
8939 &parse_result,
8940 &resolved,
8941 &MockFormatter,
8942 EcosystemId::Maven,
8943 );
8944 assert_eq!(
8945 in_use.get(&PackageName::new("log4j-core")),
8946 Some(&vec!["2.14.1".to_string()])
8947 );
8948 }
8949
8950 #[test]
8951 fn collect_in_use_versions_strips_pep440_double_equals_pin_for_pypi() {
8952 let parse_result = MockParseResult {
8957 deps: vec![MockDep {
8958 name: PackageName::new("typing_extensions"),
8959 version_req: Some(VersionReq::new("==4.9.0")),
8960 source: DependencySource::Registry,
8961 }],
8962 };
8963 let resolved = HashMap::new();
8964
8965 let in_use = collect_in_use_versions(
8966 &parse_result,
8967 &resolved,
8968 &MockFormatter,
8969 EcosystemId::Pypi,
8970 );
8971 assert_eq!(
8972 in_use.get(&PackageName::new("typing_extensions")),
8973 Some(&vec!["4.9.0".to_string()]),
8974 "pep440 '==' comparator must be stripped, not carried into the in-use version"
8975 );
8976 }
8977
8978 #[test]
8979 fn collect_in_use_versions_skips_non_concrete_requirement_with_no_lockfile() {
8980 let parse_result = MockParseResult {
8981 deps: vec![MockDep {
8982 name: PackageName::new("serde"),
8983 version_req: Some(VersionReq::new("^1.0")),
8984 source: DependencySource::Registry,
8985 }],
8986 };
8987 let resolved = HashMap::new();
8988
8989 let in_use = collect_in_use_versions(
8990 &parse_result,
8991 &resolved,
8992 &MockFormatter,
8993 EcosystemId::Cargo,
8994 );
8995 assert!(in_use.is_empty());
8996 }
8997
8998 #[test]
8999 fn collect_in_use_versions_excludes_non_registry_source_even_with_lockfile_version() {
9000 let parse_result = MockParseResult {
9003 deps: vec![MockDep {
9004 name: PackageName::new("time"),
9005 version_req: Some(VersionReq::new("0.1.43")),
9006 source: DependencySource::Git {
9007 url: "https://github.com/example/time".to_string(),
9008 rev: None,
9009 },
9010 }],
9011 };
9012 let mut resolved = HashMap::new();
9013 resolved.insert(PackageName::new("time"), "0.1.43".into());
9014
9015 let in_use = collect_in_use_versions(
9016 &parse_result,
9017 &resolved,
9018 &MockFormatter,
9019 EcosystemId::Cargo,
9020 );
9021 assert!(in_use.is_empty());
9022 }
9023
9024 #[test]
9025 fn collect_in_use_versions_tracks_all_occurrences_of_duplicate_name() {
9026 let parse_result = MockParseResult {
9034 deps: vec![
9035 MockDep {
9036 name: PackageName::new("time"),
9037 version_req: Some(VersionReq::new("=0.1.43")),
9038 source: DependencySource::Registry,
9039 },
9040 MockDep {
9041 name: PackageName::new("time"),
9042 version_req: Some(VersionReq::new("=0.1.44")),
9043 source: DependencySource::Registry,
9044 },
9045 ],
9046 };
9047 let resolved = HashMap::new();
9048
9049 let in_use = collect_in_use_versions(
9050 &parse_result,
9051 &resolved,
9052 &MockFormatter,
9053 EcosystemId::Cargo,
9054 );
9055 assert_eq!(
9056 in_use.get(&PackageName::new("time")),
9057 Some(&vec!["0.1.43".to_string(), "0.1.44".to_string()]),
9058 "both occurrences' in-use versions must be tracked, not just the last one"
9059 );
9060 }
9061 }
9062
9063 mod fix_target_verification_tests {
9067 use super::*;
9068 use deps_core::lsp_helpers::{
9069 DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
9070 RequirementResolution, SourcePolicy,
9071 };
9072 use deps_core::osv::{
9073 Advisory, Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity,
9074 VulnerabilityMap,
9075 };
9076 use std::sync::Arc;
9077
9078 struct IdentityFormatter;
9079 impl PackageNaming for IdentityFormatter {}
9080
9081 impl PackageRendering for IdentityFormatter {
9082 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
9083 version.to_string()
9084 }
9085
9086 fn package_url(&self, name: &PackageName) -> String {
9087 format!("https://example.com/{name}")
9088 }
9089 }
9090
9091 impl RequirementResolution for IdentityFormatter {}
9092
9093 impl DiagnosticMessages for IdentityFormatter {}
9094
9095 impl DiagnosticPolicy for IdentityFormatter {}
9096
9097 impl SourcePolicy for IdentityFormatter {}
9098
9099 impl OsvNaming for IdentityFormatter {}
9100
9101 fn advisory(id: &str, fixed_versions: &[&str]) -> Arc<Advisory> {
9102 Arc::new(Advisory {
9103 id: id.to_string(),
9104 modified: "2023-01-01T00:00:00Z".to_string(),
9105 summary: None,
9106 aliases: vec![],
9107 severity: VulnSeverity::High,
9108 cvss_vector: None,
9109 fixed_versions: fixed_versions.iter().map(ToString::to_string).collect(),
9110 url: String::new(),
9111 })
9112 }
9113
9114 fn dv(
9115 advisories: Vec<Arc<Advisory>>,
9116 upgrade_status: UpgradeStatus,
9117 ) -> DependencyVulnerabilities {
9118 let total = advisories.len();
9119 DependencyVulnerabilities {
9120 advisories: Capped::new(advisories, total),
9121 upgrade_status,
9122 fix_target_status: UpgradeStatus::NotChecked,
9123 }
9124 }
9125
9126 #[test]
9127 fn resolve_fix_target_skips_when_no_fix_is_recommended() {
9128 let dv = dv(vec![advisory("A1", &[])], UpgradeStatus::NotChecked);
9130 let resolution = resolve_fix_target(
9131 &dv,
9132 "pkg",
9133 &HashMap::new(),
9134 &HashMap::new(),
9135 &IdentityFormatter,
9136 );
9137 assert_eq!(resolution, FixTargetResolution::Skip);
9138 }
9139
9140 #[test]
9141 fn resolve_fix_target_reuses_latest_when_f_equals_latest() {
9142 let latest_status = UpgradeStatus::CandidateClean {
9145 version: "1.2.0".to_string(),
9146 };
9147 let dv = dv(vec![advisory("A1", &["1.2.0"])], latest_status.clone());
9148 let mut latest_native_by_key = HashMap::new();
9149 latest_native_by_key.insert("pkg".to_string(), "1.2.0".to_string());
9150
9151 let resolution = resolve_fix_target(
9152 &dv,
9153 "pkg",
9154 &latest_native_by_key,
9155 &HashMap::new(),
9156 &IdentityFormatter,
9157 );
9158 assert_eq!(resolution, FixTargetResolution::Resolved(latest_status));
9159 }
9160
9161 #[test]
9162 fn resolve_fix_target_always_needs_live_check_when_f_differs_from_latest() {
9163 let dv = dv(
9170 vec![advisory("A1", &["1.2.0"])],
9171 UpgradeStatus::CandidateClean {
9172 version: "3.0.0".to_string(),
9173 },
9174 );
9175 let mut latest_native_by_key = HashMap::new();
9176 latest_native_by_key.insert("pkg".to_string(), "3.0.0".to_string());
9177 let mut osv_name_by_key = HashMap::new();
9178 osv_name_by_key.insert("pkg".to_string(), "pkg".to_string());
9179
9180 let resolution = resolve_fix_target(
9181 &dv,
9182 "pkg",
9183 &latest_native_by_key,
9184 &osv_name_by_key,
9185 &IdentityFormatter,
9186 );
9187 assert_eq!(
9188 resolution,
9189 FixTargetResolution::NeedsLiveCheck(deps_core::osv::ScanTarget {
9190 key: format!("pkg{FIX_TARGET_KEY_SUFFIX}"),
9191 osv_name: "pkg".to_string(),
9192 version: "1.2.0".to_string(),
9193 display_version: "1.2.0".to_string(),
9194 })
9195 );
9196 }
9197
9198 #[test]
9199 fn resolve_fix_target_skips_when_osv_name_is_unavailable() {
9200 let dv = dv(vec![advisory("A1", &["1.0.0"])], UpgradeStatus::NotChecked);
9204 let resolution = resolve_fix_target(
9205 &dv,
9206 "pkg",
9207 &HashMap::new(),
9208 &HashMap::new(),
9209 &IdentityFormatter,
9210 );
9211 assert_eq!(resolution, FixTargetResolution::Skip);
9212 }
9213
9214 #[test]
9215 fn resolve_fix_target_skips_when_f_is_not_a_safe_version_string() {
9216 let dv = dv(
9221 vec![advisory("A1", &["1.2.0\", \"evil\": \"true"])],
9222 UpgradeStatus::NotChecked,
9223 );
9224 let resolution = resolve_fix_target(
9225 &dv,
9226 "pkg",
9227 &HashMap::new(),
9228 &HashMap::new(),
9229 &IdentityFormatter,
9230 );
9231 assert_eq!(resolution, FixTargetResolution::Skip);
9232 }
9233
9234 #[test]
9235 fn collect_fix_target_resolutions_batches_multiple_dependencies_needing_live_check_into_one_vec()
9236 {
9237 let mut vulnerabilities = VulnerabilityMap::new();
9243 vulnerabilities.insert(
9244 "reused".to_string(),
9245 ScanOutcome::Vulnerable(dv(
9246 vec![advisory("A1", &["1.0.0"])],
9247 UpgradeStatus::CandidateClean {
9248 version: "1.0.0".to_string(),
9249 },
9250 )),
9251 );
9252 vulnerabilities.insert(
9253 "live-a".to_string(),
9254 ScanOutcome::Vulnerable(dv(
9255 vec![advisory("A2", &["1.2.0"])],
9256 UpgradeStatus::NotChecked,
9257 )),
9258 );
9259 vulnerabilities.insert(
9260 "live-b".to_string(),
9261 ScanOutcome::Vulnerable(dv(
9262 vec![advisory("A3", &["2.2.0"])],
9263 UpgradeStatus::NotChecked,
9264 )),
9265 );
9266
9267 let vulnerable_keys = vec![
9268 "reused".to_string(),
9269 "live-a".to_string(),
9270 "live-b".to_string(),
9271 ];
9272 let mut latest_native_by_key = HashMap::new();
9273 latest_native_by_key.insert("reused".to_string(), "1.0.0".to_string());
9274 latest_native_by_key.insert("live-a".to_string(), "9.0.0".to_string());
9275 latest_native_by_key.insert("live-b".to_string(), "9.0.0".to_string());
9276 let mut osv_name_by_key = HashMap::new();
9277 osv_name_by_key.insert("reused".to_string(), "reused".to_string());
9278 osv_name_by_key.insert("live-a".to_string(), "live-a".to_string());
9279 osv_name_by_key.insert("live-b".to_string(), "live-b".to_string());
9280
9281 let (resolved, live_check_candidates) = collect_fix_target_resolutions(
9282 &vulnerabilities,
9283 &vulnerable_keys,
9284 &osv_name_by_key,
9285 &latest_native_by_key,
9286 &IdentityFormatter,
9287 );
9288
9289 assert_eq!(resolved.len(), 1, "{resolved:?}");
9290 assert_eq!(resolved[0].0, "reused");
9291
9292 assert_eq!(live_check_candidates.len(), 2, "{live_check_candidates:?}");
9293 let keys: std::collections::HashSet<&str> = live_check_candidates
9294 .iter()
9295 .map(|t| t.key.as_str())
9296 .collect();
9297 assert!(keys.contains(format!("live-a{FIX_TARGET_KEY_SUFFIX}").as_str()));
9298 assert!(keys.contains(format!("live-b{FIX_TARGET_KEY_SUFFIX}").as_str()));
9299 }
9300
9301 #[test]
9302 fn apply_live_fix_target_statuses_sets_only_matching_keys_leaving_others_untouched() {
9303 let mut vulnerabilities = VulnerabilityMap::new();
9308 vulnerabilities.insert(
9309 "checked".to_string(),
9310 ScanOutcome::Vulnerable(dv(
9311 vec![advisory("A1", &["1.0.0"])],
9312 UpgradeStatus::NotChecked,
9313 )),
9314 );
9315 vulnerabilities.insert(
9316 "timed-out".to_string(),
9317 ScanOutcome::Vulnerable(dv(
9318 vec![advisory("A2", &["1.0.0"])],
9319 UpgradeStatus::NotChecked,
9320 )),
9321 );
9322
9323 let mut statuses = HashMap::new();
9324 statuses.insert(
9325 format!("checked{FIX_TARGET_KEY_SUFFIX}"),
9326 UpgradeStatus::CandidateClean {
9327 version: "1.0.0".to_string(),
9328 },
9329 );
9330 apply_live_fix_target_statuses(&mut vulnerabilities, statuses);
9333
9334 let ScanOutcome::Vulnerable(checked) = vulnerabilities.get("checked").unwrap() else {
9335 panic!("expected Vulnerable");
9336 };
9337 assert_eq!(
9338 checked.fix_target_status,
9339 UpgradeStatus::CandidateClean {
9340 version: "1.0.0".to_string()
9341 }
9342 );
9343
9344 let ScanOutcome::Vulnerable(timed_out) = vulnerabilities.get("timed-out").unwrap()
9345 else {
9346 panic!("expected Vulnerable");
9347 };
9348 assert_eq!(timed_out.fix_target_status, UpgradeStatus::NotChecked);
9349 }
9350 }
9351
9352 mod yanked_check_tests {
9353 use super::*;
9354 use deps_core::{Metadata, Version};
9355 use std::any::Any;
9356 use std::sync::atomic::{AtomicUsize, Ordering};
9357
9358 #[derive(Debug, Clone)]
9359 struct MockYankVersion {
9360 version: ConcreteVersion,
9361 yanked: bool,
9362 }
9363
9364 impl Version for MockYankVersion {
9365 fn version_string(&self) -> &ConcreteVersion {
9366 &self.version
9367 }
9368 fn removal_status(&self) -> deps_core::RemovalStatus {
9369 deps_core::RemovalStatus::from_yanked(self.yanked)
9370 }
9371 fn as_any(&self) -> &dyn Any {
9372 self
9373 }
9374 }
9375
9376 enum FetchOutcome {
9379 Versions(Vec<(&'static str, bool)>),
9380 Error,
9381 Timeout,
9382 }
9383
9384 struct MockRegistry {
9394 reports_yanked: bool,
9395 versions: HashMap<&'static str, FetchOutcome>,
9396 latest_fallback: HashMap<&'static str, (&'static str, bool)>,
9397 fetch_calls: Arc<AtomicUsize>,
9398 }
9399
9400 impl Registry for MockRegistry {
9401 fn get_versions<'a>(
9402 &'a self,
9403 name: &'a PackageName,
9404 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
9405 {
9406 self.fetch_calls.fetch_add(1, Ordering::Relaxed);
9407 let outcome = self.versions.get(name.as_str());
9408 Box::pin(async move {
9409 match outcome {
9410 Some(FetchOutcome::Versions(vs)) => Ok(vs
9411 .iter()
9412 .map(|(v, y)| {
9413 Box::new(MockYankVersion {
9414 version: (*v).into(),
9415 yanked: *y,
9416 }) as Box<dyn Version>
9417 })
9418 .collect()),
9419 Some(FetchOutcome::Error) => Err(deps_core::error::DepsError::CacheError(
9420 "mock fetch error".to_string(),
9421 )),
9422 Some(FetchOutcome::Timeout) => {
9423 tokio::time::sleep(std::time::Duration::from_secs(10)).await;
9424 Ok(vec![])
9425 }
9426 None => Ok(vec![]),
9427 }
9428 })
9429 }
9430
9431 fn select_latest_matching(
9432 &self,
9433 versions: &[Box<dyn Version>],
9434 _req: &VersionReq,
9435 ) -> Option<usize> {
9436 versions
9437 .iter()
9438 .position(|v| !v.removal_status().blocks_resolution())
9439 }
9440
9441 fn get_latest_matching<'a>(
9442 &'a self,
9443 name: &'a PackageName,
9444 _req: &'a VersionReq,
9445 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
9446 {
9447 let outcome = self.latest_fallback.get(name.as_str()).copied();
9448 Box::pin(async move {
9449 Ok(outcome.map(|(v, y)| {
9450 Box::new(MockYankVersion {
9451 version: v.into(),
9452 yanked: y,
9453 }) as Box<dyn Version>
9454 }))
9455 })
9456 }
9457
9458 fn search<'a>(
9459 &'a self,
9460 _query: &'a str,
9461 _limit: usize,
9462 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
9463 {
9464 Box::pin(async move { Ok(vec![]) })
9465 }
9466
9467 fn reports_yanked(&self) -> bool {
9468 self.reports_yanked
9469 }
9470
9471 fn as_any(&self) -> &dyn Any {
9472 self
9473 }
9474 }
9475
9476 #[tokio::test]
9477 async fn reports_yanked_false_never_recorded() {
9478 let fetch_calls = Arc::new(AtomicUsize::new(0));
9482 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9483 reports_yanked: false,
9484 versions: HashMap::from([(
9485 "pkg",
9486 FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", true)]),
9487 )]),
9488 latest_fallback: HashMap::new(),
9489 fetch_calls: Arc::clone(&fetch_calls),
9490 });
9491 let mut in_use = HashMap::new();
9492 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
9493
9494 let result = fetch_latest_versions_parallel(
9495 registry,
9496 vec![(PackageName::new("pkg"), DependencySource::Registry)],
9497 &in_use,
9498 None,
9499 deps_core::freshness::FreshnessSettings::default(),
9500 5,
9501 10,
9502 None,
9503 )
9504 .await;
9505
9506 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
9507 assert!(result.yanked_versions.is_empty());
9508 }
9509
9510 #[tokio::test]
9511 async fn in_use_equal_to_latest_not_yanked() {
9512 let fetch_calls = Arc::new(AtomicUsize::new(0));
9513 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9514 reports_yanked: true,
9515 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", false)]))]),
9516 latest_fallback: HashMap::new(),
9517 fetch_calls: Arc::clone(&fetch_calls),
9518 });
9519 let mut in_use = HashMap::new();
9520 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
9521
9522 let result = fetch_latest_versions_parallel(
9523 registry,
9524 vec![(PackageName::new("pkg"), DependencySource::Registry)],
9525 &in_use,
9526 None,
9527 deps_core::freshness::FreshnessSettings::default(),
9528 5,
9529 10,
9530 None,
9531 )
9532 .await;
9533
9534 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
9535 assert!(result.yanked_versions.is_empty());
9536 }
9537
9538 #[tokio::test]
9539 async fn no_known_in_use_version_skips_the_check() {
9540 let fetch_calls = Arc::new(AtomicUsize::new(0));
9541 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9542 reports_yanked: true,
9543 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("2.0.0", false)]))]),
9544 latest_fallback: HashMap::new(),
9545 fetch_calls: Arc::clone(&fetch_calls),
9546 });
9547
9548 let result = fetch_latest_versions_parallel(
9549 registry,
9550 vec![(PackageName::new("pkg"), DependencySource::Registry)],
9551 &HashMap::new(),
9552 None,
9553 deps_core::freshness::FreshnessSettings::default(),
9554 5,
9555 10,
9556 None,
9557 )
9558 .await;
9559
9560 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
9561 assert!(result.yanked_versions.is_empty());
9562 }
9563
9564 #[tokio::test]
9565 async fn in_use_differs_and_yanked_is_recorded() {
9566 let fetch_calls = Arc::new(AtomicUsize::new(0));
9570 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9571 reports_yanked: true,
9572 versions: HashMap::from([(
9573 "pkg",
9574 FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", true)]),
9575 )]),
9576 latest_fallback: HashMap::new(),
9577 fetch_calls: Arc::clone(&fetch_calls),
9578 });
9579 let mut in_use = HashMap::new();
9580 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
9581
9582 let result = fetch_latest_versions_parallel(
9583 registry,
9584 vec![(PackageName::new("pkg"), DependencySource::Registry)],
9585 &in_use,
9586 None,
9587 deps_core::freshness::FreshnessSettings::default(),
9588 5,
9589 10,
9590 None,
9591 )
9592 .await;
9593
9594 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
9595 assert_eq!(
9596 result.yanked_versions.get(&PackageName::new("pkg")),
9597 Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
9598 );
9599 }
9600
9601 #[tokio::test]
9602 async fn in_use_differs_and_not_yanked_is_not_recorded() {
9603 let fetch_calls = Arc::new(AtomicUsize::new(0));
9604 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9605 reports_yanked: true,
9606 versions: HashMap::from([(
9607 "pkg",
9608 FetchOutcome::Versions(vec![("2.0.0", false), ("1.0.0", false)]),
9609 )]),
9610 latest_fallback: HashMap::new(),
9611 fetch_calls: Arc::clone(&fetch_calls),
9612 });
9613 let mut in_use = HashMap::new();
9614 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
9615
9616 let result = fetch_latest_versions_parallel(
9617 registry,
9618 vec![(PackageName::new("pkg"), DependencySource::Registry)],
9619 &in_use,
9620 None,
9621 deps_core::freshness::FreshnessSettings::default(),
9622 5,
9623 10,
9624 None,
9625 )
9626 .await;
9627
9628 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
9629 assert!(result.yanked_versions.is_empty());
9630 }
9631
9632 #[tokio::test]
9633 async fn every_version_yanked_still_checks_in_use() {
9634 let fetch_calls = Arc::new(AtomicUsize::new(0));
9641 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9642 reports_yanked: true,
9643 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
9644 latest_fallback: HashMap::new(),
9645 fetch_calls: Arc::clone(&fetch_calls),
9646 });
9647 let mut in_use = HashMap::new();
9648 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
9649
9650 let result = fetch_latest_versions_parallel(
9651 registry,
9652 vec![(PackageName::new("pkg"), DependencySource::Registry)],
9653 &in_use,
9654 None,
9655 deps_core::freshness::FreshnessSettings::default(),
9656 5,
9657 10,
9658 None,
9659 )
9660 .await;
9661
9662 assert_eq!(
9663 result.yanked_versions.get(&PackageName::new("pkg")),
9664 Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
9665 );
9666 assert!(result.versions.is_empty());
9667 }
9668
9669 #[tokio::test]
9670 async fn latest_pick_needs_fallback_in_use_yanked_still_found() {
9671 let fetch_calls = Arc::new(AtomicUsize::new(0));
9677 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9678 reports_yanked: true,
9679 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
9680 latest_fallback: HashMap::from([("pkg", ("2.0.0", false))]),
9681 fetch_calls: Arc::clone(&fetch_calls),
9682 });
9683 let mut in_use = HashMap::new();
9684 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
9685
9686 let result = fetch_latest_versions_parallel(
9687 registry,
9688 vec![(PackageName::new("pkg"), DependencySource::Registry)],
9689 &in_use,
9690 None,
9691 deps_core::freshness::FreshnessSettings::default(),
9692 5,
9693 10,
9694 None,
9695 )
9696 .await;
9697
9698 assert_eq!(fetch_calls.load(Ordering::Relaxed), 1);
9699 assert_eq!(
9700 result
9701 .versions
9702 .get(&PackageName::new("pkg"))
9703 .map(|v| v.latest.as_str()),
9704 Some("2.0.0")
9705 );
9706 assert_eq!(
9707 result.yanked_versions.get(&PackageName::new("pkg")),
9708 Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
9709 );
9710 }
9711
9712 #[tokio::test]
9713 async fn in_use_checks_every_occurrence_of_a_duplicate_name() {
9714 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9720 reports_yanked: true,
9721 versions: HashMap::from([(
9722 "pkg",
9723 FetchOutcome::Versions(vec![
9724 ("3.0.0", false),
9725 ("2.0.0", true),
9726 ("1.0.0", false),
9727 ]),
9728 )]),
9729 latest_fallback: HashMap::new(),
9730 fetch_calls: Arc::new(AtomicUsize::new(0)),
9731 });
9732 let mut in_use = HashMap::new();
9733 in_use.insert(
9734 PackageName::new("pkg"),
9735 vec!["1.0.0".to_string(), "2.0.0".to_string()],
9736 );
9737
9738 let result = fetch_latest_versions_parallel(
9739 registry,
9740 vec![(PackageName::new("pkg"), DependencySource::Registry)],
9741 &in_use,
9742 None,
9743 deps_core::freshness::FreshnessSettings::default(),
9744 5,
9745 10,
9746 None,
9747 )
9748 .await;
9749
9750 assert_eq!(
9751 result.yanked_versions.get(&PackageName::new("pkg")),
9752 Some(&(ConcreteVersion::new("2.0.0"), RemovalStatus::Yanked)),
9753 "the yanked occurrence must be found even though a name-keyed \
9754 single-value map could have kept only the non-yanked \"1.0.0\" pin"
9755 );
9756 }
9757
9758 #[tokio::test]
9759 async fn latest_is_yanked_recorded_as_defense_in_depth() {
9760 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9766 reports_yanked: true,
9767 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
9768 latest_fallback: HashMap::from([("pkg", ("1.0.0", true))]),
9769 fetch_calls: Arc::new(AtomicUsize::new(0)),
9770 });
9771
9772 let result = fetch_latest_versions_parallel(
9773 registry,
9774 vec![(PackageName::new("pkg"), DependencySource::Registry)],
9775 &HashMap::new(),
9776 None,
9777 deps_core::freshness::FreshnessSettings::default(),
9778 5,
9779 10,
9780 None,
9781 )
9782 .await;
9783
9784 assert_eq!(
9785 result.yanked_versions.get(&PackageName::new("pkg")),
9786 Some(&(ConcreteVersion::new("1.0.0"), RemovalStatus::Yanked))
9787 );
9788 }
9789
9790 #[tokio::test]
9791 async fn latest_is_yanked_not_recorded_when_reports_yanked_false() {
9792 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9800 reports_yanked: false,
9801 versions: HashMap::from([("pkg", FetchOutcome::Versions(vec![("1.0.0", true)]))]),
9802 latest_fallback: HashMap::from([("pkg", ("1.0.0", true))]),
9803 fetch_calls: Arc::new(AtomicUsize::new(0)),
9804 });
9805
9806 let result = fetch_latest_versions_parallel(
9807 registry,
9808 vec![(PackageName::new("pkg"), DependencySource::Registry)],
9809 &HashMap::new(),
9810 None,
9811 deps_core::freshness::FreshnessSettings::default(),
9812 5,
9813 10,
9814 None,
9815 )
9816 .await;
9817
9818 assert!(
9819 result.yanked_versions.is_empty(),
9820 "a `reports_yanked() == false` registry's `removal_status()` must never be \
9821 trusted, even on the zero-cost row-1 path"
9822 );
9823 assert!(
9824 result
9825 .versions
9826 .get(&PackageName::new("pkg"))
9827 .expect("pkg was fetched")
9828 .yanked
9829 .is_empty(),
9830 "`PackageVersions::yanked` must stay empty for a `reports_yanked() == false` \
9831 registry, even though the fetched version is itself flagged"
9832 );
9833 }
9834
9835 #[tokio::test]
9836 async fn primary_fetch_error_counts_as_failed_no_yanked_data() {
9837 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9843 reports_yanked: true,
9844 versions: HashMap::from([("pkg", FetchOutcome::Error)]),
9845 latest_fallback: HashMap::new(),
9846 fetch_calls: Arc::new(AtomicUsize::new(0)),
9847 });
9848 let mut in_use = HashMap::new();
9849 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
9850
9851 let result = fetch_latest_versions_parallel(
9852 registry,
9853 vec![(PackageName::new("pkg"), DependencySource::Registry)],
9854 &in_use,
9855 None,
9856 deps_core::freshness::FreshnessSettings::default(),
9857 5,
9858 10,
9859 None,
9860 )
9861 .await;
9862
9863 assert!(result.yanked_versions.is_empty());
9864 assert_eq!(result.failed_count, 1);
9865 assert!(result.versions.is_empty());
9866 }
9867
9868 #[tokio::test]
9869 async fn primary_fetch_timeout_counts_as_failed_no_yanked_data() {
9870 let registry: Arc<dyn Registry> = Arc::new(MockRegistry {
9872 reports_yanked: true,
9873 versions: HashMap::from([("pkg", FetchOutcome::Timeout)]),
9874 latest_fallback: HashMap::new(),
9875 fetch_calls: Arc::new(AtomicUsize::new(0)),
9876 });
9877 let mut in_use = HashMap::new();
9878 in_use.insert(PackageName::new("pkg"), vec!["1.0.0".to_string()]);
9879
9880 let result = fetch_latest_versions_parallel(
9882 registry,
9883 vec![(PackageName::new("pkg"), DependencySource::Registry)],
9884 &in_use,
9885 None,
9886 deps_core::freshness::FreshnessSettings::default(),
9887 1,
9888 10,
9889 None,
9890 )
9891 .await;
9892
9893 assert!(result.yanked_versions.is_empty());
9894 assert_eq!(result.failed_count, 1);
9895 assert!(result.versions.is_empty());
9896 }
9897 }
9898
9899 mod deprecation_derivation_tests {
9905 use super::*;
9906 use deps_core::{Metadata, Version};
9907 use std::any::Any;
9908
9909 struct MockDeprecatedVersion {
9910 version: ConcreteVersion,
9911 deprecation: Option<Deprecation>,
9912 }
9913
9914 impl Version for MockDeprecatedVersion {
9915 fn version_string(&self) -> &ConcreteVersion {
9916 &self.version
9917 }
9918 fn removal_status(&self) -> RemovalStatus {
9919 RemovalStatus::from_advisory(self.deprecation.is_some())
9920 }
9921 fn deprecation(&self) -> Option<&Deprecation> {
9922 self.deprecation.as_ref()
9923 }
9924 fn as_any(&self) -> &dyn Any {
9925 self
9926 }
9927 }
9928
9929 struct SingleVersionRegistry {
9931 deprecation: Option<Deprecation>,
9932 }
9933
9934 impl Registry for SingleVersionRegistry {
9935 fn get_versions<'a>(
9936 &'a self,
9937 _name: &'a PackageName,
9938 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Version>>>>
9939 {
9940 let deprecation = self.deprecation.clone();
9941 Box::pin(async move {
9942 Ok(vec![Box::new(MockDeprecatedVersion {
9943 version: "1.0.0".into(),
9944 deprecation,
9945 }) as Box<dyn Version>])
9946 })
9947 }
9948
9949 fn select_latest_matching(
9950 &self,
9951 versions: &[Box<dyn Version>],
9952 _req: &VersionReq,
9953 ) -> Option<usize> {
9954 (!versions.is_empty()).then_some(0)
9955 }
9956
9957 fn get_latest_matching<'a>(
9958 &'a self,
9959 _name: &'a PackageName,
9960 _req: &'a VersionReq,
9961 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn Version>>>>
9962 {
9963 Box::pin(async move { Ok(None) })
9964 }
9965
9966 fn search<'a>(
9967 &'a self,
9968 _query: &'a str,
9969 _limit: usize,
9970 ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn Metadata>>>>
9971 {
9972 Box::pin(async move { Ok(vec![]) })
9973 }
9974
9975 fn as_any(&self) -> &dyn Any {
9976 self
9977 }
9978 }
9979
9980 #[tokio::test]
9981 async fn fetch_result_carries_deprecation_from_resolved_pick() {
9982 let registry: Arc<dyn Registry> = Arc::new(SingleVersionRegistry {
9983 deprecation: Some(Deprecation {
9984 reason: Some("archived".to_string()),
9985 replacement: Some("other/pkg".to_string()),
9986 }),
9987 });
9988
9989 let result = fetch_latest_versions_parallel(
9990 registry,
9991 vec![(PackageName::new("pkg"), DependencySource::Registry)],
9992 &HashMap::new(),
9993 None,
9994 deps_core::freshness::FreshnessSettings::default(),
9995 5,
9996 10,
9997 None,
9998 )
9999 .await;
10000
10001 assert_eq!(
10002 result.deprecations.get(&PackageName::new("pkg")),
10003 Some(&Deprecation {
10004 reason: Some("archived".to_string()),
10005 replacement: Some("other/pkg".to_string()),
10006 })
10007 );
10008 }
10009
10010 #[tokio::test]
10011 async fn fetch_result_has_no_deprecation_when_resolved_pick_is_clean() {
10012 let registry: Arc<dyn Registry> = Arc::new(SingleVersionRegistry { deprecation: None });
10013
10014 let result = fetch_latest_versions_parallel(
10015 registry,
10016 vec![(PackageName::new("pkg"), DependencySource::Registry)],
10017 &HashMap::new(),
10018 None,
10019 deps_core::freshness::FreshnessSettings::default(),
10020 5,
10021 10,
10022 None,
10023 )
10024 .await;
10025
10026 assert!(result.deprecations.is_empty());
10027 }
10028 }
10029}