Skip to main content

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}