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;
10use deps_core::lsp_helpers::resolve_in_use_version;
11use std::collections::HashMap;
12use std::sync::Arc;
13
14/// Builds `dep_name -> [in_use_version, ...]` (§4.5/§4.6) for every dependency with a known
15/// in-use version, for the yanked-check probe in `fetch_latest_versions_parallel`.
16///
17/// Skips non-registry dependencies (git/path forks, step 0 of `build_scan_targets`'s ladder)
18/// so a patched fork is never flagged for a registry version it does not contain.
19///
20/// One entry per *occurrence* of a name, not a single collapsed value: the
21/// same dependency name can appear more than once in a manifest (the same
22/// crate under `[dependencies]`/`[dev-dependencies]` or multiple
23/// `[target.'cfg(...)'.dependencies]` blocks — #394). A HashMap keyed by
24/// name alone would silently drop all but the last occurrence's in-use
25/// version from the yanked probe below.
26///
27/// # Examples
28///
29/// ```
30/// use deps_core::lsp_helpers::{
31///     DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
32///     RequirementResolution, SourcePolicy,
33/// };
34/// use deps_core::test_util::stub_parse_result_with_dependencies;
35/// use deps_core::{ConcreteVersion, EcosystemId, PackageName};
36/// use deps_engine::classify::resolved::collect_in_use_versions;
37/// use std::collections::HashMap;
38///
39/// struct SimpleFormatter;
40/// impl PackageNaming for SimpleFormatter {}
41/// impl PackageRendering for SimpleFormatter {
42///     fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
43///         version.to_string()
44///     }
45///     fn package_url(&self, name: &PackageName) -> String {
46///         name.as_str().to_string()
47///     }
48/// }
49/// impl RequirementResolution for SimpleFormatter {}
50/// impl DiagnosticMessages for SimpleFormatter {}
51/// impl DiagnosticPolicy for SimpleFormatter {}
52/// impl SourcePolicy for SimpleFormatter {}
53/// impl OsvNaming for SimpleFormatter {}
54///
55/// // A single registry-sourced dependency ("dep-0"), with a lock-file-resolved version.
56/// let parsed = stub_parse_result_with_dependencies(1);
57/// let mut resolved_versions = HashMap::new();
58/// resolved_versions.insert(PackageName::new("dep-0"), ConcreteVersion::from("1.0.0"));
59///
60/// let in_use = collect_in_use_versions(
61///     parsed.as_ref(),
62///     &resolved_versions,
63///     &HashMap::new(),
64///     &SimpleFormatter,
65///     EcosystemId::Cargo,
66/// );
67/// assert_eq!(
68///     in_use.get(&PackageName::new("dep-0")),
69///     Some(&vec!["1.0.0".to_string()])
70/// );
71/// ```
72pub fn collect_in_use_versions(
73    parse_result: &dyn deps_core::ParseResult,
74    resolved_versions: &HashMap<PackageName, ConcreteVersion>,
75    resolved_version_candidates: &HashMap<PackageName, Vec<ConcreteVersion>>,
76    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
77    ecosystem: EcosystemId,
78) -> HashMap<PackageName, Vec<String>> {
79    let mut map: HashMap<PackageName, Vec<String>> = HashMap::new();
80    for dep in parse_result
81        .dependencies()
82        .into_iter()
83        .filter(|dep| formatter.source_is_public_registry_content(&dep.source()))
84    {
85        let normalized_name = formatter.normalize_package_name(dep.name());
86        if let Some(v) = resolve_in_use_version(
87            dep,
88            &normalized_name,
89            resolved_versions,
90            Some(resolved_version_candidates),
91            formatter,
92            ecosystem,
93        ) {
94            map.entry(dep.name().clone()).or_default().push(v);
95        }
96    }
97    map
98}
99
100/// Builds `name -> [version_requirement, ...]` for every dependency in `pr`, one entry per
101/// occurrence — the shape `DependencyDiff::compute` needs.
102///
103/// A `HashMap<PackageName, Option<VersionReq>>` (single value per name) would silently
104/// collapse a duplicate name to its last occurrence, losing any edit made to an earlier one
105/// (#394).
106///
107/// Occurrence order is whatever `pr.dependencies()` returns, which for
108/// `deps-cargo` is *not* document order for multiple `[target.*]` blocks
109/// (see `DependencyDiff::compute`'s doc). A consequence worth knowing: if
110/// an edit only renames a `[target.'cfg(...)'.dependencies]` expression
111/// (no version change), that occurrence can sort into a different position
112/// in the new `Vec` than the old one, so `old.get(name) != new.get(name)`
113/// trips even though every individual version requirement is unchanged —
114/// a spurious but harmless `version_changed` (one extra registry
115/// refetch/OSV rescan for that name, never a missed or misattributed one).
116///
117/// # Examples
118///
119/// ```
120/// use deps_core::PackageName;
121/// use deps_core::test_util::stub_parse_result_with_dependencies;
122/// use deps_engine::classify::resolved::dependency_version_map;
123///
124/// let parsed = stub_parse_result_with_dependencies(2);
125/// let map = dependency_version_map(parsed.as_ref());
126///
127/// assert_eq!(map.len(), 2);
128/// assert_eq!(map.get(&PackageName::new("dep-0")), Some(&vec![None]));
129/// ```
130pub fn dependency_version_map(
131    pr: &dyn deps_core::ParseResult,
132) -> HashMap<PackageName, Vec<Option<VersionReq>>> {
133    let mut map: HashMap<PackageName, Vec<Option<VersionReq>>> = HashMap::new();
134    for d in pr.dependencies() {
135        map.entry(d.name().clone())
136            .or_default()
137            .push(d.version_requirement().cloned());
138    }
139    map
140}
141
142/// Builds a `cached_versions` map from lock-file-resolved versions, ahead of any registry
143/// fetch.
144///
145/// `available` is deliberately left empty (`PackageVersions::latest_without_list`, not a
146/// plausible-looking one-element list) — this runs before any registry fetch, and
147/// `requirement_is_unsatisfiable` treats an empty `available` as "still loading, skip"
148/// (FR-004). Using `latest_only` here instead would populate a bogus single-entry list and
149/// let the unsatisfiable-requirement check compute a false verdict on every document open,
150/// before the fetch that's supposed to suppress it has a chance to run.
151///
152/// # Examples
153///
154/// ```
155/// use deps_core::PackageName;
156/// use deps_engine::classify::resolved::cached_versions_from_lockfile;
157/// use std::collections::HashMap;
158///
159/// let mut resolved = HashMap::new();
160/// resolved.insert(PackageName::new("serde"), "1.0.195".into());
161///
162/// let cached = cached_versions_from_lockfile(&resolved);
163///
164/// let serde = cached.get(&PackageName::new("serde")).unwrap();
165/// assert_eq!(serde.latest, "1.0.195");
166/// assert!(serde.available.is_empty());
167/// ```
168pub fn cached_versions_from_lockfile(
169    resolved: &HashMap<PackageName, ConcreteVersion>,
170) -> HashMap<PackageName, PackageVersions> {
171    resolved
172        .iter()
173        .map(|(name, version)| {
174            (
175                name.clone(),
176                PackageVersions::latest_without_list(version.clone()),
177            )
178        })
179        .collect()
180}
181
182/// Splits a parsed [`deps_core::lockfile::ResolvedPackages`] into two maps.
183///
184/// The collapsed `dep_name -> version` map (`ResolvedPackages::iter`, unchanged FR-005 fast
185/// path) and a sibling `dep_name -> [version, ...]` map (issue #649) holding every retained
186/// lock-file entry for names with more than one — built from `ResolvedPackages::iter_all`,
187/// and deliberately omitting a single-occurrence name entirely (NFR-003: the common case
188/// never pays for a candidates-map lookup).
189///
190/// Shared by [`load_resolved_versions`] and `deps-lsp`'s watched-lock-file-change handler —
191/// both re-parse a lock file and need the identical split.
192///
193/// # Examples
194///
195/// ```
196/// use deps_core::lockfile::{ResolvedPackage, ResolvedPackages, ResolvedSource};
197/// use deps_engine::classify::resolved::split_resolved_packages;
198///
199/// let mut resolved = ResolvedPackages::new();
200/// resolved.insert(ResolvedPackage::new(
201///     "serde".into(),
202///     "1.0.195".into(),
203///     ResolvedSource::Registry {
204///         url: "https://github.com/rust-lang/crates.io-index".into(),
205///         checksum: "abc123".into(),
206///     },
207/// ));
208///
209/// let (versions, candidates) = split_resolved_packages(&resolved);
210/// assert_eq!(versions.len(), 1);
211/// assert!(
212///     candidates.is_empty(),
213///     "a single-occurrence name has no candidates entry"
214/// );
215/// ```
216pub fn split_resolved_packages(
217    resolved: &deps_core::lockfile::ResolvedPackages,
218) -> (
219    HashMap<PackageName, ConcreteVersion>,
220    HashMap<PackageName, Vec<ConcreteVersion>>,
221) {
222    let versions = resolved
223        .iter()
224        .map(|(name, pkg)| (PackageName::new(name.as_str()), pkg.version.clone().into()))
225        .collect();
226    let candidates = resolved
227        .iter_all()
228        .filter(|(_, versions)| versions.len() > 1)
229        .map(|(name, versions)| {
230            (
231                PackageName::new(name.as_str()),
232                versions
233                    .iter()
234                    .map(|pkg| ConcreteVersion::from(pkg.version.clone()))
235                    .collect(),
236            )
237        })
238        .collect();
239    (versions, candidates)
240}
241
242/// Loads resolved versions from lock file for a given manifest URI.
243///
244/// Uses the ecosystem's lockfile provider to parse the lock file, then
245/// [`split_resolved_packages`]. Both returned maps are empty if no lock file is found or
246/// parsing fails.
247///
248/// # Examples
249///
250/// ```
251/// use deps_core::HttpCache;
252/// use deps_core::lockfile::LockFileCache;
253/// use deps_core::test_util::test_uri;
254/// use deps_engine::classify::resolved::load_resolved_versions;
255/// use deps_engine::setup::CargoEcosystem;
256/// use std::sync::Arc;
257///
258/// #[tokio::main]
259/// async fn main() {
260///     let ecosystem = CargoEcosystem::new(Arc::new(HttpCache::new()));
261///     let lockfile_cache = Arc::new(LockFileCache::new());
262///     // No `Cargo.lock` exists at this synthetic path, so both maps come back empty —
263///     // the same fast path a manifest with no lock file takes in production.
264///     let uri = test_uri("/nonexistent-for-doctest/Cargo.toml");
265///
266///     let (versions, candidates) =
267///         load_resolved_versions(&uri, &lockfile_cache, &ecosystem).await;
268///     assert!(versions.is_empty());
269///     assert!(candidates.is_empty());
270/// }
271/// ```
272pub async fn load_resolved_versions(
273    uri: &url::Url,
274    lockfile_cache: &Arc<LockFileCache>,
275    ecosystem: &dyn Ecosystem,
276) -> (
277    HashMap<PackageName, ConcreteVersion>,
278    HashMap<PackageName, Vec<ConcreteVersion>>,
279) {
280    let lock_provider = match ecosystem.lockfile_provider() {
281        Some(p) => p,
282        None => {
283            tracing::debug!("No lock file provider for ecosystem {}", ecosystem.id());
284            return (HashMap::new(), HashMap::new());
285        }
286    };
287
288    // `locate_lockfile` does a synchronous ancestor-directory stat walk; run in
289    // `spawn_blocking` rather than inline on the tokio worker (#963).
290    let lock_provider_for_locate = Arc::clone(&lock_provider);
291    let uri_for_locate = uri.clone();
292    let located = tokio::task::spawn_blocking(move || {
293        lock_provider_for_locate.locate_lockfile(&uri_for_locate)
294    })
295    .await;
296
297    let lockfile_path = match located {
298        Ok(Some(path)) => path,
299        Ok(None) => {
300            tracing::debug!("No lock file found for {:?}", uri);
301            return (HashMap::new(), HashMap::new());
302        }
303        Err(e) => {
304            tracing::warn!("Lock file discovery task panicked for {:?}: {}", uri, e);
305            return (HashMap::new(), HashMap::new());
306        }
307    };
308
309    match lockfile_cache
310        .get_or_parse(lock_provider.as_ref(), &lockfile_path)
311        .await
312    {
313        Ok(resolved) => {
314            tracing::info!(
315                "Loaded {} resolved versions from {}",
316                resolved.len(),
317                lockfile_path.display()
318            );
319            split_resolved_packages(&resolved)
320        }
321        Err(e) => {
322            tracing::warn!("Failed to parse lock file: {}", e);
323            (HashMap::new(), HashMap::new())
324        }
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    /// N5 regression guard: the lock-file-population path must build every
333    /// `PackageVersions` with an **empty** `available` list, never a populated one — an
334    /// empty `available` is what makes `requirement_is_unsatisfiable`'s FR-004 guard
335    /// suppress the check before any registry fetch has run. This is the exact function
336    /// `handle_document_open`'s background task calls, so a regression here (e.g.
337    /// swapping `latest_without_list` for `latest_only`) is caught directly, without
338    /// racing the background task.
339    #[test]
340    fn test_cached_versions_from_lockfile_has_empty_available() {
341        let mut resolved = HashMap::new();
342        resolved.insert(PackageName::new("serde"), "1.0.195".into());
343        resolved.insert(PackageName::new("tokio"), "1.35.0".into());
344
345        let cached = cached_versions_from_lockfile(&resolved);
346
347        assert_eq!(cached.len(), 2);
348        let serde = cached.get(&PackageName::new("serde")).unwrap();
349        assert_eq!(serde.latest, "1.0.195");
350        assert!(
351            serde.available.is_empty(),
352            "lock-file-populated entries must have an empty available list, got: {:?}",
353            serde.available
354        );
355        // #227 C3: a pinned version's age isn't actionable — never attach `published_at` here.
356        assert_eq!(serde.published_at, None);
357        let tokio = cached.get(&PackageName::new("tokio")).unwrap();
358        assert_eq!(tokio.latest, "1.35.0");
359        assert!(tokio.available.is_empty());
360        assert_eq!(tokio.published_at, None);
361    }
362
363    #[test]
364    fn test_cached_versions_from_lockfile_empty_input_is_empty_output() {
365        let resolved = HashMap::new();
366        assert!(cached_versions_from_lockfile(&resolved).is_empty());
367    }
368}