deps_engine/classify/diff.rs
1//! Merging a completed registry fetch's deprecation and no-comparable-versions findings
2//! into a document's outcome map.
3//!
4//! Extracted from `deps-lsp`'s `document/diff.rs` (issue #1059): both functions here decide
5//! what a fetch result means for `DependencyOutcomes`, not how or when to fetch — editor-only
6//! cache reconciliation (`preserve_cache`, `drop_cache_for_forced_refetch`) stays in `deps-lsp`
7//! since it mutates `DocumentState` fields these functions never touch.
8
9use deps_core::Deprecation;
10use deps_core::PackageName;
11use deps_core::lsp_helpers::DependencyOutcomes;
12use std::collections::{HashMap, HashSet};
13
14/// Merges a partial fetch's #205 deprecation findings into `outcomes`' deprecation channel
15/// (incremental didChange path — S1).
16///
17/// Without the clearing half of this (S1), a package that stops being deprecated
18/// (`npm deprecate pkg ""`) would keep a stale finding for the document's lifetime —
19/// nothing else ever removes one (a package-level finding does not become stale on a
20/// version-only edit — see the comment above `diff.version_changed`'s pruning loop in
21/// `deps-lsp`'s `handle_document_change`).
22///
23/// `fetched_names` — every raw package name successfully fetched this round (i.e. the
24/// keys of `fetch_result.versions`, captured before it is consumed) — must clear any
25/// previously-recorded finding when `fetched_deprecations` has no entry for it ("fetched
26/// and clean"); a name *not* fetched this round (untouched by `deps_to_fetch`) must not
27/// be touched at all, which is why this takes the explicit fetched-name list rather than
28/// iterating `outcomes` itself.
29///
30/// # Examples
31///
32/// ```
33/// use deps_core::Deprecation;
34/// use deps_core::lsp_helpers::{
35/// DependencyOutcomes, DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming,
36/// PackageRendering, RequirementResolution, SourcePolicy,
37/// };
38/// use deps_core::{ConcreteVersion, PackageName};
39/// use deps_engine::classify::diff::merge_deprecations_after_fetch;
40/// use std::collections::HashMap;
41///
42/// struct SimpleFormatter;
43/// impl PackageNaming for SimpleFormatter {}
44/// impl PackageRendering for SimpleFormatter {
45/// fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
46/// version.to_string()
47/// }
48/// fn package_url(&self, name: &PackageName) -> String {
49/// name.as_str().to_string()
50/// }
51/// }
52/// impl RequirementResolution for SimpleFormatter {}
53/// impl DiagnosticMessages for SimpleFormatter {}
54/// impl DiagnosticPolicy for SimpleFormatter {}
55/// impl SourcePolicy for SimpleFormatter {}
56/// impl OsvNaming for SimpleFormatter {}
57///
58/// let mut outcomes = DependencyOutcomes::new();
59/// outcomes.set_deprecation(
60/// "old-finding".to_string(),
61/// Deprecation { reason: None, replacement: None },
62/// );
63///
64/// // "old-finding" was re-fetched this round and no longer reports a finding, so its
65/// // stale marker is cleared; a name never touched by this round's fetch is left alone.
66/// merge_deprecations_after_fetch(
67/// &mut outcomes,
68/// &[PackageName::new("old-finding")],
69/// HashMap::new(),
70/// &SimpleFormatter,
71/// );
72///
73/// assert!(outcomes.deprecation("old-finding").is_none());
74/// ```
75pub fn merge_deprecations_after_fetch(
76 outcomes: &mut DependencyOutcomes,
77 fetched_names: &[PackageName],
78 mut fetched_deprecations: HashMap<PackageName, Deprecation>,
79 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
80) {
81 // I2: decide per normalized name in one pass, not incrementally per raw name — raw
82 // names sharing a normalized key (e.g. Composer's case-insensitive `require`) would
83 // otherwise flip the outcome based on `fetched_names`' unspecified HashMap iteration order.
84 let mut per_normalized: HashMap<String, Option<Deprecation>> = HashMap::new();
85 for name in fetched_names {
86 let normalized = formatter.normalize_package_name(name);
87 let found = fetched_deprecations.remove(name);
88 let entry = per_normalized.entry(normalized).or_insert(None);
89 if entry.is_none() {
90 *entry = found;
91 }
92 }
93 for (normalized, deprecation) in per_normalized {
94 match deprecation {
95 Some(deprecation) => {
96 outcomes.set_deprecation(normalized, deprecation);
97 }
98 None => {
99 outcomes.clear_deprecation(&normalized);
100 }
101 }
102 }
103}
104
105/// Merges a partial fetch's #550 no-comparable-versions findings into `outcomes`'
106/// corresponding channel (incremental didChange path).
107///
108/// Package-level, like [`merge_deprecations_after_fetch`] (not tied to the declared
109/// version, unlike `yanked`/`fetch_failure` — see the `diff.version_changed` pruning
110/// loop in `deps-lsp`'s `handle_document_change` for why those two, but not this one, are
111/// cleared on a version-only edit): if a package's registry situation improves between
112/// fetches (a real tag gets published), a stale marker must not survive for the document's
113/// lifetime.
114///
115/// `attempted_names` — every raw package name a fetch was actually attempted for this
116/// round (`dep_sources`' keys, captured before it is consumed) — rather than
117/// [`merge_deprecations_after_fetch`]'s `fetched_names` (`fetch_result.versions`'
118/// keys): a no-comparable-versions package is by definition never a member of
119/// `fetch_result.versions`, so deriving "attempted" from that map's keys would miss
120/// every package this function exists to clear or set.
121///
122/// # Examples
123///
124/// ```
125/// use deps_core::lsp_helpers::{
126/// DependencyOutcomes, DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming,
127/// PackageRendering, RequirementResolution, SourcePolicy,
128/// };
129/// use deps_core::{ConcreteVersion, PackageName};
130/// use deps_engine::classify::diff::merge_no_comparable_versions_after_fetch;
131/// use std::collections::HashSet;
132///
133/// struct SimpleFormatter;
134/// impl PackageNaming for SimpleFormatter {}
135/// impl PackageRendering for SimpleFormatter {
136/// fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
137/// version.to_string()
138/// }
139/// fn package_url(&self, name: &PackageName) -> String {
140/// name.as_str().to_string()
141/// }
142/// }
143/// impl RequirementResolution for SimpleFormatter {}
144/// impl DiagnosticMessages for SimpleFormatter {}
145/// impl DiagnosticPolicy for SimpleFormatter {}
146/// impl SourcePolicy for SimpleFormatter {}
147/// impl OsvNaming for SimpleFormatter {}
148///
149/// let mut outcomes = DependencyOutcomes::new();
150/// let mut fetched = HashSet::new();
151/// fetched.insert(PackageName::new("dtolnay-rust-toolchain"));
152///
153/// merge_no_comparable_versions_after_fetch(
154/// &mut outcomes,
155/// &[PackageName::new("dtolnay-rust-toolchain")],
156/// fetched,
157/// &SimpleFormatter,
158/// );
159///
160/// assert!(outcomes.no_comparable_versions("dtolnay-rust-toolchain"));
161/// ```
162pub fn merge_no_comparable_versions_after_fetch(
163 outcomes: &mut DependencyOutcomes,
164 attempted_names: &[PackageName],
165 mut fetched_no_comparable_versions: HashSet<PackageName>,
166 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
167) {
168 // Same normalized-name dedup rationale as `merge_deprecations_after_fetch` (I2).
169 let mut per_normalized: HashMap<String, bool> = HashMap::new();
170 for name in attempted_names {
171 let normalized = formatter.normalize_package_name(name);
172 let found = fetched_no_comparable_versions.remove(name);
173 let entry = per_normalized.entry(normalized).or_insert(false);
174 *entry = *entry || found;
175 }
176 for (normalized, found) in per_normalized {
177 if found {
178 outcomes.set_no_comparable_versions(normalized);
179 } else {
180 outcomes.clear_no_comparable_versions(&normalized);
181 }
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 #[cfg(feature = "cargo")]
188 mod cargo_tests {
189 use super::super::*;
190 use crate::setup::CargoFormatter;
191
192 /// T4 (C3): a deprecation finding recorded on the full-fetch path must survive
193 /// a partial didChange fetch that does not re-fetch that package.
194 #[test]
195 fn test_merge_deprecations_after_fetch_retains_finding_for_name_not_refetched() {
196 let formatter = CargoFormatter;
197 let mut outcomes = DependencyOutcomes::new();
198 outcomes.set_deprecation(
199 "vendor/a".to_string(),
200 Deprecation {
201 reason: None,
202 replacement: Some("vendor/a2".to_string()),
203 },
204 );
205
206 // Only "vendor/b" was fetched this round (e.g. a new dependency added by
207 // the edit); "vendor/a" was untouched.
208 let mut fetched = HashMap::new();
209 fetched.insert(
210 PackageName::new("vendor/b"),
211 Deprecation {
212 reason: Some("abandoned".to_string()),
213 replacement: None,
214 },
215 );
216 merge_deprecations_after_fetch(
217 &mut outcomes,
218 &[PackageName::new("vendor/b")],
219 fetched,
220 &formatter,
221 );
222
223 assert_eq!(
224 outcomes.deprecation("vendor/a"),
225 Some(&Deprecation {
226 reason: None,
227 replacement: Some("vendor/a2".to_string()),
228 }),
229 "a finding for a name not in this round's fetch must survive untouched"
230 );
231 assert_eq!(
232 outcomes.deprecation("vendor/b"),
233 Some(&Deprecation {
234 reason: Some("abandoned".to_string()),
235 replacement: None,
236 })
237 );
238 }
239
240 /// T5 (S1): a package that stops being deprecated must have its finding
241 /// cleared once re-fetched clean — distinct from a name simply not fetched
242 /// this round (T4), which must be left untouched.
243 #[test]
244 fn test_merge_deprecations_after_fetch_clears_finding_when_refetched_clean() {
245 let formatter = CargoFormatter;
246 let mut outcomes = DependencyOutcomes::new();
247 outcomes.set_deprecation(
248 "vendor/a".to_string(),
249 Deprecation {
250 reason: None,
251 replacement: None,
252 },
253 );
254
255 // "vendor/a" was re-fetched this round and no longer reports a finding.
256 merge_deprecations_after_fetch(
257 &mut outcomes,
258 &[PackageName::new("vendor/a")],
259 HashMap::new(),
260 &formatter,
261 );
262
263 assert!(
264 outcomes.deprecation("vendor/a").is_none(),
265 "a name that was fetched and produced no finding must be cleared"
266 );
267 }
268
269 /// Regression for critic finding C3 (#550): `merge_no_comparable_versions_after_fetch`'s
270 /// `found == true` branch (`set_no_comparable_versions`) had zero coverage — mirrors
271 /// `test_merge_deprecations_after_fetch_retains_finding_for_name_not_refetched`, but
272 /// for the *first-time-set* case: a package attempted this round whose fetch
273 /// genuinely found zero comparable versions must be recorded, and an unrelated
274 /// package not attempted this round must be left untouched either way.
275 #[test]
276 fn test_merge_no_comparable_versions_after_fetch_sets_finding_for_newly_flagged_name() {
277 let formatter = CargoFormatter;
278 let mut outcomes = DependencyOutcomes::new();
279
280 // "vendor/b" was attempted this round (e.g. a new dependency added by the
281 // edit) and its fetch genuinely succeeded with zero comparable versions;
282 // "vendor/a" was not attempted at all.
283 let mut fetched = HashSet::new();
284 fetched.insert(PackageName::new("vendor/b"));
285 merge_no_comparable_versions_after_fetch(
286 &mut outcomes,
287 &[PackageName::new("vendor/b")],
288 fetched,
289 &formatter,
290 );
291
292 assert!(
293 outcomes.no_comparable_versions("vendor/b"),
294 "a package whose fetch was attempted and found zero comparable versions \
295 this round must be recorded"
296 );
297 assert!(
298 !outcomes.no_comparable_versions("vendor/a"),
299 "a package never attempted this round must not be flagged"
300 );
301 }
302
303 /// Regression for critic finding C3 (#550): the literal "package no longer has
304 /// zero-comparable-versions on a subsequent fetch" scenario — e.g.
305 /// `dtolnay/rust-toolchain` eventually publishes a real `v1.2.3` tag. Mirrors
306 /// `test_merge_deprecations_after_fetch_clears_finding_when_refetched_clean`.
307 #[test]
308 fn test_merge_no_comparable_versions_after_fetch_clears_finding_when_refetched_with_versions()
309 {
310 let formatter = CargoFormatter;
311 let mut outcomes = DependencyOutcomes::new();
312 outcomes.set_no_comparable_versions("vendor/a".to_string());
313
314 // "vendor/a" was re-fetched this round and this time resolved a real
315 // version, so it's absent from the fetched-flags set.
316 merge_no_comparable_versions_after_fetch(
317 &mut outcomes,
318 &[PackageName::new("vendor/a")],
319 HashSet::new(),
320 &formatter,
321 );
322
323 assert!(
324 !outcomes.no_comparable_versions("vendor/a"),
325 "a package that was attempted and this time resolved a real version must \
326 have its stale marker cleared, or R5e would keep suppressing Unknown \
327 package diagnostics for a name that could now legitimately need one"
328 );
329 }
330
331 /// A finding for a name not attempted this round (e.g. an unrelated dependency
332 /// untouched by a partial didChange fetch) must survive untouched — distinct
333 /// from the clear-on-refetch case above.
334 #[test]
335 fn test_merge_no_comparable_versions_after_fetch_retains_finding_for_name_not_attempted() {
336 let formatter = CargoFormatter;
337 let mut outcomes = DependencyOutcomes::new();
338 outcomes.set_no_comparable_versions("vendor/a".to_string());
339
340 // Only "vendor/b" was attempted this round; "vendor/a" was untouched.
341 merge_no_comparable_versions_after_fetch(
342 &mut outcomes,
343 &[PackageName::new("vendor/b")],
344 HashSet::new(),
345 &formatter,
346 );
347
348 assert!(
349 outcomes.no_comparable_versions("vendor/a"),
350 "a finding for a name not attempted this round must survive untouched"
351 );
352 }
353 }
354
355 // Sibling to `cargo_tests` above (not nested inside it) so this test is reachable under
356 // `--features composer` alone.
357 #[cfg(feature = "composer")]
358 mod composer_tests {
359 use super::super::*;
360 use crate::setup::ComposerFormatter;
361
362 /// I2: two raw names that normalize to the same key (Composer's `normalize_package_name`
363 /// lowercases, so `"Vendor/Package"` and `"vendor/package"` collide) must merge
364 /// deterministically — a finding under either raw name must survive regardless of
365 /// `fetched_names`' (unspecified `HashMap::keys()`) iteration order.
366 #[test]
367 fn test_merge_deprecations_after_fetch_is_order_independent_across_normalization_collision()
368 {
369 let formatter = ComposerFormatter;
370
371 for names in [
372 [
373 PackageName::new("vendor/package"),
374 PackageName::new("Vendor/Package"),
375 ],
376 [
377 PackageName::new("Vendor/Package"),
378 PackageName::new("vendor/package"),
379 ],
380 ] {
381 let mut outcomes = DependencyOutcomes::new();
382
383 let mut fetched = HashMap::new();
384 fetched.insert(
385 PackageName::new("Vendor/Package"),
386 Deprecation {
387 reason: None,
388 replacement: Some("vendor/other".to_string()),
389 },
390 );
391 // "vendor/package" (lowercase) is fetched too and reports no finding.
392
393 merge_deprecations_after_fetch(&mut outcomes, &names, fetched, &formatter);
394
395 assert_eq!(
396 outcomes.deprecation("vendor/package"),
397 Some(&Deprecation {
398 reason: None,
399 replacement: Some("vendor/other".to_string()),
400 }),
401 "a finding under either raw name sharing a normalized key must survive, \
402 regardless of fetch order: {names:?}"
403 );
404 }
405 }
406 }
407}