Skip to main content

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}