deps_engine/classify/resolved.rs
1//! Lock-file and in-use dependency version resolution.
2
3use deps_core::ConcreteVersion;
4use deps_core::Ecosystem;
5use deps_core::EcosystemId;
6use deps_core::PackageName;
7use deps_core::PackageVersions;
8use deps_core::VersionReq;
9use deps_core::lockfile::{LockFileCache, LockFileProvider};
10use deps_core::lsp_helpers::resolve_in_use_version;
11use std::collections::HashMap;
12use std::path::Path;
13use std::sync::Arc;
14
15/// Builds `dep_name -> [in_use_version, ...]` (§4.5/§4.6) for every dependency with a known
16/// in-use version, for the yanked-check probe in `fetch_latest_versions_parallel`.
17///
18/// Skips non-registry dependencies (git/path forks, step 0 of `build_scan_targets`'s ladder)
19/// so a patched fork is never flagged for a registry version it does not contain.
20///
21/// One entry per *occurrence* of a name, not a single collapsed value: the
22/// same dependency name can appear more than once in a manifest (the same
23/// crate under `[dependencies]`/`[dev-dependencies]` or multiple
24/// `[target.'cfg(...)'.dependencies]` blocks — #394). A HashMap keyed by
25/// name alone would silently drop all but the last occurrence's in-use
26/// version from the yanked probe below.
27///
28/// # Examples
29///
30/// ```
31/// use deps_core::lsp_helpers::{
32/// DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
33/// RequirementResolution, SourcePolicy,
34/// };
35/// use deps_core::test_util::stub_parse_result_with_dependencies;
36/// use deps_core::{ConcreteVersion, EcosystemId, PackageName};
37/// use deps_engine::classify::resolved::collect_in_use_versions;
38/// use std::collections::HashMap;
39///
40/// struct SimpleFormatter;
41/// impl PackageNaming for SimpleFormatter {}
42/// impl PackageRendering for SimpleFormatter {
43/// fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
44/// version.to_string()
45/// }
46/// fn package_url(&self, name: &PackageName) -> String {
47/// name.as_str().to_string()
48/// }
49/// }
50/// impl RequirementResolution for SimpleFormatter {}
51/// impl DiagnosticMessages for SimpleFormatter {}
52/// impl DiagnosticPolicy for SimpleFormatter {}
53/// impl SourcePolicy for SimpleFormatter {}
54/// impl OsvNaming for SimpleFormatter {}
55///
56/// // A single registry-sourced dependency ("dep-0"), with a lock-file-resolved version.
57/// let parsed = stub_parse_result_with_dependencies(1);
58/// let mut resolved_versions = HashMap::new();
59/// resolved_versions.insert(PackageName::new("dep-0"), ConcreteVersion::from("1.0.0"));
60///
61/// let in_use = collect_in_use_versions(
62/// parsed.as_ref(),
63/// &resolved_versions,
64/// &HashMap::new(),
65/// &SimpleFormatter,
66/// EcosystemId::Cargo,
67/// );
68/// assert_eq!(
69/// in_use.get(&PackageName::new("dep-0")),
70/// Some(&vec![ConcreteVersion::from("1.0.0")])
71/// );
72/// ```
73pub fn collect_in_use_versions(
74 parse_result: &dyn deps_core::ParseResult,
75 resolved_versions: &HashMap<PackageName, ConcreteVersion>,
76 resolved_version_candidates: &HashMap<PackageName, Vec<ConcreteVersion>>,
77 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
78 ecosystem: EcosystemId,
79) -> HashMap<PackageName, Vec<ConcreteVersion>> {
80 let mut map: HashMap<PackageName, Vec<ConcreteVersion>> = HashMap::new();
81 for dep in parse_result
82 .dependencies()
83 .into_iter()
84 .filter(|dep| formatter.source_is_public_registry_content(&dep.source()))
85 {
86 let normalized_name = formatter.normalize_package_name(dep.name());
87 if let Some(v) = resolve_in_use_version(
88 dep,
89 &normalized_name,
90 resolved_versions,
91 Some(resolved_version_candidates),
92 formatter,
93 ecosystem,
94 ) {
95 map.entry(dep.name().clone()).or_default().push(v);
96 }
97 }
98 map
99}
100
101/// Builds `name -> [version_requirement, ...]` for every dependency in `pr`, one entry per
102/// occurrence — the shape `DependencyDiff::compute` needs.
103///
104/// A `HashMap<PackageName, Option<VersionReq>>` (single value per name) would silently
105/// collapse a duplicate name to its last occurrence, losing any edit made to an earlier one
106/// (#394).
107///
108/// Occurrence order is whatever `pr.dependencies()` returns, which for
109/// `deps-cargo` is *not* document order for multiple `[target.*]` blocks
110/// (see `DependencyDiff::compute`'s doc). A consequence worth knowing: if
111/// an edit only renames a `[target.'cfg(...)'.dependencies]` expression
112/// (no version change), that occurrence can sort into a different position
113/// in the new `Vec` than the old one, so `old.get(name) != new.get(name)`
114/// trips even though every individual version requirement is unchanged —
115/// a spurious but harmless `version_changed` (one extra registry
116/// refetch/OSV rescan for that name, never a missed or misattributed one).
117///
118/// # Examples
119///
120/// ```
121/// use deps_core::PackageName;
122/// use deps_core::test_util::stub_parse_result_with_dependencies;
123/// use deps_engine::classify::resolved::dependency_version_map;
124///
125/// let parsed = stub_parse_result_with_dependencies(2);
126/// let map = dependency_version_map(parsed.as_ref());
127///
128/// assert_eq!(map.len(), 2);
129/// assert_eq!(map.get(&PackageName::new("dep-0")), Some(&vec![None]));
130/// ```
131pub fn dependency_version_map(
132 pr: &dyn deps_core::ParseResult,
133) -> HashMap<PackageName, Vec<Option<VersionReq>>> {
134 let mut map: HashMap<PackageName, Vec<Option<VersionReq>>> = HashMap::new();
135 for d in pr.dependencies() {
136 map.entry(d.name().clone())
137 .or_default()
138 .push(d.version_requirement().cloned());
139 }
140 map
141}
142
143/// Builds a `cached_versions` map from lock-file-resolved versions, ahead of any registry
144/// fetch.
145///
146/// `available` is deliberately left empty (`PackageVersions::latest_without_list`, not a
147/// plausible-looking one-element list) — this runs before any registry fetch, and
148/// `requirement_is_unsatisfiable` treats an empty `available` as "still loading, skip"
149/// (FR-004). Using `latest_only` here instead would populate a bogus single-entry list and
150/// let the unsatisfiable-requirement check compute a false verdict on every document open,
151/// before the fetch that's supposed to suppress it has a chance to run.
152///
153/// # Examples
154///
155/// ```
156/// use deps_core::PackageName;
157/// use deps_engine::classify::resolved::cached_versions_from_lockfile;
158/// use std::collections::HashMap;
159///
160/// let mut resolved = HashMap::new();
161/// resolved.insert(PackageName::new("serde"), "1.0.195".into());
162///
163/// let cached = cached_versions_from_lockfile(&resolved);
164///
165/// let serde = cached.get(&PackageName::new("serde")).unwrap();
166/// assert_eq!(serde.latest, "1.0.195");
167/// assert!(serde.available.is_empty());
168/// ```
169pub fn cached_versions_from_lockfile(
170 resolved: &HashMap<PackageName, ConcreteVersion>,
171) -> HashMap<PackageName, PackageVersions> {
172 resolved
173 .iter()
174 .map(|(name, version)| {
175 (
176 name.clone(),
177 PackageVersions::latest_without_list(version.clone()),
178 )
179 })
180 .collect()
181}
182
183/// Splits a parsed [`deps_core::lockfile::ResolvedPackages`] into two maps.
184///
185/// The collapsed `dep_name -> version` map (`ResolvedPackages::iter`, unchanged FR-005 fast
186/// path) and a sibling `dep_name -> [version, ...]` map (issue #649) holding every retained
187/// lock-file entry for names with more than one — built from `ResolvedPackages::iter_all`,
188/// and deliberately omitting a single-occurrence name entirely (NFR-003: the common case
189/// never pays for a candidates-map lookup).
190///
191/// Shared by both [`LockfileLoad::Loaded`] constructors: [`parse_known_lockfile`] (and thus
192/// [`load_resolved_versions`]) and `deps-lsp`'s watched-lock-file-change handler, which calls
193/// [`parse_known_lockfile`] directly — both re-parse a lock file and need the identical
194/// split.
195///
196/// # Examples
197///
198/// ```
199/// use deps_core::lockfile::{ResolvedPackage, ResolvedPackages, ResolvedSource};
200/// use deps_engine::classify::resolved::split_resolved_packages;
201///
202/// let mut resolved = ResolvedPackages::new();
203/// resolved.insert(ResolvedPackage::new(
204/// "serde".into(),
205/// "1.0.195".into(),
206/// ResolvedSource::Registry {
207/// url: "https://github.com/rust-lang/crates.io-index".into(),
208/// checksum: "abc123".into(),
209/// },
210/// ));
211///
212/// let (versions, candidates) = split_resolved_packages(&resolved);
213/// assert_eq!(versions.len(), 1);
214/// assert!(
215/// candidates.is_empty(),
216/// "a single-occurrence name has no candidates entry"
217/// );
218/// ```
219pub fn split_resolved_packages(
220 resolved: &deps_core::lockfile::ResolvedPackages,
221) -> (
222 HashMap<PackageName, ConcreteVersion>,
223 HashMap<PackageName, Vec<ConcreteVersion>>,
224) {
225 let versions = resolved
226 .iter()
227 .map(|(name, pkg)| (PackageName::new(name.as_str()), pkg.version.clone().into()))
228 .collect();
229 let candidates = resolved
230 .iter_all()
231 .filter(|(_, versions)| versions.len() > 1)
232 .map(|(name, versions)| {
233 (
234 PackageName::new(name.as_str()),
235 versions
236 .iter()
237 .map(|pkg| ConcreteVersion::from(pkg.version.clone()))
238 .collect(),
239 )
240 })
241 .collect();
242 (versions, candidates)
243}
244
245/// Outcome of loading (or reloading) a lock file's resolved-version data.
246///
247/// Replaces the earlier `(HashMap, HashMap, bool)` return shape (issue #1424): the trailing
248/// `bool` distinguished "reload OK: parsed, or genuinely absent" from "parse failed, or
249/// discovery panicked" only via a doc-comment convention, which two of three call sites
250/// simply ignored. As a named, exhaustive enum, that distinction is now a first-class value
251/// with its own documented [`Self::reload_ok`] method, not a bare `bool` a caller receives
252/// with no attached meaning. [`Self::into_maps`] is a deliberate escape hatch for a caller
253/// that only wants the maps: it collapses [`Self::Absent`]/[`Self::Failed`] alike, so
254/// nothing stops a caller from reaching for it without ever consulting [`Self::reload_ok`]
255/// first, exactly as a caller of the old tuple could ignore its `bool`. The improvement here
256/// is discoverability — the distinction has a name and a doc comment sitting right next to
257/// `into_maps` — not a compiler-enforced guarantee that every caller gets it right.
258///
259/// # Examples
260///
261/// ```
262/// use deps_core::{ConcreteVersion, PackageName};
263/// use deps_engine::classify::resolved::LockfileLoad;
264/// use std::collections::HashMap;
265///
266/// let load = LockfileLoad::Loaded {
267/// versions: HashMap::from([(PackageName::new("serde"), ConcreteVersion::from("1.0.210"))]),
268/// candidates: HashMap::new(),
269/// };
270/// assert!(load.reload_ok());
271/// let (versions, _candidates) = load.into_maps();
272/// assert_eq!(versions.len(), 1);
273///
274/// assert!(!LockfileLoad::Failed.reload_ok());
275/// assert!(LockfileLoad::Absent.reload_ok());
276/// ```
277#[derive(Debug, Clone, PartialEq, Eq)]
278pub enum LockfileLoad {
279 /// No `LockFileProvider` for this ecosystem, or no lock file found on disk — a genuine,
280 /// non-error absence.
281 Absent,
282 /// A lock file was found and parsed successfully, possibly to zero packages.
283 Loaded {
284 /// `dep_name -> version`, one entry per name (see [`split_resolved_packages`]).
285 versions: HashMap<PackageName, ConcreteVersion>,
286 /// `dep_name -> [version, ...]`, only for names with more than one retained entry.
287 candidates: HashMap<PackageName, Vec<ConcreteVersion>>,
288 },
289 /// A lock file was found but failed to parse (e.g. caught mid-rewrite by the package
290 /// manager), or its discovery task panicked. Conservative: a panic is not a definitive
291 /// "no lock file" answer the way a located-but-absent path is, so it is classified the
292 /// same as a parse failure rather than as [`Self::Absent`].
293 Failed,
294}
295
296impl LockfileLoad {
297 /// Whether an empty-vs-non-empty transition read from this outcome is trustworthy: a
298 /// genuine absence ([`Self::Absent`]) or a successful parse ([`Self::Loaded`]) — never a
299 /// transient [`Self::Failed`].
300 ///
301 /// Callers that treat such a transition as a real signal (a resolved-version move worth
302 /// re-scanning/diffing against) must gate on this — otherwise a transient parse failure
303 /// looks identical to every dependency genuinely losing its resolution, silently
304 /// discarding known-good data and replacing correct OSV/license results with
305 /// `Skipped`/stale ones (the #1395 M1 class this mirrors).
306 #[must_use]
307 pub fn reload_ok(&self) -> bool {
308 !matches!(self, Self::Failed)
309 }
310
311 /// Extracts the resolved-version maps, falling back to a pair of empty maps for
312 /// [`Self::Absent`]/[`Self::Failed`].
313 #[must_use]
314 pub fn into_maps(
315 self,
316 ) -> (
317 HashMap<PackageName, ConcreteVersion>,
318 HashMap<PackageName, Vec<ConcreteVersion>>,
319 ) {
320 match self {
321 Self::Loaded {
322 versions,
323 candidates,
324 } => (versions, candidates),
325 Self::Absent | Self::Failed => (HashMap::new(), HashMap::new()),
326 }
327 }
328}
329
330/// Parses the lock file at `lockfile_path` through `lockfile_cache`, converting the result
331/// into a [`LockfileLoad::Loaded`] or [`LockfileLoad::Failed`] outcome.
332///
333/// `lockfile_path` is a path already known to exist — resolved by
334/// [`load_resolved_versions`]'s own discovery step, or by a caller reacting to a
335/// file-watcher event for a path it already knows. Never returns [`LockfileLoad::Absent`] —
336/// only [`load_resolved_versions`]'s discovery step (no lock file located at all) can
337/// conclude that; a known path means discovery already succeeded.
338///
339/// Shared by [`load_resolved_versions`] and `deps-lsp`'s watched-lock-file-change handler
340/// (issue #1424) — both need the identical get-or-parse-then-split sequence, previously
341/// duplicated between them.
342///
343/// # Examples
344///
345/// ```
346/// use deps_core::{Ecosystem, HttpCache};
347/// use deps_core::lockfile::LockFileCache;
348/// use deps_engine::classify::resolved::{LockfileLoad, parse_known_lockfile};
349/// use deps_engine::setup::CargoEcosystem;
350/// use std::path::Path;
351/// use std::sync::Arc;
352///
353/// #[tokio::main]
354/// async fn main() {
355/// let ecosystem = CargoEcosystem::new(Arc::new(HttpCache::new()));
356/// let lockfile_cache = LockFileCache::new();
357/// let lock_provider = ecosystem.lockfile_provider().unwrap();
358///
359/// // The path itself doesn't exist, so parsing fails.
360/// let load = parse_known_lockfile(
361/// &lockfile_cache,
362/// lock_provider.as_ref(),
363/// Path::new("/nonexistent-for-doctest/Cargo.lock"),
364/// )
365/// .await;
366/// assert_eq!(load, LockfileLoad::Failed);
367/// }
368/// ```
369pub async fn parse_known_lockfile(
370 lockfile_cache: &LockFileCache,
371 lock_provider: &dyn LockFileProvider,
372 lockfile_path: &Path,
373) -> LockfileLoad {
374 match lockfile_cache
375 .get_or_parse(lock_provider, lockfile_path)
376 .await
377 {
378 Ok(resolved) => {
379 tracing::info!(
380 "Loaded {} resolved versions from {}",
381 resolved.len(),
382 lockfile_path.display()
383 );
384 let (versions, candidates) = split_resolved_packages(&resolved);
385 LockfileLoad::Loaded {
386 versions,
387 candidates,
388 }
389 }
390 Err(e) => {
391 tracing::warn!(
392 "Failed to parse lock file {}: {}",
393 lockfile_path.display(),
394 e
395 );
396 LockfileLoad::Failed
397 }
398 }
399}
400
401/// Loads resolved versions from lock file for a given manifest URI.
402///
403/// Uses the ecosystem's lockfile provider to locate the lock file, then
404/// [`parse_known_lockfile`] to parse it. See [`LockfileLoad`] for what each outcome means.
405///
406/// # Examples
407///
408/// ```
409/// use deps_core::HttpCache;
410/// use deps_core::lockfile::LockFileCache;
411/// use deps_core::test_util::test_uri;
412/// use deps_engine::classify::resolved::{LockfileLoad, load_resolved_versions};
413/// use deps_engine::setup::CargoEcosystem;
414/// use std::sync::Arc;
415///
416/// #[tokio::main]
417/// async fn main() {
418/// let ecosystem = CargoEcosystem::new(Arc::new(HttpCache::new()));
419/// let lockfile_cache = Arc::new(LockFileCache::new());
420/// // No `Cargo.lock` exists at this synthetic path, so this is a genuine absence — the
421/// // same fast path a manifest with no lock file takes in production.
422/// let uri = test_uri("/nonexistent-for-doctest/Cargo.toml");
423///
424/// let load = load_resolved_versions(&uri, &lockfile_cache, &ecosystem).await;
425/// assert_eq!(load, LockfileLoad::Absent);
426/// }
427/// ```
428pub async fn load_resolved_versions(
429 uri: &url::Url,
430 lockfile_cache: &Arc<LockFileCache>,
431 ecosystem: &dyn Ecosystem,
432) -> LockfileLoad {
433 let lock_provider = match ecosystem.lockfile_provider() {
434 Some(p) => p,
435 None => {
436 tracing::debug!("No lock file provider for ecosystem {}", ecosystem.id());
437 return LockfileLoad::Absent;
438 }
439 };
440
441 // `locate_lockfile` does a synchronous ancestor-directory stat walk; run in
442 // `spawn_blocking` rather than inline on the tokio worker (#963).
443 let lock_provider_for_locate = Arc::clone(&lock_provider);
444 let uri_for_locate = uri.clone();
445 let located = tokio::task::spawn_blocking(move || {
446 lock_provider_for_locate.locate_lockfile(&uri_for_locate)
447 })
448 .await;
449
450 let lockfile_path = match located {
451 Ok(Some(path)) => path,
452 Ok(None) => {
453 tracing::debug!("No lock file found for {:?}", uri);
454 return LockfileLoad::Absent;
455 }
456 Err(e) => {
457 tracing::warn!("Lock file discovery task panicked for {:?}: {}", uri, e);
458 return LockfileLoad::Failed;
459 }
460 };
461
462 parse_known_lockfile(lockfile_cache, lock_provider.as_ref(), &lockfile_path).await
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 /// N5 regression guard: the lock-file-population path must build every
470 /// `PackageVersions` with an **empty** `available` list, never a populated one — an
471 /// empty `available` is what makes `requirement_is_unsatisfiable`'s FR-004 guard
472 /// suppress the check before any registry fetch has run. This is the exact function
473 /// `handle_document_open`'s background task calls, so a regression here (e.g.
474 /// swapping `latest_without_list` for `latest_only`) is caught directly, without
475 /// racing the background task.
476 #[test]
477 fn test_cached_versions_from_lockfile_has_empty_available() {
478 let mut resolved = HashMap::new();
479 resolved.insert(PackageName::new("serde"), "1.0.195".into());
480 resolved.insert(PackageName::new("tokio"), "1.35.0".into());
481
482 let cached = cached_versions_from_lockfile(&resolved);
483
484 assert_eq!(cached.len(), 2);
485 let serde = cached.get(&PackageName::new("serde")).unwrap();
486 assert_eq!(serde.latest, "1.0.195");
487 assert!(
488 serde.available.is_empty(),
489 "lock-file-populated entries must have an empty available list, got: {:?}",
490 serde.available
491 );
492 // #227 C3: a pinned version's age isn't actionable — never attach `published_at` here.
493 assert_eq!(serde.published_at, None);
494 let tokio = cached.get(&PackageName::new("tokio")).unwrap();
495 assert_eq!(tokio.latest, "1.35.0");
496 assert!(tokio.available.is_empty());
497 assert_eq!(tokio.published_at, None);
498 }
499
500 #[test]
501 fn test_cached_versions_from_lockfile_empty_input_is_empty_output() {
502 let resolved = HashMap::new();
503 assert!(cached_versions_from_lockfile(&resolved).is_empty());
504 }
505
506 /// Issue #1424: `Absent` and `Failed` both collapse to a pair of empty maps via
507 /// `into_maps`, but are not the same outcome — `reload_ok` (what a caller gates a
508 /// resolved-version-move rescan signal on) must still tell them apart, as a compiler-
509 /// checked `match` rather than the prior doc-comment-only convention on the old
510 /// `(HashMap, HashMap, bool)` return shape.
511 #[test]
512 fn test_lockfile_load_distinguishes_absent_from_failed() {
513 assert!(LockfileLoad::Absent.reload_ok());
514 assert!(!LockfileLoad::Failed.reload_ok());
515 assert!(
516 LockfileLoad::Loaded {
517 versions: HashMap::new(),
518 candidates: HashMap::new(),
519 }
520 .reload_ok()
521 );
522
523 assert_eq!(
524 LockfileLoad::Absent.into_maps(),
525 (HashMap::new(), HashMap::new())
526 );
527 assert_eq!(
528 LockfileLoad::Failed.into_maps(),
529 (HashMap::new(), HashMap::new())
530 );
531 assert_ne!(LockfileLoad::Absent, LockfileLoad::Failed);
532 }
533
534 /// `parse_known_lockfile` end to end against a real `Cargo.lock`, not just the
535 /// nonexistent-path `Failed` case its own doctest covers.
536 #[cfg(feature = "cargo")]
537 #[tokio::test]
538 async fn test_parse_known_lockfile_loads_real_lockfile() {
539 let dir = tempfile::tempdir().expect("create temp dir");
540 let lockfile_path = dir.path().join("Cargo.lock");
541 std::fs::write(
542 &lockfile_path,
543 r#"# This file is automatically @generated by Cargo.
544version = 4
545
546[[package]]
547name = "serde"
548version = "1.0.195"
549source = "registry+https://github.com/rust-lang/crates.io-index"
550"#,
551 )
552 .expect("write Cargo.lock");
553
554 let provider = deps_cargo::lockfile::CargoLockParser;
555 let cache = LockFileCache::new();
556
557 match parse_known_lockfile(&cache, &provider, &lockfile_path).await {
558 LockfileLoad::Loaded { versions, .. } => {
559 assert_eq!(
560 versions.get(&PackageName::new("serde")),
561 Some(&ConcreteVersion::from("1.0.195"))
562 );
563 }
564 other => panic!("expected Loaded, got {other:?}"),
565 }
566 }
567}