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}