Skip to main content

deps_core/lsp_helpers/
diagnostics.rs

1use std::collections::HashMap;
2
3use tower_lsp_server::ls_types::{
4    CodeDescription, Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, Location,
5    NumberOrString, Position, Range, Uri,
6};
7
8use crate::osv::{ScanOutcome, diagnostic_severity_for};
9use crate::{
10    ConcreteVersion, Dependency, Deprecation, FetchFailure, ParseResult, PublishTime,
11    RemovalStatus, VersionReq, format_relative_age, is_within_cooldown,
12};
13
14use super::{
15    EcosystemFormatter, PackageVersions, RequirementMatcher, RequirementStatus, VersionData,
16};
17
18/// Stable [`Diagnostic::code`] set on the unsatisfiable-requirement diagnostic.
19///
20/// Set by `generate_diagnostics_from_cache`, so `build_unsatisfiable_fix_action`'s
21/// stashed `CodeAction::data` can name it and the `deps-lsp` handler's diagnostic-binding
22/// step can match on it — the same mechanism `build_vulnerability_fix_action` uses with an
23/// advisory id, generalized to a constant since this diagnostic has no per-instance
24/// identifier.
25pub const UNSATISFIABLE_DIAGNOSTIC_CODE: &str = "unsatisfiable-requirement";
26
27/// Maximum character count of a blocked-registry diagnostic's raw declared value (an alias
28/// or literal URL) before it is truncated with an ellipsis marker.
29///
30/// The value is attacker-controlled — an arbitrary string from a cloned repository's
31/// `Cargo.toml`/`.cargo/config.toml` — and nothing upstream in the TOML parse pipeline caps
32/// an individual string field's length (only nesting depth and table count are bounded), so
33/// this is the last chokepoint before it renders inline in the editor as a
34/// [`DiagnosticSeverity::INFORMATION`] message.
35const MAX_BLOCKED_REGISTRY_MESSAGE_VALUE_CHARS: usize = 128;
36
37/// Truncates `value` to at most `max_chars` characters, appending `…` when truncated.
38///
39/// So an attacker-controlled string interpolated into a diagnostic message can never
40/// render an unbounded amount of text inline in the editor. Counts characters, not bytes,
41/// so a truncation point never lands mid-character (`value` may contain multi-byte
42/// UTF-8). `pub`, not module-private: `deps-github-actions`' mutable-ref-pin diagnostic
43/// (issue #473) reuses this same chokepoint for its own attacker-controlled `name`/`tag`
44/// interpolation, rather than duplicating the truncation logic in a second crate.
45///
46/// # Examples
47///
48/// ```
49/// use deps_core::lsp_helpers::truncate_for_diagnostic;
50///
51/// assert_eq!(truncate_for_diagnostic("short", 128), "short");
52/// assert_eq!(truncate_for_diagnostic(&"a".repeat(200), 5), "aaaaa…");
53/// ```
54pub fn truncate_for_diagnostic(value: &str, max_chars: usize) -> std::borrow::Cow<'_, str> {
55    if value.chars().count() <= max_chars {
56        return std::borrow::Cow::Borrowed(value);
57    }
58    let mut truncated: String = value.chars().take(max_chars).collect();
59    truncated.push('…');
60    std::borrow::Cow::Owned(truncated)
61}
62
63/// Stable [`Diagnostic::code`] set on the package-level deprecation diagnostic (issue #205).
64///
65/// Mirrors [`UNSATISFIABLE_DIAGNOSTIC_CODE`] — lets `build_replacement_action`'s stashed
66/// `CodeAction::data` name it so the `deps-lsp` handler's diagnostic-binding step can
67/// attach the "Replace with X" quickfix to the diagnostic it resolves.
68pub const DEPRECATED_DIAGNOSTIC_CODE: &str = "deprecated-package";
69
70/// Diagnostic severity levels for the four per-dependency issue categories.
71///
72/// Threaded from `DiagnosticsConfig` (`deps-lsp`) through
73/// [`crate::Ecosystem::generate_diagnostics`] into [`generate_diagnostics_from_cache`].
74///
75/// # Examples
76///
77/// ```
78/// use deps_core::DiagnosticSeverities;
79/// use tower_lsp_server::ls_types::DiagnosticSeverity;
80///
81/// let severities = DiagnosticSeverities::default();
82/// assert_eq!(severities.outdated, DiagnosticSeverity::HINT);
83/// assert_eq!(severities.unknown, DiagnosticSeverity::WARNING);
84/// assert_eq!(severities.yanked, DiagnosticSeverity::WARNING);
85/// assert_eq!(severities.unsatisfiable, DiagnosticSeverity::WARNING);
86/// assert_eq!(severities.deprecated, DiagnosticSeverity::WARNING);
87/// assert_eq!(severities.mutable_ref_pin, DiagnosticSeverity::HINT);
88/// assert!(severities.mutable_ref_pin_enabled);
89/// ```
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct DiagnosticSeverities {
92    /// Severity for a dependency with a newer version available.
93    pub outdated: DiagnosticSeverity,
94    /// Severity for a dependency not found in the registry (or with an invalid name).
95    pub unknown: DiagnosticSeverity,
96    /// Severity for a dependency pinned to a yanked/deprecated version.
97    pub yanked: DiagnosticSeverity,
98    /// Severity for a dependency whose requirement matches zero published versions.
99    pub unsatisfiable: DiagnosticSeverity,
100    /// Severity for a dependency on a package the registry reports as
101    /// deprecated/abandoned (issue #205).
102    pub deprecated: DiagnosticSeverity,
103    /// Severity for a GitHub Actions `uses:` step pinned to a mutable ref (a tag)
104    /// instead of a full commit SHA (issue #473). Unused by every ecosystem except
105    /// `deps-github-actions` today — see that crate's
106    /// `MUTABLE_REF_PIN_DIAGNOSTIC_CODE` for the diagnostic this severity gates.
107    /// Tunes loudness only; see [`Self::mutable_ref_pin_enabled`] for the on/off
108    /// toggle.
109    pub mutable_ref_pin: DiagnosticSeverity,
110    /// Whether the mutable-ref-pin diagnostic (issue #473) runs at all, unused by
111    /// every ecosystem except `deps-github-actions`. Unlike every other field in
112    /// this struct, `mutable_ref_pin` alone cannot silence the diagnostic —
113    /// `DiagnosticSeverity` has no suppression value, and severity is never treated
114    /// as a suppression input anywhere in this codebase — so this diagnostic
115    /// additionally needs a real presence toggle, mirroring
116    /// `deps_lsp::config::DiagnosticsConfig::vulnerabilities_enabled`'s shape.
117    pub mutable_ref_pin_enabled: bool,
118}
119
120impl Default for DiagnosticSeverities {
121    fn default() -> Self {
122        Self {
123            outdated: DiagnosticSeverity::HINT,
124            unknown: DiagnosticSeverity::WARNING,
125            yanked: DiagnosticSeverity::WARNING,
126            unsatisfiable: DiagnosticSeverity::WARNING,
127            deprecated: DiagnosticSeverity::WARNING,
128            mutable_ref_pin: DiagnosticSeverity::HINT,
129            mutable_ref_pin_enabled: true,
130        }
131    }
132}
133
134/// Shared shape for a [`crate::lsp_helpers::RequirementResolution::compile_requirement`] guarded by one predicate.
135///
136/// This is the pattern several ecosystems' guards independently re-implemented
137/// (`deps-go`'s pseudo-version check, `deps-composer`'s dev-branch/`@dev` check,
138/// `deps-bundler`'s exact-pin check, `deps-maven`/`deps-gradle`'s malformed-range check,
139/// `deps-nuget`'s malformed-requirement check). See
140/// [`crate::lsp_helpers::RequirementResolution::compile_requirement`]'s docs for why `None` is correct in exactly
141/// this case: `is_undecidable(requirement)` true means the fetched `available` list
142/// structurally cannot contain a version that would decide the match either way, so scanning
143/// it would always report `Some(false)` and produce a false "no published version satisfies
144/// this requirement" diagnostic.
145///
146/// Returns `None` when `is_undecidable(requirement)` is `true`. Otherwise builds `matcher`
147/// from `requirement`'s owned `String` and boxes it as the trait object
148/// [`crate::lsp_helpers::RequirementResolution::compile_requirement`] returns.
149///
150/// Ecosystems whose guard is a fallible parse rather than a named predicate over the
151/// requirement string (`deps-cargo`, `deps-npm`, `deps-pypi`, `deps-swift`) don't fit this
152/// shape and implement `compile_requirement` directly via `.ok().map(...)` instead.
153/// `deps-dart` implements `compile_requirement` but has no guard at all — every requirement
154/// string is a valid Dart constraint by construction, so it is always `Some`.
155///
156/// # Examples
157///
158/// ```
159/// use deps_core::lsp_helpers::{compile_requirement_unless, RequirementMatcher};
160/// use deps_core::ConcreteVersion;
161///
162/// struct ExactMatcher(String);
163/// impl RequirementMatcher for ExactMatcher {
164///     fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
165///         Some(version.as_str() == self.0)
166///     }
167/// }
168///
169/// let is_pseudo_version = |r: &str| r.starts_with("v0.0.0-");
170///
171/// assert!(
172///     compile_requirement_unless(
173///         "v0.0.0-20191109021931-daa7c04131f5",
174///         is_pseudo_version,
175///         ExactMatcher,
176///     )
177///     .is_none()
178/// );
179/// assert!(compile_requirement_unless("v1.2.3", is_pseudo_version, ExactMatcher).is_some());
180/// ```
181pub fn compile_requirement_unless<M>(
182    requirement: &str,
183    is_undecidable: impl FnOnce(&str) -> bool,
184    matcher: impl FnOnce(String) -> M,
185) -> Option<Box<dyn RequirementMatcher>>
186where
187    M: RequirementMatcher + 'static,
188{
189    if is_undecidable(requirement) {
190        return None;
191    }
192    Some(Box::new(matcher(requirement.to_string())))
193}
194
195/// Requirement strings longer than this are rejected by [`requirement_is_unsatisfiable`]
196/// before compilation, rather than compiled and scanned. No real manifest requirement in any
197/// supported ecosystem approaches this length; it exists solely to bound the cost of an
198/// adversarial or corrupted requirement string. All eleven ecosystems' `compile_requirement`
199/// implementations now parse `requirement` exactly once per dependency and reuse the parsed
200/// form across every candidate in `matches` — Maven/Gradle/NuGet's `RequirementMatcher`s were
201/// the last holdouts re-parsing per candidate, fixed alongside this comment — so the scan
202/// itself is O(`available.len()`) in the size of the candidate list, not the requirement.
203/// This cap stays as defense-in-depth against the one-time parse cost: Maven's range union
204/// can still degrade non-linearly on a pathological multi-KB comma union, and a stray
205/// oversized string is never a real requirement, only a corrupted or adversarial one.
206const MAX_REQUIREMENT_LEN: usize = 256;
207
208/// Returns `true` when no published version satisfies `requirement`.
209///
210/// `available` must be non-empty, `requirement` must be a concrete (non-empty, resolved,
211/// not implausibly long) constraint, and no entry in `available` — of any kind: stable,
212/// prerelease, or yanked — may satisfy it. All of the following must hold for `true`:
213///
214/// 1. `!available.is_empty()` — an empty or not-yet-loaded list means "unknown", not
215///    "unsatisfiable" (FR-004: no diagnostic while loading or offline).
216/// 2. `!requirement.as_str().trim().is_empty()`.
217/// 3. `requirement.as_str().len() <= MAX_REQUIREMENT_LEN` — see that constant's docs; an
218///    oversized requirement is treated the same as "unmodellable" (suppressed, not warned).
219/// 4. `!formatter.requirement_is_unresolved(requirement)` (FR-005) — an unresolved
220///    placeholder requirement was never actually checked against anything.
221/// 5. `!formatter.requirement_is_undecidable_given_available(requirement, available)` — this
222///    ecosystem's registry can hide a published version that would have decided the match.
223/// 6. `formatter.compile_requirement(requirement)` returns `Some(matcher)` — this
224///    ecosystem opted in and the requirement string itself parses.
225/// 7. Scanning `available` with `matcher.matches`: **at least one** candidate returned
226///    `Some(false)`, and **none** returned `Some(true)`. Candidates returning `None`
227///    (unparseable candidate strings) are skipped and count toward neither side —
228///    condition 7's "at least one `Some(false)`" is load-bearing: if every candidate is
229///    unparseable, nothing was decided, so the verdict is `false` (no diagnostic) rather
230///    than a vacuous `true`.
231///
232/// The scan short-circuits on the first `Some(true)` — O(N) worst case, and the newest-first
233/// ordering of `available` means a satisfiable requirement typically exits within the first
234/// few entries.
235///
236/// # Examples
237///
238/// ```
239/// use deps_core::lsp_helpers::{
240///     requirement_is_unsatisfiable, DiagnosticMessages, DiagnosticPolicy, OsvNaming,
241///     PackageNaming, PackageRendering, RequirementMatcher, RequirementResolution, SourcePolicy,
242/// };
243/// use deps_core::{ConcreteVersion, PackageName, VersionReq};
244///
245/// struct ExactMatcher(String);
246/// impl RequirementMatcher for ExactMatcher {
247///     fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
248///         Some(version.as_str() == self.0)
249///     }
250/// }
251///
252/// struct ExactFormatter;
253/// impl PackageNaming for ExactFormatter {}
254/// impl PackageRendering for ExactFormatter {
255///     fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
256///         version.to_string()
257///     }
258///     fn package_url(&self, name: &PackageName) -> String {
259///         name.to_string()
260///     }
261/// }
262/// impl RequirementResolution for ExactFormatter {
263///     fn compile_requirement(
264///         &self,
265///         requirement: &VersionReq,
266///     ) -> Option<Box<dyn RequirementMatcher>> {
267///         Some(Box::new(ExactMatcher(requirement.as_str().to_string())))
268///     }
269/// }
270/// impl DiagnosticMessages for ExactFormatter {}
271/// impl DiagnosticPolicy for ExactFormatter {}
272/// impl SourcePolicy for ExactFormatter {}
273/// impl OsvNaming for ExactFormatter {}
274///
275/// let available = vec![ConcreteVersion::new("1.0.0"), ConcreteVersion::new("0.9.0")];
276/// assert!(requirement_is_unsatisfiable(
277///     &ExactFormatter,
278///     &VersionReq::new("2.0.0"),
279///     &available,
280/// ));
281/// assert!(!requirement_is_unsatisfiable(
282///     &ExactFormatter,
283///     &VersionReq::new("1.0.0"),
284///     &available,
285/// ));
286/// ```
287pub fn requirement_is_unsatisfiable(
288    formatter: &dyn EcosystemFormatter,
289    requirement: &VersionReq,
290    available: &[ConcreteVersion],
291) -> bool {
292    if available.is_empty() || requirement.as_str().trim().is_empty() {
293        return false;
294    }
295    if requirement.as_str().len() > MAX_REQUIREMENT_LEN {
296        return false;
297    }
298    if formatter.requirement_is_unresolved(requirement) {
299        return false;
300    }
301    if formatter.requirement_is_undecidable_given_available(requirement, available) {
302        return false;
303    }
304    let Some(matcher) = formatter.compile_requirement(requirement) else {
305        return false;
306    };
307
308    let mut saw_decided_false = false;
309    for candidate in available {
310        match matcher.matches(candidate) {
311            Some(true) => return false,
312            Some(false) => saw_decided_false = true,
313            None => {}
314        }
315    }
316    saw_decided_false
317}
318
319/// Splits a strict-SemVer version string's stable `X.Y.Z` core from its pre-release
320/// identifier, if `version` carries one — e.g. `"2.0.0-rc.1"` -> `Some("2.0.0")`,
321/// `"2.0.0-rc.1+build.5"` -> `Some("2.0.0")`, `"2.0.0"` -> `None`.
322///
323/// `None` means `version` is already a stable release, not that it failed to parse — this
324/// is a textual SemVer split, not a validating parse. Callers only rely on it for
325/// strict-SemVer ecosystems (see
326/// [`EcosystemFormatter::strict_semver_prerelease_exclusion`]), whose registries only
327/// publish spec-conformant version strings.
328fn semver_prerelease_base(version: &str) -> Option<&str> {
329    let core = version.split('+').next().unwrap_or(version);
330    core.find('-').map(|dash| &core[..dash])
331}
332
333/// Reports whether `requirement` itself already names a pre-release tag — a `-` embedded
334/// directly in a version token (no surrounding whitespace), as opposed to a whitespace-padded
335/// hyphen range operator (npm's `"1.2.3 - 2.3.4"`).
336///
337/// Guards [`matching_prerelease_would_satisfy`] (#299 S1): when the requirement already pins
338/// to a pre-release tuple (e.g. `^2.0.0-rc.5`), a published pre-release that fails to match is
339/// rejected by ordinary version *ordering* against that explicit floor, not by SemVer's
340/// default pre-release exclusion — enriching the message in that case would misattribute the
341/// cause.
342fn requirement_names_prerelease(requirement: &str) -> bool {
343    let bytes = requirement.as_bytes();
344    bytes.iter().enumerate().any(|(i, &b)| {
345        b == b'-'
346            && i > 0
347            && i + 1 < bytes.len()
348            && !bytes[i - 1].is_ascii_whitespace()
349            && !bytes[i + 1].is_ascii_whitespace()
350    })
351}
352
353/// For strict-SemVer ecosystems (see
354/// [`EcosystemFormatter::strict_semver_prerelease_exclusion`]), finds the newest published,
355/// non-yanked pre-release in `available` whose stable core would satisfy `requirement` —
356/// evidence that `requirement` reads as unsatisfiable only because SemVer's default comparator
357/// excludes pre-releases, not because no compatible version was ever published (#299).
358///
359/// Returns `None` when the ecosystem hasn't opted in, `requirement` itself already names a
360/// pre-release (see [`requirement_names_prerelease`] — in that shape a non-matching candidate
361/// is rejected by ordering against the requirement's own explicit floor, not by pre-release
362/// exclusion), `requirement` doesn't compile, or no such pre-release exists. `available` is
363/// assumed newest-first (see [`PackageVersions::available`]), so the first match found is the
364/// newest.
365fn matching_prerelease_would_satisfy(
366    formatter: &dyn EcosystemFormatter,
367    requirement: &VersionReq,
368    available: &[ConcreteVersion],
369    yanked: &[(ConcreteVersion, RemovalStatus)],
370) -> Option<String> {
371    if !formatter.strict_semver_prerelease_exclusion() {
372        return None;
373    }
374    if requirement_names_prerelease(requirement.as_str()) {
375        return None;
376    }
377    let matcher = formatter.compile_requirement(requirement)?;
378    available.iter().find_map(|candidate| {
379        let base = semver_prerelease_base(candidate.as_str())?;
380        (!yanked.iter().any(|(y, _)| y == candidate)
381            && matcher.matches(&ConcreteVersion::new(base)) == Some(true))
382        .then(|| candidate.to_string())
383    })
384}
385
386/// Looks up `candidate`'s [`RemovalStatus`] in `yanked`, preferring `Yanked` over
387/// `AdvisoryDeprecated` when more than one entry shares `candidate`'s version string.
388///
389/// A registry response can carry duplicate entries for the same version string (see
390/// `lifecycle.rs`'s in-use-version scan, which guards against exactly this by not stopping
391/// at the first same-string match). `RemovalStatus` derives no `Ord`, so this can't be a
392/// `.max()` — `Yanked` is explicitly preferred so a mixed-status duplicate never hides a
393/// genuine hard yank behind a merely-deprecated entry for the same version (#437 M2).
394fn status_for_version(
395    yanked: &[(ConcreteVersion, RemovalStatus)],
396    candidate: &ConcreteVersion,
397) -> Option<RemovalStatus> {
398    let mut found: Option<RemovalStatus> = None;
399    for (y, status) in yanked {
400        if y == candidate {
401            if *status == RemovalStatus::Yanked {
402                return Some(RemovalStatus::Yanked);
403            }
404            found = Some(*status);
405        }
406    }
407    found
408}
409
410/// Returns the aggregate [`RemovalStatus`] when `requirement` is satisfied by at least one
411/// entry in `available`, but every matching entry is yanked/deprecated — i.e. the dependency
412/// is currently satisfiable only by a flagged version — and `None` otherwise.
413///
414/// The aggregate is `Yanked` if any matching entry's status is `Yanked`, else
415/// `AdvisoryDeprecated` (the only other status `yanked` entries carry — see
416/// [`PackageVersions::yanked`]). This lets the caller apply the same D5 gate #263 uses
417/// (see [`VersionData::yanked`]'s docs): suppress the diagnostic for an `AdvisoryDeprecated`
418/// aggregate when a package-level deprecation finding co-occurs, but never for a `Yanked`
419/// one, even if one of several matching entries is merely deprecated (#437).
420///
421/// Mutually exclusive with [`requirement_is_unsatisfiable`]: both scan `available` through the
422/// same `formatter.compile_requirement` matcher, but this one additionally cross-references
423/// `yanked` to distinguish "satisfied, but only by a flagged version" from "satisfied by an
424/// ordinary version" or "not satisfied at all". Callers should only invoke this once
425/// `requirement_is_unsatisfiable` has returned `false` for the same `requirement`/`available`
426/// pair, so a match is already known to exist.
427///
428/// Shares `requirement_is_unsatisfiable`'s guard cascade (empty `available`/`requirement`,
429/// oversized `requirement`, unresolved placeholder `requirement`, uncompilable `requirement`)
430/// — each returns `None` here for the identical reason it does there.
431///
432/// Unlike `requirement_is_unsatisfiable`, an undecided candidate (`matcher.matches` returns
433/// `None` — an unparseable candidate string) does not just get skipped: it disqualifies a
434/// verdict entirely. That candidate might have been a genuine non-yanked match this scan
435/// simply could not evaluate, so claiming "every match is flagged" without accounting for it
436/// would be a false positive — the same #206 conservatism (nothing decided means no
437/// diagnostic, not a guess) applied to a different question than `requirement_is_unsatisfiable`
438/// asks.
439fn requirement_matches_only_yanked(
440    formatter: &dyn EcosystemFormatter,
441    requirement: &VersionReq,
442    available: &[ConcreteVersion],
443    yanked: &[(ConcreteVersion, RemovalStatus)],
444) -> Option<RemovalStatus> {
445    if available.is_empty() || yanked.is_empty() || requirement.as_str().trim().is_empty() {
446        return None;
447    }
448    if requirement.as_str().len() > MAX_REQUIREMENT_LEN {
449        return None;
450    }
451    if formatter.requirement_is_unresolved(requirement) {
452        return None;
453    }
454    let matcher = formatter.compile_requirement(requirement)?;
455
456    let mut saw_match = false;
457    let mut saw_undecided = false;
458    let mut aggregate: Option<RemovalStatus> = None;
459    for candidate in available {
460        match matcher.matches(candidate) {
461            Some(true) => {
462                saw_match = true;
463                let status = status_for_version(yanked, candidate)?;
464                if aggregate != Some(RemovalStatus::Yanked) {
465                    aggregate = Some(status);
466                }
467            }
468            Some(false) => {}
469            None => saw_undecided = true,
470        }
471    }
472    if saw_match && !saw_undecided {
473        aggregate
474    } else {
475        None
476    }
477}
478
479/// Generates diagnostics using cached versions (no network calls).
480///
481/// Uses pre-fetched version information from the lifecycle's parallel fetch.
482/// This avoids making additional network requests during diagnostic generation.
483///
484/// # Arguments
485///
486/// * `parse_result` - Parsed dependencies from manifest
487/// * `versions` - Latest (registry) and resolved (lock file) version maps, keyed by package name
488/// * `formatter` - Ecosystem-specific formatting and comparison logic
489/// * `uri` - Document URI, used only to anchor the [`Location`] of any
490///   [`DiagnosticRelatedInformation`] entries attached to a collapsed fetch-failure
491///   diagnostic (#479/#480 S2) — every other diagnostic here is scoped to the document
492///   it's published under implicitly and doesn't need it
493/// * `freshness` - Whether to differentiate an "outdated" diagnostic still within the
494///   release cooldown window (severity is unaffected either way — see the "Newer version
495///   available" message below)
496/// * `severities` - Configured severity for each diagnostic category
497/// * `now` - The instant every publish age in this call is computed against. Taken as a
498///   parameter rather than read internally via `PublishTime::now()` (issue #227 M4) so
499///   callers can pin an exact cooldown-boundary instant deterministically in tests, and
500///   so every dependency in one document is aged against the same instant.
501pub fn generate_diagnostics_from_cache(
502    parse_result: &dyn ParseResult,
503    versions: VersionData<'_>,
504    formatter: &dyn EcosystemFormatter,
505    uri: &Uri,
506    freshness: crate::freshness::FreshnessSettings,
507    severities: DiagnosticSeverities,
508    now: PublishTime,
509) -> Vec<Diagnostic> {
510    let deps = parse_result.dependencies();
511    let mut diagnostics = Vec::with_capacity(deps.len());
512    // Collected separately from `diagnostics`: buffered here so 2+ near-identical
513    // fetch failures collapse into one diagnostic instead of fanning out — but each
514    // entry keeps its own name, built message, and classification, since the
515    // collapse still needs to name every affected dependency (via
516    // `related_information`) and must never silently drop an `Actionable` hint
517    // (#478/#485's whole point) just because #479's collapse kicked in.
518    let mut fetch_failed: Vec<FetchFailureEntry> = Vec::new();
519
520    offline_notice(&mut diagnostics, versions, &deps);
521    blocked_registry_diagnostics(&mut diagnostics, parse_result);
522
523    // #394 S2: version-qualified OSV lookup keys, so two occurrences of one
524    // name pinned to different versions never share a `Vulnerable`/`Clean`
525    // result. `None` when this `VersionData` carries no ecosystem (most test
526    // fixtures) — the per-dep lookup below then falls back to the plain
527    // name, unaffected.
528    let vuln_keys = versions.ecosystem.map(|ecosystem| {
529        crate::osv::vulnerability_keys(parse_result, versions.resolved, formatter, ecosystem)
530    });
531
532    for dep in deps {
533        let normalized_name = formatter.normalize_package_name(dep.name());
534        let ctx = RuleContext {
535            dep,
536            normalized_name: &normalized_name,
537            versions,
538            formatter,
539            severities,
540            freshness,
541            now,
542        };
543
544        // R2, R3, R4 run before both terminal guards below (registry outage, no
545        // version range): an OSV / deprecation / in-use-yanked finding must never be
546        // hidden by an unrelated "latest" lookup failure (FR-007/US-004) — each reads
547        // an independent data source.
548        apply_vulnerability_rule(&mut diagnostics, &ctx, vuln_keys.as_ref());
549        let deprecation_found = apply_deprecation_rule(&mut diagnostics, &ctx);
550        let in_use_yanked_emitted =
551            apply_in_use_yanked_rule(&mut diagnostics, &ctx, deprecation_found);
552
553        let Some(package_versions) = ctx.cached_versions() else {
554            apply_unknown_package_rule(&mut diagnostics, &mut fetch_failed, &ctx);
555            continue;
556        };
557
558        // Every rule below anchors its diagnostic on the declared version range.
559        let Some(version_range) = dep.version_range() else {
560            continue;
561        };
562        let resolved = ResolvedData {
563            package_versions,
564            version_range,
565        };
566
567        if apply_unsatisfiable_rule(&mut diagnostics, &ctx, &resolved) == RuleFlow::Stop {
568            continue;
569        }
570        if apply_yanked_only_rule(
571            &mut diagnostics,
572            &ctx,
573            &resolved,
574            YankedOnlyPrior {
575                deprecation_found,
576                in_use_yanked_emitted,
577            },
578        ) == RuleFlow::Stop
579        {
580            continue;
581        }
582        apply_outdated_rule(&mut diagnostics, &ctx, &resolved);
583    }
584
585    push_collapsed_fetch_failures(&mut diagnostics, fetch_failed, uri);
586    diagnostics
587}
588
589/// Per-dependency inputs every rule in the pipeline reads.
590///
591/// Bundled so each rule takes one borrow instead of re-threading seven parameters (and so
592/// no rule can read state a later refactor forgot to pass it). `VersionData<'a>`,
593/// [`crate::freshness::FreshnessSettings`], and [`PublishTime`] are all `Copy`, so
594/// `RuleContext` is held by value.
595struct RuleContext<'a> {
596    dep: &'a dyn Dependency,
597    /// `formatter.normalize_package_name(dep.name())`, computed once per dependency
598    /// because five rules key their lookups on it.
599    normalized_name: &'a str,
600    versions: VersionData<'a>,
601    formatter: &'a dyn EcosystemFormatter,
602    severities: DiagnosticSeverities,
603    /// Whether an "outdated" diagnostic's message should differentiate a release
604    /// still within its cooldown window (issue #227 §4.3). Read only by
605    /// [`apply_outdated_rule`].
606    freshness: crate::freshness::FreshnessSettings,
607    /// The instant every publish age in this call is aged against. Read only by
608    /// [`apply_outdated_rule`].
609    now: PublishTime,
610}
611
612impl<'a> RuleContext<'a> {
613    /// The registry cache entry for this dependency, tried under the normalized name
614    /// first and the declared name second. `None` routes the dependency into the
615    /// unknown-package family (R5) and ends its evaluation.
616    fn cached_versions(&self) -> Option<&'a PackageVersions> {
617        self.versions
618            .cached
619            .get(self.normalized_name)
620            .or_else(|| self.versions.cached.get(self.dep.name()))
621    }
622}
623
624/// The registry-derived inputs the post-lookup rules (R6a, R6b, R7) share.
625///
626/// Constructing one requires both a cache hit (see [`RuleContext::cached_versions`])
627/// and a declared version range, so a rule that reads either cannot be moved above the
628/// guards that produce them.
629struct ResolvedData<'a> {
630    package_versions: &'a PackageVersions,
631    version_range: Range,
632}
633
634/// Whether the per-dependency pipeline continues past a rule that is allowed to
635/// suppress its successors.
636///
637/// Only rules that end the dependency's evaluation return this. The #263
638/// in-use-version-yanked check (R4) deliberately does **not** return it — it must
639/// co-emit with the outdated rule (R7) — so it cannot acquire suppression power
640/// without a signature change a reviewer will see. With every rule otherwise
641/// returning a plain `bool`, "emitted, does not stop" (R4) and "emitted, stops" (R6a/
642/// R6b) would be indistinguishable at the call site — the #437-class confusion this
643/// type exists to make visible.
644#[derive(Debug, Clone, Copy, PartialEq, Eq)]
645enum RuleFlow {
646    /// Evaluate the next rule for this dependency.
647    Continue,
648    /// Suppress every remaining rule for this dependency.
649    Stop,
650}
651
652/// One buffered fetch-failure finding, collapsed by [`push_collapsed_fetch_failures`]
653/// (R8).
654///
655/// `name` and `failure` are kept alongside the built `diagnostic` because the collapse
656/// must still name every affected dependency (`related_information`) and must never
657/// drop an `Actionable` hint (#478/#485) just because #479's collapse kicked in.
658struct FetchFailureEntry {
659    name: String,
660    diagnostic: Diagnostic,
661    failure: Option<FetchFailure>,
662}
663
664/// The two upstream-rule facts [`apply_yanked_only_rule`] gates on.
665///
666/// Bundled solely so its one construction site (in the orchestrator) uses Rust's
667/// field-named struct-literal syntax instead of two adjacent positional `bool`
668/// arguments — field-name binding is order-independent, so it rules out a silent
669/// argument swap the same way [`offline_notice`]'s `versions`/`deps` parameters do for
670/// its own two bools. Not threaded any further than that one call site. This is a
671/// different fix than the `PriorFindings` struct an earlier draft of this refactor
672/// used and the implementation critic had removed (M1): that one was rejected for a
673/// false *conflation* safety claim ("mixing up which finding fed which field can't
674/// happen"), not for this *argument-order* concern, and it was also passed as a
675/// parameter to a second function beyond its one true reader — this type is not.
676struct YankedOnlyPrior {
677    deprecation_found: bool,
678    in_use_yanked_emitted: bool,
679}
680
681/// R0 — file-level "offline" notice (#483 S2/I2).
682///
683/// `network.offline` silently degrades every diagnostic in this pipeline that depends
684/// on a registry/OSV fetch — vulnerabilities never queried, "unknown"/"outdated"/
685/// deprecation checks all working from whatever was already cached. Absence of a
686/// warning must not read as "safe" in this *persistent, user-configured* mode. One
687/// file-level diagnostic, not per-dependency: emitting one per affected dependency
688/// (the per-dependency fetch-failure arm inside [`apply_unknown_package_rule`] is
689/// suppressed while offline for exactly this reason, R5b below) would be strictly
690/// noisier than the failure toast this same PR suppresses for being "unusable" — the
691/// two must not contradict each other.
692///
693/// Reads: `versions.offline`, `deps` (only to check non-emptiness — takes the slice
694/// itself, not two positional bools, so a caller can never silently swap them).
695/// Emits: at most one [`DiagnosticSeverity::INFORMATION`] at `Position(0,0)`, appended
696/// first so it always precedes every other diagnostic in the returned `Vec`.
697/// Suppressed by: nothing. Suppresses: R5b (see [`apply_unknown_package_rule`]).
698fn offline_notice(
699    diagnostics: &mut Vec<Diagnostic>,
700    versions: VersionData<'_>,
701    deps: &[&dyn Dependency],
702) {
703    if versions.offline && !deps.is_empty() {
704        diagnostics.push(Diagnostic {
705            range: Range {
706                start: Position::new(0, 0),
707                end: Position::new(0, 0),
708            },
709            severity: Some(DiagnosticSeverity::INFORMATION),
710            message: "deps-lsp is offline (network.offline): dependency and vulnerability \
711                      data reflects only what was already cached, not the current registry state"
712                .to_string(),
713            source: Some("deps-lsp".into()),
714            ..Default::default()
715        });
716    }
717}
718
719/// R1 — blocked-registry notices (#443/plan-1b §1.7).
720///
721/// A registry index blocked by `registries.workspace_registries` must not degrade silently.
722/// Independent of the dependency loop below: a blocked dependency never reaches
723/// version resolution, so it would otherwise leave no trace at all in the editor.
724///
725/// Reads: `parse_result.blocked_registries()`.
726/// Emits: one [`DiagnosticSeverity::INFORMATION`] per entry, on the dependency's own
727/// range, message truncated at [`MAX_BLOCKED_REGISTRY_MESSAGE_VALUE_CHARS`]. Pushed
728/// after R0, before any per-dependency diagnostic.
729/// Suppressed by: nothing. Suppresses: nothing.
730fn blocked_registry_diagnostics(diagnostics: &mut Vec<Diagnostic>, parse_result: &dyn ParseResult) {
731    for (range, class, raw_value) in parse_result.blocked_registries() {
732        diagnostics.push(Diagnostic {
733            range,
734            severity: Some(DiagnosticSeverity::INFORMATION),
735            message: format!(
736                "registry index \"{}\" blocked by registries.workspace_registries policy \
737                 (host class: {class})",
738                truncate_for_diagnostic(&raw_value, MAX_BLOCKED_REGISTRY_MESSAGE_VALUE_CHARS)
739            ),
740            source: Some("deps-lsp".into()),
741            ..Default::default()
742        });
743    }
744}
745
746/// R2 — OSV vulnerability findings (#394 S2, FR-007/US-004).
747///
748/// Emitted before either terminal guard in the orchestrator (registry outage, no
749/// version range) so a registry failure never suppresses an OSV finding — the two are
750/// independent data sources.
751///
752/// Reads: `ctx.versions.vulnerabilities`; `vuln_keys` looked up by `ctx.dep.name_range()`
753/// (#394 S2, preferred), falling back to `ctx.normalized_name`, then `ctx.dep.name()` —
754/// first `Some(ScanOutcome::Vulnerable(_))` wins.
755/// Emits: N advisory diagnostics (in `dv.advisories.items()` order) plus an optional
756/// "+N more advisories" entry, via [`push_vulnerability_diagnostics`].
757/// Suppressed by: nothing — not gated on `can_resolve_source`.
758/// Suppresses: nothing.
759fn apply_vulnerability_rule(
760    diagnostics: &mut Vec<Diagnostic>,
761    ctx: &RuleContext<'_>,
762    vuln_keys: Option<&HashMap<Range, String>>,
763) {
764    if let Some(vulnerabilities) = ctx.versions.vulnerabilities
765        && let Some(ScanOutcome::Vulnerable(dv)) = vuln_keys
766            .and_then(|keys| keys.get(&ctx.dep.name_range()))
767            .and_then(|key| vulnerabilities.get(key))
768            .or_else(|| vulnerabilities.get(ctx.normalized_name))
769            .or_else(|| vulnerabilities.get(ctx.dep.name().as_str()))
770    {
771        push_vulnerability_diagnostics(diagnostics, ctx.dep, dv);
772    }
773}
774
775/// R3 — package-level deprecation finding (#205, I4).
776///
777/// Emitted independently of the in-use-yanked check (R4), for the same FR-007-style
778/// reason as R2 — a registry-reported deprecation must never be suppressed by an
779/// unrelated "latest" lookup failure.
780///
781/// Reads: `ctx.formatter.can_resolve_source(&ctx.dep.source())` **and**
782/// `ctx.versions.outcomes.deprecation(ctx.normalized_name)`. I4: the `can_resolve_source`
783/// gate is folded into the returned value (not just the push) so this rule, R4's D5
784/// gate, and R6b's #437 mirror of it all see the same answer — `versions.outcomes`'
785/// deprecation channel is name-keyed, so a git/path/SDK occurrence sharing a name with
786/// an unrelated registry-resolved package (the #248 coincidental-namesake hazard) must
787/// not surface a diagnostic for a package it doesn't actually resolve against.
788/// Emits: 1 deprecation diagnostic (`DEPRECATED_DIAGNOSTIC_CODE`) via
789/// [`push_deprecation_diagnostic`].
790/// Suppressed by: nothing — never suppressed by a registry outage (same FR-007
791/// reasoning as R2).
792/// Suppresses: R4 conditionally (D5, see [`apply_in_use_yanked_rule`]), R6b
793/// conditionally (#437, see [`apply_yanked_only_rule`]) — both read only this rule's
794/// return value (presence), never its content.
795fn apply_deprecation_rule(diagnostics: &mut Vec<Diagnostic>, ctx: &RuleContext<'_>) -> bool {
796    let deprecation = ctx
797        .formatter
798        .can_resolve_source(&ctx.dep.source())
799        .then(|| {
800            ctx.versions
801                .outcomes
802                .and_then(|o| o.deprecation(ctx.normalized_name))
803        })
804        .flatten();
805    if let Some(dep_info) = deprecation {
806        push_deprecation_diagnostic(
807            diagnostics,
808            ctx.dep,
809            ctx.formatter,
810            dep_info,
811            ctx.severities,
812        );
813    }
814    deprecation.is_some()
815}
816
817/// R4 — in-use-version yanked check (#263, #394 S1, D5).
818///
819/// Two independent yanked-version checks exist in this pipeline: this one (#263)
820/// flags the specific in-use version (lockfile-resolved, or an exact manifest pin)
821/// when it is yanked, while R6b ([`apply_yanked_only_rule`], #247) flags a declared
822/// *range* requirement that can currently only be satisfied by a yanked version, even
823/// with no lockfile at all. They answer different questions and neither subsumes the
824/// other, but for a dependency pinned to the one version that also happens to be the
825/// only version satisfying its own requirement, both would fire on the same
826/// dependency — this rule's return value lets R6b dedup that case.
827///
828/// The two checks deliberately keep different outdated-interaction policies — this is
829/// not an oversight. This (#263) check has no `continue`, so it co-emits alongside an
830/// "outdated" diagnostic for the same dependency (see
831/// `test_generate_diagnostics_from_cache_yanked_and_outdated_both_emitted`). R6b does
832/// `continue`, suppressing "outdated" for the same dependency (see
833/// `test_yanked_only_match_suppresses_outdated_diagnostic`). Each policy was
834/// independently reviewed and tested before this merge; harmonizing them is out of
835/// scope here.
836///
837/// Reads: `ctx.versions.outcomes.yanked(ctx.normalized_name)` -> `(yanked_version,
838/// status)`; `ctx.versions.ecosystem`; `super::in_use_version(...)`.
839/// Gate D5 (`deprecation_found`): a package-level deprecation finding suppresses this
840/// check — but only when the underlying yanked finding's status is
841/// `AdvisoryDeprecated`, never when it is `Yanked`. A genuine hard yank ("the exact
842/// version you pinned was withdrawn") is strictly more actionable than a package-level
843/// deprecation notice ("the project is archived"), so it must never be hidden behind
844/// one. Requires an *actual* yanked (#263) entry with that status — uses
845/// `is_some_and`, **not** `is_none_or`: a vacuous `None` (no #263 entry) must NOT count
846/// as "suppress". R6b reads a different data source (`package_versions.yanked`) and,
847/// as of #437, applies this identical D5 polarity *independently* from its own matched
848/// entry's status, regardless of what this check decided — this is deliberately NOT
849/// folded into this rule's return value (an earlier version of this fix did that, and
850/// thereby let a suppressed-`AdvisoryDeprecated` #263 entry also silently gate off a
851/// same-dependency #247 match whose own aggregate was `Yanked` — reintroducing the
852/// exact bug #437 exists to close, just through the other branch).
853/// Gate #394 S1: when multiple occurrences share `normalized_name`, the finding
854/// recorded under it may belong to a *different* occurrence (e.g.
855/// `[dependencies] time = "=0.1.43"`, yanked, and `[dev-dependencies] time =
856/// "=0.1.44"`, not yanked — both name-keyed to the same `yanked_version`). Emitting
857/// unconditionally would push a false-positive "yanked" diagnostic onto the safe
858/// occurrence, for a version string that does not even appear on that line. Gated on
859/// `ctx.versions.ecosystem` being set (production always sets it; the check is
860/// skipped, matching pre-#394 behavior, for the test fixtures that do not) via
861/// `is_none_or`.
862/// Emits: 1 yanked diagnostic on `version_range().unwrap_or(name_range())`. Message is
863/// deliberately `"{yanked_message()} ({version})"` — do not "harmonize" it with R6b's
864/// message (`"{yanked_message()}; latest is {latest}"`), which differs on purpose.
865/// Suppressed by: D5 above. Not gated on `can_resolve_source`.
866/// Suppresses: nothing — deliberately returns `bool`, not [`RuleFlow`], so it
867/// structurally cannot stop the pipeline.
868///
869/// Returns whether a diagnostic was actually **pushed** — never `true` for a
870/// D5-suppressed finding, and never merely "a #263 entry existed". [`apply_yanked_only_rule`]
871/// reads this as its dedup gate.
872fn apply_in_use_yanked_rule(
873    diagnostics: &mut Vec<Diagnostic>,
874    ctx: &RuleContext<'_>,
875    deprecation_found: bool,
876) -> bool {
877    let deprecation_suppresses_yanked = deprecation_found
878        && ctx
879            .versions
880            .outcomes
881            .and_then(|o| o.yanked(ctx.normalized_name))
882            .is_some_and(|(_, status)| *status != RemovalStatus::Yanked);
883
884    if deprecation_suppresses_yanked {
885        false
886    } else if let Some((yanked_version, _status)) = ctx
887        .versions
888        .outcomes
889        .and_then(|o| o.yanked(ctx.normalized_name))
890        && ctx.versions.ecosystem.is_none_or(|ecosystem| {
891            super::in_use_version(
892                ctx.dep,
893                ctx.normalized_name,
894                ctx.versions.resolved,
895                ctx.formatter,
896                ecosystem,
897            )
898            .as_deref()
899                == Some(yanked_version.as_str())
900        })
901    {
902        diagnostics.push(Diagnostic {
903            range: ctx
904                .dep
905                .version_range()
906                .unwrap_or_else(|| ctx.dep.name_range()),
907            severity: Some(ctx.severities.yanked),
908            message: format!("{} ({})", ctx.formatter.yanked_message(), yanked_version),
909            source: Some("deps-lsp".into()),
910            ..Default::default()
911        });
912        true
913    } else {
914        false
915    }
916}
917
918/// R5 — unknown-package family. Reached only when [`RuleContext::cached_versions`]
919/// returns `None`. Always terminal — the orchestrator `continue`s unconditionally
920/// after calling this.
921///
922/// R5-guard `in_lockfile` (#248): if `ctx.versions.resolved` contains either key,
923/// emits nothing at all — a registry fetch may simply have been rate-limited.
924/// Name-syntax validation is unaffected by this guard: it never depends on registry
925/// data.
926///
927/// `fetch_failure = can_resolve_source(&dep.source()) &&
928/// outcomes.fetch_failure(normalized_name)` — a fetch error/timeout (#267) is not
929/// evidence the package doesn't exist, so it is reported distinctly from a genuine
930/// "not found".
931///
932/// `match formatter.validate_package_name(dep.name())` — **arm order is
933/// load-bearing**:
934/// 1. `Err(reason)` -> R5a `Invalid package name '{name}': {reason}`. Fires regardless
935///    of `can_resolve_source`/`fetch_failure` — a fetch failure must never mask an
936///    invalid name.
937/// 2. `Ok(())` + `fetch_failure.is_some()` + `versions.offline` -> R5b emits nothing
938///    (#483 I2): this is a deliberately configured mode, not a registry outage, and
939///    [`offline_notice`]'s file-level notice already covers this dependency.
940/// 3. `Ok(())` + `fetch_failure.is_some()` -> R5c: queued into `fetch_failed`
941///    (deferred, collapsed by [`push_collapsed_fetch_failures`] — R8) rather than
942///    pushed inline, since a fetch failure is a registry-wide condition that can hit
943///    many dependencies identically.
944/// 4. `Ok(())` + `no_comparable_versions` -> R5e emits nothing (#550): the registry
945///    fetch succeeded and the package demonstrably exists, it just has zero versions
946///    comparable to the declared requirement (e.g. `dtolnay/rust-toolchain`'s only tag
947///    `v1` isn't full semver) — reported by neither R5c ("couldn't be asked") nor R5d
948///    ("no evidence it exists"). Checked before R5d so it takes priority over the
949///    "absent cache entry" heuristic that would otherwise misclassify it.
950/// 5. `Ok(())` + `can_resolve_source` -> R5d `Unknown package '{name}'`.
951/// 6. `Ok(())` -> nothing (#248: unresolvable source, absent cache entry means "never
952///    fetched").
953fn apply_unknown_package_rule(
954    diagnostics: &mut Vec<Diagnostic>,
955    fetch_failed: &mut Vec<FetchFailureEntry>,
956    ctx: &RuleContext<'_>,
957) {
958    let dep = ctx.dep;
959    let in_lockfile = ctx.versions.resolved.contains_key(ctx.normalized_name)
960        || ctx.versions.resolved.contains_key(dep.name());
961    if in_lockfile {
962        return;
963    }
964
965    let can_resolve_source = ctx.formatter.can_resolve_source(&dep.source());
966    let fetch_failure: Option<&FetchFailure> = can_resolve_source
967        .then(|| {
968            ctx.versions
969                .outcomes
970                .and_then(|o| o.fetch_failure(ctx.normalized_name))
971        })
972        .flatten();
973    let no_comparable_versions = can_resolve_source
974        && ctx
975            .versions
976            .outcomes
977            .is_some_and(|o| o.no_comparable_versions(ctx.normalized_name));
978    match ctx.formatter.validate_package_name(dep.name().as_str()) {
979        Err(reason) => {
980            diagnostics.push(Diagnostic {
981                range: dep.name_range(),
982                severity: Some(ctx.severities.unknown),
983                message: format!("Invalid package name '{}': {reason}", dep.name()),
984                source: Some("deps-lsp".into()),
985                ..Default::default()
986            });
987        }
988        Ok(()) if fetch_failure.is_some() && ctx.versions.offline => {}
989        Ok(()) if fetch_failure.is_some() => {
990            let message = match fetch_failure {
991                Some(FetchFailure::Actionable(hint)) => {
992                    format!("Registry lookup failed for '{}': {hint}", dep.name())
993                }
994                Some(FetchFailure::Transient | FetchFailure::NotAttempted) | None => {
995                    format!(
996                        "Registry lookup failed for '{}'; package status could not be determined",
997                        dep.name()
998                    )
999                }
1000            };
1001            fetch_failed.push(FetchFailureEntry {
1002                name: dep.name().to_string(),
1003                diagnostic: Diagnostic {
1004                    range: dep.name_range(),
1005                    severity: Some(ctx.severities.unknown),
1006                    message,
1007                    source: Some("deps-lsp".into()),
1008                    ..Default::default()
1009                },
1010                failure: fetch_failure.cloned(),
1011            });
1012        }
1013        Ok(()) if no_comparable_versions => {}
1014        Ok(()) if can_resolve_source => {
1015            diagnostics.push(Diagnostic {
1016                range: dep.name_range(),
1017                severity: Some(ctx.severities.unknown),
1018                message: format!("Unknown package '{}'", dep.name()),
1019                source: Some("deps-lsp".into()),
1020                ..Default::default()
1021            });
1022        }
1023        Ok(()) => {}
1024    }
1025}
1026
1027/// R6a — unsatisfiable requirement (#206, #299).
1028///
1029/// Path/git/URL/SDK/workspace dependencies never resolve against a registry version
1030/// list at all — `resolved.package_versions` (when present) either came from a
1031/// coincidentally-matching registry entry of the same name or an entirely unrelated
1032/// package. Neither is a meaningful "no published version satisfies this" check,
1033/// hence the `can_resolve_source` gate below.
1034///
1035/// Reads: `can_resolve_source(&dep.source())` **and** `dep.version_requirement()`
1036/// **and** `requirement_is_unsatisfiable(formatter, req,
1037/// &resolved.package_versions.available)`. Message enriched via
1038/// `matching_prerelease_would_satisfy` when a non-yanked pre-release whose stable core
1039/// matches exists (#299).
1040/// Emits: 1 diagnostic (`UNSATISFIABLE_DIAGNOSTIC_CODE`) on `resolved.version_range`.
1041/// Suppressed by: nothing.
1042/// Suppresses: R6b and R7 via the returned `RuleFlow::Stop`. `resolved: &ResolvedData`
1043/// (carrying `version_range`) is a precondition all three rules share — established by
1044/// the orchestrator's `version_range` guard *before* this rule runs, not an effect of
1045/// this rule running — so it is `RuleFlow::Stop` alone, not `version_range`'s
1046/// existence, that skips R6b and R7 here.
1047fn apply_unsatisfiable_rule(
1048    diagnostics: &mut Vec<Diagnostic>,
1049    ctx: &RuleContext<'_>,
1050    resolved: &ResolvedData<'_>,
1051) -> RuleFlow {
1052    let dep = ctx.dep;
1053    let package_versions = resolved.package_versions;
1054    let latest = &package_versions.latest;
1055
1056    let unsatisfiable = ctx.formatter.can_resolve_source(&dep.source())
1057        && dep.version_requirement().is_some_and(|version_req| {
1058            requirement_is_unsatisfiable(ctx.formatter, version_req, &package_versions.available)
1059        });
1060
1061    if !unsatisfiable {
1062        return RuleFlow::Continue;
1063    }
1064
1065    let req_str = dep.version_requirement().map_or("", |r| r.as_str());
1066    let mut message =
1067        format!("No published version satisfies requirement '{req_str}'; latest is {latest}");
1068    if let Some(prerelease) = dep.version_requirement().and_then(|version_req| {
1069        matching_prerelease_would_satisfy(
1070            ctx.formatter,
1071            version_req,
1072            &package_versions.available,
1073            &package_versions.yanked,
1074        )
1075    }) {
1076        use std::fmt::Write as _;
1077        let _ = write!(
1078            message,
1079            " (a pre-release, {prerelease}, is excluded by SemVer's default \
1080             pre-release-matching rules; require it explicitly to use it)"
1081        );
1082    }
1083    diagnostics.push(Diagnostic {
1084        range: resolved.version_range,
1085        severity: Some(ctx.severities.unsatisfiable),
1086        message,
1087        source: Some("deps-lsp".into()),
1088        code: Some(NumberOrString::String(UNSATISFIABLE_DIAGNOSTIC_CODE.into())),
1089        ..Default::default()
1090    });
1091    RuleFlow::Stop
1092}
1093
1094/// R6b — yanked-only range match (#247, #437, #431, #436).
1095///
1096/// Independent of R4 ([`apply_in_use_yanked_rule`]), which reads `versions.outcomes`
1097/// directly and is unaffected by the gates below.
1098///
1099/// Three independent gates, in order:
1100/// 1. `!in_use_yanked_emitted` (dedup with R4) — **and critically not** "R4 was
1101///    D5-suppressed" (#437 S1): that suppression pushed nothing, so this rule must
1102///    still be free to decide for itself.
1103/// 2. `formatter.can_resolve_source(&dep.source())` (#431) — a source this ecosystem
1104///    cannot fetch from (e.g. an unresolved Cargo registry alias) is excluded the same
1105///    way the other diagnostics in this pipeline already are.
1106/// 3. `formatter.yanked_diagnostic_applies_to(dep, version_req)` — per-ecosystem
1107///    opt-out (npm unconditionally, #436) for a requirement shape where this
1108///    diagnostic would duplicate a more specific one or where `removal_status()`
1109///    isn't a reliable enough per-version signal.
1110///
1111/// Then `requirement_matches_only_yanked(...)` -> `Option<RemovalStatus>` (the
1112/// aggregate: `Yanked` if any matching entry's status is `Yanked`, else
1113/// `AdvisoryDeprecated`).
1114///
1115/// Final D5-mirror (#437): mirrors R4's D5 polarity, applied independently of the
1116/// #263 `versions.outcomes` yanked channel and of whatever R4 decided — fires iff
1117/// `status != AdvisoryDeprecated || !deprecation_found`, i.e. a `Yanked` aggregate
1118/// always fires regardless of a co-occurring deprecation and regardless of what R4
1119/// decided; an `AdvisoryDeprecated` aggregate yields to a deprecation finding (the
1120/// PyPI range-satisfiable-only-by-yanked-with-no-exact-pin case this fixes, plus the
1121/// mixed-status-within-one-package case S1 closes).
1122/// Emits: 1 yanked diagnostic on `resolved.version_range`. Message is deliberately
1123/// `"{yanked_message()}; latest is {latest}"` — do not "harmonize" it with R4's
1124/// message (`"{yanked_message()} ({version})"`), which differs on purpose.
1125/// Suppressed by: the three gates above.
1126/// Suppresses: R7 (`RuleFlow::Stop`) — the deliberate asymmetry with R4, which never
1127/// stops the pipeline (see `test_yanked_only_match_suppresses_outdated_diagnostic`).
1128fn apply_yanked_only_rule(
1129    diagnostics: &mut Vec<Diagnostic>,
1130    ctx: &RuleContext<'_>,
1131    resolved: &ResolvedData<'_>,
1132    prior: YankedOnlyPrior,
1133) -> RuleFlow {
1134    let YankedOnlyPrior {
1135        deprecation_found,
1136        in_use_yanked_emitted,
1137    } = prior;
1138    let dep = ctx.dep;
1139    let package_versions = resolved.package_versions;
1140    let latest = &package_versions.latest;
1141
1142    let yanked_only_status = (!in_use_yanked_emitted
1143        && ctx.formatter.can_resolve_source(&dep.source()))
1144    .then(|| dep.version_requirement())
1145    .flatten()
1146    .filter(|version_req| ctx.formatter.yanked_diagnostic_applies_to(dep, version_req))
1147    .and_then(|version_req| {
1148        requirement_matches_only_yanked(
1149            ctx.formatter,
1150            version_req,
1151            &package_versions.available,
1152            &package_versions.yanked,
1153        )
1154    });
1155
1156    let yanked_only = yanked_only_status
1157        .is_some_and(|status| status != RemovalStatus::AdvisoryDeprecated || !deprecation_found);
1158
1159    if !yanked_only {
1160        return RuleFlow::Continue;
1161    }
1162
1163    diagnostics.push(Diagnostic {
1164        range: resolved.version_range,
1165        severity: Some(ctx.severities.yanked),
1166        message: format!("{}; latest is {latest}", ctx.formatter.yanked_message()),
1167        source: Some("deps-lsp".into()),
1168        ..Default::default()
1169    });
1170    RuleFlow::Stop
1171}
1172
1173/// R7 — outdated (#227 §4.3). Last rule; nothing to suppress, so no [`RuleFlow`].
1174///
1175/// As with R6a's `unsatisfiable` check, a non-resolvable source's `latest` (when
1176/// present at all) comes from an unrelated or coincidental cache entry, not a real
1177/// lookup against the registry this dependency actually resolves against — so
1178/// "Outdated" must not be evaluated for it either (#248).
1179///
1180/// `status` = `Unresolved` unless `dep.version_requirement()` is `Some` **and**
1181/// `can_resolve_source` (#248); otherwise `formatter.requirement_status(req, latest)`.
1182/// Fires on `RequirementStatus::Outdated`. Message-only cooldown differentiation
1183/// gated on `ctx.freshness.enabled` + `package_versions.published_at` +
1184/// `is_within_cooldown(age, cooldown_secs)`; **severity is identical in both cases**
1185/// (already the floor — see the module docs).
1186fn apply_outdated_rule(
1187    diagnostics: &mut Vec<Diagnostic>,
1188    ctx: &RuleContext<'_>,
1189    resolved: &ResolvedData<'_>,
1190) {
1191    let dep = ctx.dep;
1192    let package_versions = resolved.package_versions;
1193    let latest = &package_versions.latest;
1194
1195    let status = match dep.version_requirement() {
1196        Some(version_req) if ctx.formatter.can_resolve_source(&dep.source()) => ctx
1197            .formatter
1198            .requirement_status_for(dep, version_req, latest),
1199        _ => RequirementStatus::Unresolved,
1200    };
1201
1202    if status != RequirementStatus::Outdated {
1203        return;
1204    }
1205
1206    let published_at = ctx
1207        .freshness
1208        .enabled
1209        .then_some(package_versions.published_at)
1210        .flatten();
1211    let message = match published_at {
1212        Some(published_at)
1213            if is_within_cooldown(
1214                published_at.age_secs_from(ctx.now),
1215                ctx.freshness.cooldown_secs,
1216            ) =>
1217        {
1218            format!(
1219                "Newer version available: {latest} (published {} — still within the release cooldown window)",
1220                format_relative_age(published_at.age_secs_from(ctx.now))
1221            )
1222        }
1223        _ => format!("Newer version available: {latest}"),
1224    };
1225    diagnostics.push(Diagnostic {
1226        range: resolved.version_range,
1227        severity: Some(ctx.severities.outdated),
1228        message,
1229        source: Some("deps-lsp".into()),
1230        ..Default::default()
1231    });
1232}
1233
1234/// R8 — fetch-failure collapse (#479, #480 S2, #478/#485).
1235///
1236/// A single fetch failure keeps its own per-dependency diagnostic (same range as
1237/// before, though — unlike every other diagnostic in this pipeline — no longer
1238/// necessarily at the same position in the returned `Vec` in dependency order: it's
1239/// appended after the main loop rather than interleaved with it, so an assertion keyed
1240/// on vec index rather than message content could be affected). More than one
1241/// collapses into one combined diagnostic (on the first failing dependency's range)
1242/// rather than fanning out N near-duplicates that all trace back to the same
1243/// registry-wide condition — but every *additional* failing dependency's name and
1244/// location survive via `related_information` instead of being silently dropped along
1245/// with their per-line diagnostic marker.
1246///
1247/// `0` -> nothing. `1` -> push the single buffered diagnostic verbatim. `n >= 2` -> one
1248/// diagnostic at entry 0's `range`/`severity`, message using the *first* `Actionable`
1249/// hint found across the batch (falling back to the generic form), with
1250/// `related_information` built from entries `[1..]` as `'{name}' also failed` anchored
1251/// at each entry's own range and `uri`.
1252fn push_collapsed_fetch_failures(
1253    diagnostics: &mut Vec<Diagnostic>,
1254    fetch_failed: Vec<FetchFailureEntry>,
1255    uri: &Uri,
1256) {
1257    match fetch_failed.len() {
1258        0 => {}
1259        1 => diagnostics.extend(fetch_failed.into_iter().map(|entry| entry.diagnostic)),
1260        n => {
1261            let first = &fetch_failed[0].diagnostic;
1262            let range = first.range;
1263            let severity = first.severity;
1264            // Surface a shared/first actionable hint across the batch if one exists,
1265            // so collapsing 2+ failures into one diagnostic never drops the specific,
1266            // pre-vetted remedy #478/#485 introduced — only fall back to the generic
1267            // message when nothing in the batch has one.
1268            let shared_hint = fetch_failed.iter().find_map(|entry| match &entry.failure {
1269                Some(FetchFailure::Actionable(hint)) => Some(hint.clone()),
1270                _ => None,
1271            });
1272            let message = match shared_hint {
1273                Some(hint) => format!("Registry lookup failed for {n} packages: {hint}"),
1274                None => format!(
1275                    "Registry lookup failed for {n} packages; package status could not be determined"
1276                ),
1277            };
1278            let related_information = fetch_failed[1..]
1279                .iter()
1280                .map(|entry| DiagnosticRelatedInformation {
1281                    location: Location {
1282                        uri: uri.clone(),
1283                        range: entry.diagnostic.range,
1284                    },
1285                    message: format!("'{}' also failed", entry.name),
1286                })
1287                .collect();
1288            diagnostics.push(Diagnostic {
1289                range,
1290                severity,
1291                message,
1292                source: Some("deps-lsp".into()),
1293                related_information: Some(related_information),
1294                ..Default::default()
1295            });
1296        }
1297    }
1298}
1299
1300/// Pushes the package-level deprecation [`Diagnostic`] for `dep` (issue #205).
1301///
1302/// Modeled on `push_vulnerability_diagnostics`: anchored on `dep.version_range()`,
1303/// falling back to `dep.name_range()` — the same range D4 requires so the client's
1304/// lightbulb gesture lands where `generate_code_actions`' quickfixes already work (see
1305/// `EcosystemFormatter::is_position_on_dependency`'s default).
1306fn push_deprecation_diagnostic(
1307    diagnostics: &mut Vec<Diagnostic>,
1308    dep: &dyn Dependency,
1309    formatter: &dyn EcosystemFormatter,
1310    deprecation: &Deprecation,
1311    severities: DiagnosticSeverities,
1312) {
1313    use std::fmt::Write as _;
1314
1315    let range = dep.version_range().unwrap_or_else(|| dep.name_range());
1316
1317    let mut message = formatter.deprecated_message().to_string();
1318    if let Some(reason) = deprecation.reason.as_deref().filter(|r| !r.is_empty()) {
1319        let _ = write!(message, ": {reason}");
1320    }
1321    if let Some(replacement) = deprecation.replacement.as_deref().filter(|r| !r.is_empty()) {
1322        let _ = write!(message, " (replacement: {replacement})");
1323    }
1324
1325    diagnostics.push(Diagnostic {
1326        range,
1327        severity: Some(severities.deprecated),
1328        message,
1329        source: Some("deps-lsp".into()),
1330        code: Some(NumberOrString::String(DEPRECATED_DIAGNOSTIC_CODE.into())),
1331        ..Default::default()
1332    });
1333}
1334
1335/// Pushes one [`Diagnostic`] per advisory (each with its own severity, code,
1336/// and clickable `code_description`), capped at
1337/// [`crate::osv::ADVISORY_DISPLAY_CAP`] plus a trailing "+N more advisories" entry.
1338///
1339/// `N` is derived from [`crate::osv::Capped::remaining`] — the batch result's reported count,
1340/// never from `dv.advisories.items().len()`, since invariant 3 (`architecture.md` §8)
1341/// caps the record *fetch* independently of the render cap.
1342fn push_vulnerability_diagnostics(
1343    diagnostics: &mut Vec<Diagnostic>,
1344    dep: &dyn Dependency,
1345    dv: &crate::osv::DependencyVulnerabilities,
1346) {
1347    let range = dep.version_range().unwrap_or_else(|| dep.name_range());
1348
1349    for advisory in dv.advisories.items() {
1350        let code_description = advisory
1351            .url
1352            .parse::<Uri>()
1353            .ok()
1354            .map(|href| CodeDescription { href });
1355
1356        diagnostics.push(Diagnostic {
1357            range,
1358            severity: Some(diagnostic_severity_for(advisory.severity)),
1359            message: format!(
1360                "{}: {}",
1361                advisory.id,
1362                advisory
1363                    .summary
1364                    .as_deref()
1365                    .unwrap_or("(no summary provided)")
1366            ),
1367            code: Some(NumberOrString::String(advisory.id.clone())),
1368            code_description,
1369            source: Some("deps-lsp".into()),
1370            ..Default::default()
1371        });
1372    }
1373
1374    let remaining = dv.advisories.remaining();
1375    if remaining > 0 {
1376        diagnostics.push(Diagnostic {
1377            range,
1378            severity: Some(DiagnosticSeverity::INFORMATION),
1379            message: format!("+{remaining} more advisories"),
1380            source: Some("deps-lsp".into()),
1381            ..Default::default()
1382        });
1383    }
1384}
1385
1386#[cfg(test)]
1387mod tests {
1388    use super::*;
1389    use crate::lsp_helpers::test_support::*;
1390    use crate::lsp_helpers::*;
1391    use crate::{PackageName, VersionReq};
1392
1393    use std::collections::HashMap;
1394    use std::sync::Arc;
1395
1396    #[test]
1397    fn test_generate_diagnostics_from_cache_unknown_package() {
1398        use std::collections::HashMap;
1399        use tower_lsp_server::ls_types::{Position, Range};
1400
1401        let formatter = MockFormatter;
1402
1403        let parse_result = MockParseResult {
1404            deps: vec![MockDep {
1405                name: "unknown-pkg".into(),
1406                version_req: "1.0.0".into(),
1407                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1408                name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
1409            }],
1410            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1411        };
1412
1413        let cached_versions = HashMap::new();
1414        let resolved_versions = HashMap::new();
1415
1416        let diagnostics = generate_diagnostics_from_cache(
1417            &parse_result,
1418            VersionData::new(&cached_versions, &resolved_versions),
1419            &formatter,
1420            parse_result.uri(),
1421            crate::freshness::FreshnessSettings::default(),
1422            DiagnosticSeverities::default(),
1423            PublishTime::now(),
1424        );
1425
1426        assert_eq!(diagnostics.len(), 1);
1427        assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::WARNING));
1428        assert!(diagnostics[0].message.contains("Unknown package"));
1429        assert!(diagnostics[0].message.contains("unknown-pkg"));
1430    }
1431
1432    /// Regression for #550: a package whose registry fetch succeeded but produced zero
1433    /// comparable versions (e.g. `dtolnay/rust-toolchain`, whose only tag `v1` isn't
1434    /// full semver, so `GithubActionsRegistry::get_versions` returns `Ok(vec![])`) must
1435    /// not be reported "Unknown package" — the package demonstrably exists; there is
1436    /// simply nothing derivable from it. No cache entry AND no `no_comparable_versions`
1437    /// outcome would still (correctly) produce "Unknown package" — this asserts the
1438    /// outcome alone suppresses it.
1439    #[test]
1440    fn test_generate_diagnostics_from_cache_no_comparable_versions_is_not_unknown_package() {
1441        use tower_lsp_server::ls_types::{Position, Range};
1442
1443        let formatter = MockFormatter;
1444
1445        let parse_result = MockParseResult {
1446            deps: vec![MockDep {
1447                name: "dtolnay/rust-toolchain".into(),
1448                version_req: "stable".into(),
1449                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1450                name_range: Range::new(Position::new(0, 0), Position::new(0, 23)),
1451            }],
1452            uri: crate::test_util::test_uri("/repo/.github/workflows/ci.yml"),
1453        };
1454
1455        let cached_versions = HashMap::new();
1456        let resolved_versions = HashMap::new();
1457        let outcomes =
1458            DependencyOutcomes::new().with_no_comparable_versions("dtolnay/rust-toolchain");
1459
1460        let diagnostics = generate_diagnostics_from_cache(
1461            &parse_result,
1462            VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
1463            &formatter,
1464            parse_result.uri(),
1465            crate::freshness::FreshnessSettings::default(),
1466            DiagnosticSeverities::default(),
1467            PublishTime::now(),
1468        );
1469
1470        assert!(
1471            diagnostics.is_empty(),
1472            "expected no diagnostic for a package with no comparable versions, got: {diagnostics:?}"
1473        );
1474    }
1475
1476    /// S3 (impl-critic): `generate_diagnostics_from_cache` must actually emit the
1477    /// `INFORMATION` diagnostic for a `ParseResult::blocked_registries()` entry — the
1478    /// §1.7 "must not degrade silently" requirement, previously entirely untested.
1479    #[test]
1480    fn test_generate_diagnostics_from_cache_emits_blocked_registry_diagnostic() {
1481        use crate::net_policy::HostClass;
1482        use tower_lsp_server::ls_types::{Position, Range};
1483
1484        struct BlockedRegistryParseResult {
1485            deps: Vec<MockDep>,
1486            uri: Uri,
1487            blocked: Vec<(Range, HostClass, String)>,
1488        }
1489
1490        impl ParseResult for BlockedRegistryParseResult {
1491            fn dependencies(&self) -> Vec<&dyn Dependency> {
1492                self.deps.iter().map(|d| d as &dyn Dependency).collect()
1493            }
1494            fn workspace_root(&self) -> Option<&std::path::Path> {
1495                None
1496            }
1497            fn uri(&self) -> &Uri {
1498                &self.uri
1499            }
1500            fn blocked_registries(&self) -> Vec<(Range, HostClass, String)> {
1501                self.blocked.clone()
1502            }
1503            fn as_any(&self) -> &dyn std::any::Any {
1504                self
1505            }
1506        }
1507
1508        let name_range = Range::new(Position::new(0, 0), Position::new(0, 14));
1509        let formatter = MockFormatter;
1510        let parse_result = BlockedRegistryParseResult {
1511            deps: vec![MockDep {
1512                name: "internal-crate".into(),
1513                version_req: "1.0.0".into(),
1514                version_range: Range::new(Position::new(0, 20), Position::new(0, 25)),
1515                name_range,
1516            }],
1517            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1518            blocked: vec![(
1519                name_range,
1520                HostClass::CloudMetadata,
1521                "https://169.254.169.254/index".to_string(),
1522            )],
1523        };
1524
1525        let cached_versions = HashMap::new();
1526        let resolved_versions = HashMap::new();
1527
1528        let diagnostics = generate_diagnostics_from_cache(
1529            &parse_result,
1530            VersionData::new(&cached_versions, &resolved_versions),
1531            &formatter,
1532            parse_result.uri(),
1533            crate::freshness::FreshnessSettings::default(),
1534            DiagnosticSeverities::default(),
1535            PublishTime::now(),
1536        );
1537
1538        let blocked_diagnostic = diagnostics
1539            .iter()
1540            .find(|d| d.message.contains("blocked"))
1541            .expect("expected a blocked-registry diagnostic");
1542        assert_eq!(blocked_diagnostic.range, name_range);
1543        assert_eq!(
1544            blocked_diagnostic.severity,
1545            Some(DiagnosticSeverity::INFORMATION)
1546        );
1547        assert!(blocked_diagnostic.message.contains("169.254.169.254"));
1548        assert!(blocked_diagnostic.message.contains("cloud metadata"));
1549        assert!(
1550            !blocked_diagnostic.message.contains("CloudMetadata"),
1551            "message must use the Display form, not the Debug identifier"
1552        );
1553    }
1554
1555    #[test]
1556    fn test_truncate_for_diagnostic_leaves_short_value_untouched() {
1557        assert_eq!(truncate_for_diagnostic("my-corp", 128), "my-corp");
1558    }
1559
1560    #[test]
1561    fn test_truncate_for_diagnostic_truncates_and_appends_ellipsis() {
1562        let long_value = "a".repeat(200);
1563        let truncated = truncate_for_diagnostic(&long_value, 128);
1564        assert_eq!(truncated.chars().count(), 129); // 128 chars + the ellipsis marker
1565        assert!(truncated.ends_with('…'));
1566        assert!(truncated.starts_with(&"a".repeat(128)));
1567    }
1568
1569    #[test]
1570    fn test_truncate_for_diagnostic_never_splits_a_multibyte_character() {
1571        // Each "日" is a multi-byte UTF-8 character; a byte-based truncation could panic or
1572        // produce invalid UTF-8 landing mid-character.
1573        let long_value = "日".repeat(200);
1574        let truncated = truncate_for_diagnostic(&long_value, 128);
1575        assert_eq!(truncated.chars().count(), 129);
1576    }
1577
1578    /// The reviewer's finding: `raw_value` is attacker-controlled and unbounded upstream (no
1579    /// TOML string-length cap exists, only nesting-depth/table-count caps) — the diagnostic
1580    /// message itself must still cap it before interpolation.
1581    #[test]
1582    fn test_generate_diagnostics_from_cache_blocked_registry_message_caps_long_raw_value() {
1583        use crate::net_policy::HostClass;
1584        use tower_lsp_server::ls_types::{Position, Range};
1585
1586        struct BlockedRegistryParseResult {
1587            deps: Vec<MockDep>,
1588            uri: Uri,
1589            blocked: Vec<(Range, HostClass, String)>,
1590        }
1591
1592        impl ParseResult for BlockedRegistryParseResult {
1593            fn dependencies(&self) -> Vec<&dyn Dependency> {
1594                self.deps.iter().map(|d| d as &dyn Dependency).collect()
1595            }
1596            fn workspace_root(&self) -> Option<&std::path::Path> {
1597                None
1598            }
1599            fn uri(&self) -> &Uri {
1600                &self.uri
1601            }
1602            fn blocked_registries(&self) -> Vec<(Range, HostClass, String)> {
1603                self.blocked.clone()
1604            }
1605            fn as_any(&self) -> &dyn std::any::Any {
1606                self
1607            }
1608        }
1609
1610        let name_range = Range::new(Position::new(0, 0), Position::new(0, 14));
1611        let formatter = MockFormatter;
1612        let long_alias = "x".repeat(10_000);
1613        let parse_result = BlockedRegistryParseResult {
1614            deps: vec![MockDep {
1615                name: "internal-crate".into(),
1616                version_req: "1.0.0".into(),
1617                version_range: Range::new(Position::new(0, 20), Position::new(0, 25)),
1618                name_range,
1619            }],
1620            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1621            blocked: vec![(name_range, HostClass::InternalName, long_alias.clone())],
1622        };
1623
1624        let cached_versions = HashMap::new();
1625        let resolved_versions = HashMap::new();
1626
1627        let diagnostics = generate_diagnostics_from_cache(
1628            &parse_result,
1629            VersionData::new(&cached_versions, &resolved_versions),
1630            &formatter,
1631            parse_result.uri(),
1632            crate::freshness::FreshnessSettings::default(),
1633            DiagnosticSeverities::default(),
1634            PublishTime::now(),
1635        );
1636
1637        let blocked_diagnostic = diagnostics
1638            .iter()
1639            .find(|d| d.message.contains("blocked"))
1640            .expect("expected a blocked-registry diagnostic");
1641        assert!(
1642            blocked_diagnostic.message.len() < long_alias.len(),
1643            "a 10,000-char alias must not render in full inside the diagnostic message"
1644        );
1645        assert!(blocked_diagnostic.message.contains('…'));
1646    }
1647
1648    #[test]
1649    fn test_generate_diagnostics_from_cache_fetch_failed_not_reported_as_unknown() {
1650        use std::collections::HashMap;
1651        use tower_lsp_server::ls_types::{Position, Range};
1652
1653        // A package missing from `cached` because its registry fetch errored
1654        // or timed out (#267) must not be reported as "Unknown package" — the
1655        // registry was never successfully asked, so absence is not evidence
1656        // the package doesn't exist.
1657        let formatter = MockFormatter;
1658
1659        let parse_result = MockParseResult {
1660            deps: vec![MockDep {
1661                name: "flaky-pkg".into(),
1662                version_req: "1.0.0".into(),
1663                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1664                name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
1665            }],
1666            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1667        };
1668
1669        let cached_versions = HashMap::new();
1670        let resolved_versions = HashMap::new();
1671        let outcomes =
1672            DependencyOutcomes::new().with_fetch_failure("flaky-pkg", FetchFailure::Transient);
1673
1674        let diagnostics = generate_diagnostics_from_cache(
1675            &parse_result,
1676            VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
1677            &formatter,
1678            parse_result.uri(),
1679            crate::freshness::FreshnessSettings::default(),
1680            DiagnosticSeverities::default(),
1681            PublishTime::now(),
1682        );
1683
1684        assert_eq!(diagnostics.len(), 1);
1685        assert!(!diagnostics[0].message.contains("Unknown package"));
1686        assert!(diagnostics[0].message.contains("Registry lookup failed"));
1687        assert!(diagnostics[0].message.contains("flaky-pkg"));
1688    }
1689
1690    /// Issue #483 I2: while offline, the per-dependency "Registry lookup failed" WARNING
1691    /// (misattributing a deliberately configured mode to a registry failure) must be
1692    /// replaced by exactly one file-level INFORMATION diagnostic, not emitted per
1693    /// dependency — the same noise argument that justified suppressing the failure toast.
1694    #[test]
1695    fn test_generate_diagnostics_from_cache_offline_suppresses_per_dependency_warning() {
1696        use std::collections::HashMap;
1697        use tower_lsp_server::ls_types::{Position, Range};
1698
1699        let formatter = MockFormatter;
1700
1701        let parse_result = MockParseResult {
1702            deps: vec![
1703                MockDep {
1704                    name: "flaky-pkg-a".into(),
1705                    version_req: "1.0.0".into(),
1706                    version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1707                    name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
1708                },
1709                MockDep {
1710                    name: "flaky-pkg-b".into(),
1711                    version_req: "1.0.0".into(),
1712                    version_range: Range::new(Position::new(1, 10), Position::new(1, 20)),
1713                    name_range: Range::new(Position::new(1, 0), Position::new(1, 11)),
1714                },
1715            ],
1716            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1717        };
1718
1719        let cached_versions = HashMap::new();
1720        let resolved_versions = HashMap::new();
1721        let outcomes = DependencyOutcomes::new()
1722            .with_fetch_failure("flaky-pkg-a", FetchFailure::Transient)
1723            .with_fetch_failure("flaky-pkg-b", FetchFailure::Transient);
1724
1725        let diagnostics = generate_diagnostics_from_cache(
1726            &parse_result,
1727            VersionData::new(&cached_versions, &resolved_versions)
1728                .with_outcomes(&outcomes)
1729                .with_offline(true),
1730            &formatter,
1731            parse_result.uri(),
1732            crate::freshness::FreshnessSettings::default(),
1733            DiagnosticSeverities::default(),
1734            PublishTime::now(),
1735        );
1736
1737        assert!(
1738            !diagnostics
1739                .iter()
1740                .any(|d| d.message.contains("Registry lookup failed")),
1741            "the per-dependency WARNING must not fire while offline, got: {diagnostics:?}"
1742        );
1743        let offline_diagnostics: Vec<_> = diagnostics
1744            .iter()
1745            .filter(|d| d.severity == Some(DiagnosticSeverity::INFORMATION))
1746            .filter(|d| d.message.to_lowercase().contains("offline"))
1747            .collect();
1748        assert_eq!(
1749            offline_diagnostics.len(),
1750            1,
1751            "expected exactly one file-level offline diagnostic, not one per dependency; \
1752             got: {diagnostics:?}"
1753        );
1754    }
1755
1756    /// Issue #483 I2: an offline document with no fetch failures at all (everything
1757    /// served from a warm cache) must still surface the file-level offline signal — S2's
1758    /// premise is that "no warning" must not read as "safe" in this persistent mode,
1759    /// independent of whether any individual dependency's lookup happened to fail.
1760    #[test]
1761    fn test_generate_diagnostics_from_cache_offline_signal_present_even_with_no_fetch_failures() {
1762        use std::collections::HashMap;
1763        use tower_lsp_server::ls_types::{Position, Range};
1764
1765        let formatter = MockFormatter;
1766
1767        let parse_result = MockParseResult {
1768            deps: vec![MockDep {
1769                name: "serde".into(),
1770                version_req: "=1.0.0".into(),
1771                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1772                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
1773            }],
1774            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1775        };
1776
1777        let mut cached_versions = HashMap::new();
1778        cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.0"));
1779        let resolved_versions = HashMap::new();
1780
1781        let diagnostics = generate_diagnostics_from_cache(
1782            &parse_result,
1783            VersionData::new(&cached_versions, &resolved_versions).with_offline(true),
1784            &formatter,
1785            parse_result.uri(),
1786            crate::freshness::FreshnessSettings::default(),
1787            DiagnosticSeverities::default(),
1788            PublishTime::now(),
1789        );
1790
1791        assert!(
1792            diagnostics
1793                .iter()
1794                .any(|d| d.severity == Some(DiagnosticSeverity::INFORMATION)
1795                    && d.message.to_lowercase().contains("offline")),
1796            "expected a file-level offline diagnostic even with zero fetch failures; \
1797             got: {diagnostics:?}"
1798        );
1799    }
1800
1801    #[test]
1802    fn test_generate_diagnostics_from_cache_fetch_failed_actionable_shows_hint() {
1803        use std::collections::HashMap;
1804        use tower_lsp_server::ls_types::{Position, Range};
1805
1806        // #478: an `Actionable` fetch failure must surface its pre-vetted hint
1807        // text in the diagnostic, not the generic fallback.
1808        let formatter = MockFormatter;
1809
1810        let parse_result = MockParseResult {
1811            deps: vec![MockDep {
1812                name: "rate-limited-pkg".into(),
1813                version_req: "1.0.0".into(),
1814                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1815                name_range: Range::new(Position::new(0, 0), Position::new(0, 16)),
1816            }],
1817            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1818        };
1819
1820        let cached_versions = HashMap::new();
1821        let resolved_versions = HashMap::new();
1822        let outcomes = DependencyOutcomes::new().with_fetch_failure(
1823            "rate-limited-pkg",
1824            FetchFailure::Actionable("set GITHUB_TOKEN to increase the rate limit".to_string()),
1825        );
1826
1827        let diagnostics = generate_diagnostics_from_cache(
1828            &parse_result,
1829            VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
1830            &formatter,
1831            parse_result.uri(),
1832            crate::freshness::FreshnessSettings::default(),
1833            DiagnosticSeverities::default(),
1834            PublishTime::now(),
1835        );
1836
1837        assert_eq!(diagnostics.len(), 1);
1838        assert!(!diagnostics[0].message.contains("Unknown package"));
1839        assert!(
1840            diagnostics[0]
1841                .message
1842                .contains("set GITHUB_TOKEN to increase the rate limit")
1843        );
1844        assert!(diagnostics[0].message.contains("rate-limited-pkg"));
1845    }
1846
1847    #[test]
1848    fn test_generate_diagnostics_from_cache_multiple_fetch_failed_collapse_into_one_diagnostic() {
1849        use std::collections::HashMap;
1850        use tower_lsp_server::ls_types::{Position, Range};
1851
1852        // #479: a registry-wide condition (e.g. a rate limit tripped by one dependency)
1853        // can fail every remaining dependency identically. N near-duplicate
1854        // per-dependency "Registry lookup failed" diagnostics carry no more information
1855        // than one combined diagnostic — this asserts the 2+ case actually collapses,
1856        // on the first failing dependency's range, with the count in the message.
1857        let formatter = MockFormatter;
1858
1859        let name_range_1 = Range::new(Position::new(0, 0), Position::new(0, 8));
1860        let name_range_2 = Range::new(Position::new(1, 0), Position::new(1, 8));
1861        let name_range_3 = Range::new(Position::new(2, 0), Position::new(2, 8));
1862
1863        let parse_result = MockParseResult {
1864            deps: vec![
1865                MockDep {
1866                    name: "flaky-1".into(),
1867                    version_req: "1.0.0".into(),
1868                    version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1869                    name_range: name_range_1,
1870                },
1871                MockDep {
1872                    name: "flaky-2".into(),
1873                    version_req: "1.0.0".into(),
1874                    version_range: Range::new(Position::new(1, 10), Position::new(1, 20)),
1875                    name_range: name_range_2,
1876                },
1877                MockDep {
1878                    name: "flaky-3".into(),
1879                    version_req: "1.0.0".into(),
1880                    version_range: Range::new(Position::new(2, 10), Position::new(2, 20)),
1881                    name_range: name_range_3,
1882                },
1883            ],
1884            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1885        };
1886
1887        let cached_versions = HashMap::new();
1888        let resolved_versions = HashMap::new();
1889        let outcomes = DependencyOutcomes::new()
1890            .with_fetch_failure("flaky-1", FetchFailure::Transient)
1891            .with_fetch_failure("flaky-2", FetchFailure::Transient)
1892            .with_fetch_failure("flaky-3", FetchFailure::Transient);
1893
1894        let diagnostics = generate_diagnostics_from_cache(
1895            &parse_result,
1896            VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
1897            &formatter,
1898            parse_result.uri(),
1899            crate::freshness::FreshnessSettings::default(),
1900            DiagnosticSeverities::default(),
1901            PublishTime::now(),
1902        );
1903
1904        assert_eq!(
1905            diagnostics.len(),
1906            1,
1907            "3 fetch-failed dependencies must collapse into exactly one diagnostic, got: {diagnostics:?}"
1908        );
1909        assert!(
1910            diagnostics[0]
1911                .message
1912                .contains("Registry lookup failed for 3 packages")
1913        );
1914        assert_eq!(
1915            diagnostics[0].range, name_range_1,
1916            "the combined diagnostic must sit on the first failing dependency's range"
1917        );
1918
1919        // #480 S2: the collapse must not silently drop the other failing dependencies —
1920        // each one beyond the first survives via `related_information`, keyed to its own
1921        // name and `name_range`.
1922        let related_information = diagnostics[0]
1923            .related_information
1924            .as_ref()
1925            .expect("collapsed diagnostic must carry related_information for the dropped deps");
1926        assert_eq!(
1927            related_information.len(),
1928            2,
1929            "expected one related_information entry per additional failing dependency (n - 1)"
1930        );
1931        assert_eq!(related_information[0].location.range, name_range_2);
1932        assert_eq!(related_information[0].location.uri, *parse_result.uri());
1933        assert!(related_information[0].message.contains("flaky-2"));
1934        assert_eq!(related_information[1].location.range, name_range_3);
1935        assert_eq!(related_information[1].location.uri, *parse_result.uri());
1936        assert!(related_information[1].message.contains("flaky-3"));
1937    }
1938
1939    #[test]
1940    fn test_generate_diagnostics_from_cache_two_fetch_failed_collapse_boundary() {
1941        use std::collections::HashMap;
1942        use tower_lsp_server::ls_types::{Position, Range};
1943
1944        // n==2 is the lowest n that collapses at all (n==1 stays a plain per-dependency
1945        // diagnostic — see `test_generate_diagnostics_from_cache_fetch_failed_not_reported_as_unknown`
1946        // above) — this confirms `related_information` is populated right at that
1947        // threshold, not just once there are 3+ failures to fold in.
1948        let formatter = MockFormatter;
1949
1950        let name_range_1 = Range::new(Position::new(0, 0), Position::new(0, 8));
1951        let name_range_2 = Range::new(Position::new(1, 0), Position::new(1, 8));
1952
1953        let parse_result = MockParseResult {
1954            deps: vec![
1955                MockDep {
1956                    name: "flaky-1".into(),
1957                    version_req: "1.0.0".into(),
1958                    version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
1959                    name_range: name_range_1,
1960                },
1961                MockDep {
1962                    name: "flaky-2".into(),
1963                    version_req: "1.0.0".into(),
1964                    version_range: Range::new(Position::new(1, 10), Position::new(1, 20)),
1965                    name_range: name_range_2,
1966                },
1967            ],
1968            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1969        };
1970
1971        let cached_versions = HashMap::new();
1972        let resolved_versions = HashMap::new();
1973        let outcomes = DependencyOutcomes::new()
1974            .with_fetch_failure("flaky-1", FetchFailure::Transient)
1975            .with_fetch_failure("flaky-2", FetchFailure::Transient);
1976
1977        let diagnostics = generate_diagnostics_from_cache(
1978            &parse_result,
1979            VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
1980            &formatter,
1981            parse_result.uri(),
1982            crate::freshness::FreshnessSettings::default(),
1983            DiagnosticSeverities::default(),
1984            PublishTime::now(),
1985        );
1986
1987        assert_eq!(
1988            diagnostics.len(),
1989            1,
1990            "2 fetch-failed dependencies must already collapse into one diagnostic, got: {diagnostics:?}"
1991        );
1992        assert!(
1993            diagnostics[0]
1994                .message
1995                .contains("Registry lookup failed for 2 packages")
1996        );
1997        let related_information = diagnostics[0]
1998            .related_information
1999            .as_ref()
2000            .expect("the n==2 collapse must already carry related_information");
2001        assert_eq!(
2002            related_information.len(),
2003            1,
2004            "n==2 collapse must carry exactly one related_information entry (n - 1)"
2005        );
2006        assert_eq!(related_information[0].location.range, name_range_2);
2007        assert_eq!(related_information[0].location.uri, *parse_result.uri());
2008        assert!(related_information[0].message.contains("flaky-2"));
2009    }
2010
2011    #[test]
2012    fn test_generate_diagnostics_from_cache_multiple_fetch_failed_shared_actionable_hint() {
2013        use std::collections::HashMap;
2014        use tower_lsp_server::ls_types::{Position, Range};
2015
2016        // #478/#485 + #479: the motivating scenario for both — a rate-limit gate trips
2017        // and fails every remaining dependency with the SAME `Actionable` hint. The
2018        // #479 collapse must not silently drop that hint just because 2+ failures
2019        // collapsed into one diagnostic.
2020        let formatter = MockFormatter;
2021
2022        let name_range_1 = Range::new(Position::new(0, 0), Position::new(0, 8));
2023        let name_range_2 = Range::new(Position::new(1, 0), Position::new(1, 8));
2024        let name_range_3 = Range::new(Position::new(2, 0), Position::new(2, 8));
2025
2026        let parse_result = MockParseResult {
2027            deps: vec![
2028                MockDep {
2029                    name: "flaky-1".into(),
2030                    version_req: "1.0.0".into(),
2031                    version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2032                    name_range: name_range_1,
2033                },
2034                MockDep {
2035                    name: "flaky-2".into(),
2036                    version_req: "1.0.0".into(),
2037                    version_range: Range::new(Position::new(1, 10), Position::new(1, 20)),
2038                    name_range: name_range_2,
2039                },
2040                MockDep {
2041                    name: "flaky-3".into(),
2042                    version_req: "1.0.0".into(),
2043                    version_range: Range::new(Position::new(2, 10), Position::new(2, 20)),
2044                    name_range: name_range_3,
2045                },
2046            ],
2047            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2048        };
2049
2050        let cached_versions = HashMap::new();
2051        let resolved_versions = HashMap::new();
2052        let shared_hint = "set GITHUB_TOKEN to increase the rate limit".to_string();
2053        let outcomes = DependencyOutcomes::new()
2054            .with_fetch_failure("flaky-1", FetchFailure::Actionable(shared_hint.clone()))
2055            .with_fetch_failure("flaky-2", FetchFailure::Actionable(shared_hint.clone()))
2056            .with_fetch_failure("flaky-3", FetchFailure::Actionable(shared_hint.clone()));
2057
2058        let diagnostics = generate_diagnostics_from_cache(
2059            &parse_result,
2060            VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
2061            &formatter,
2062            parse_result.uri(),
2063            crate::freshness::FreshnessSettings::default(),
2064            DiagnosticSeverities::default(),
2065            PublishTime::now(),
2066        );
2067
2068        assert_eq!(
2069            diagnostics.len(),
2070            1,
2071            "3 fetch-failed dependencies must still collapse into exactly one diagnostic, got: {diagnostics:?}"
2072        );
2073        assert!(
2074            diagnostics[0].message.contains(&shared_hint),
2075            "collapsed diagnostic must surface the shared actionable hint, got: {}",
2076            diagnostics[0].message
2077        );
2078        assert!(
2079            !diagnostics[0]
2080                .message
2081                .contains("package status could not be determined"),
2082            "the actionable hint must replace, not accompany, the generic fallback message"
2083        );
2084        let related_information = diagnostics[0]
2085            .related_information
2086            .as_ref()
2087            .expect("collapsed diagnostic must carry related_information for the dropped deps");
2088        assert_eq!(
2089            related_information.len(),
2090            2,
2091            "expected one related_information entry per additional failing dependency (n - 1)"
2092        );
2093    }
2094
2095    #[test]
2096    fn test_generate_diagnostics_from_cache_fetch_failed_transient_shows_generic_message() {
2097        use std::collections::HashMap;
2098        use tower_lsp_server::ls_types::{Position, Range};
2099
2100        // A `Transient` fetch failure has no safe detail to show, so it must
2101        // fall back to the generic "package status could not be determined"
2102        // message rather than leak anything failure-specific.
2103        let formatter = MockFormatter;
2104
2105        let parse_result = MockParseResult {
2106            deps: vec![MockDep {
2107                name: "transient-pkg".into(),
2108                version_req: "1.0.0".into(),
2109                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2110                name_range: Range::new(Position::new(0, 0), Position::new(0, 13)),
2111            }],
2112            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2113        };
2114
2115        let cached_versions = HashMap::new();
2116        let resolved_versions = HashMap::new();
2117        let outcomes =
2118            DependencyOutcomes::new().with_fetch_failure("transient-pkg", FetchFailure::Transient);
2119
2120        let diagnostics = generate_diagnostics_from_cache(
2121            &parse_result,
2122            VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
2123            &formatter,
2124            parse_result.uri(),
2125            crate::freshness::FreshnessSettings::default(),
2126            DiagnosticSeverities::default(),
2127            PublishTime::now(),
2128        );
2129
2130        assert_eq!(diagnostics.len(), 1);
2131        assert!(!diagnostics[0].message.contains("Unknown package"));
2132        assert!(
2133            diagnostics[0]
2134                .message
2135                .contains("package status could not be determined")
2136        );
2137    }
2138
2139    #[test]
2140    fn test_generate_diagnostics_from_cache_fetch_failed_not_attempted_shows_generic_message() {
2141        use std::collections::HashMap;
2142        use tower_lsp_server::ls_types::{Position, Range};
2143
2144        // #478 fix (impl-critic S1): `NotAttempted` (a source-collided
2145        // dependency that was deliberately never queried) must render the
2146        // SAME generic message as `Transient`, never "Unknown package" — the
2147        // dependency's non-fetch is not evidence it doesn't exist.
2148        let formatter = MockFormatter;
2149
2150        let parse_result = MockParseResult {
2151            deps: vec![MockDep {
2152                name: "collided-pkg".into(),
2153                version_req: "1.0.0".into(),
2154                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2155                name_range: Range::new(Position::new(0, 0), Position::new(0, 12)),
2156            }],
2157            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2158        };
2159
2160        let cached_versions = HashMap::new();
2161        let resolved_versions = HashMap::new();
2162        let outcomes = DependencyOutcomes::new()
2163            .with_fetch_failure("collided-pkg", FetchFailure::NotAttempted);
2164
2165        let diagnostics = generate_diagnostics_from_cache(
2166            &parse_result,
2167            VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
2168            &formatter,
2169            parse_result.uri(),
2170            crate::freshness::FreshnessSettings::default(),
2171            DiagnosticSeverities::default(),
2172            PublishTime::now(),
2173        );
2174
2175        assert_eq!(diagnostics.len(), 1);
2176        assert!(!diagnostics[0].message.contains("Unknown package"));
2177        assert!(
2178            diagnostics[0]
2179                .message
2180                .contains("package status could not be determined")
2181        );
2182    }
2183
2184    #[test]
2185    fn test_generate_diagnostics_from_cache_fetch_failed_does_not_mask_invalid_name() {
2186        use std::collections::HashMap;
2187        use tower_lsp_server::ls_types::{Position, Range};
2188
2189        // A syntactically invalid name is a local, name-only check independent
2190        // of any registry round trip — it must win over a fetch-failure
2191        // marker for the same (invalid) name, not be suppressed by it.
2192        let formatter = RejectingFormatter;
2193
2194        let parse_result = MockParseResult {
2195            deps: vec![MockDep {
2196                name: "bad name".into(),
2197                version_req: "1.0.0".into(),
2198                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2199                name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
2200            }],
2201            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2202        };
2203
2204        let cached_versions = HashMap::new();
2205        let resolved_versions = HashMap::new();
2206        let outcomes =
2207            DependencyOutcomes::new().with_fetch_failure("bad name", FetchFailure::Transient);
2208
2209        let diagnostics = generate_diagnostics_from_cache(
2210            &parse_result,
2211            VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
2212            &formatter,
2213            parse_result.uri(),
2214            crate::freshness::FreshnessSettings::default(),
2215            DiagnosticSeverities::default(),
2216            PublishTime::now(),
2217        );
2218
2219        assert_eq!(diagnostics.len(), 1);
2220        assert!(diagnostics[0].message.contains("Invalid package name"));
2221    }
2222
2223    #[test]
2224    fn test_generate_diagnostics_from_cache_invalid_package_name() {
2225        use std::collections::HashMap;
2226        use tower_lsp_server::ls_types::{Position, Range};
2227
2228        // A formatter that rejects every name must produce exactly one
2229        // "Invalid package name" diagnostic per unresolved dependency, never
2230        // both that and "Unknown package".
2231        let formatter = RejectingFormatter;
2232
2233        let parse_result = MockParseResult {
2234            deps: vec![MockDep {
2235                name: "bad-pkg".into(),
2236                version_req: "1.0.0".into(),
2237                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2238                name_range: Range::new(Position::new(0, 0), Position::new(0, 7)),
2239            }],
2240            uri: crate::test_util::test_uri("/test/package.json"),
2241        };
2242
2243        let cached_versions = HashMap::new();
2244        let resolved_versions = HashMap::new();
2245
2246        let diagnostics = generate_diagnostics_from_cache(
2247            &parse_result,
2248            VersionData::new(&cached_versions, &resolved_versions),
2249            &formatter,
2250            parse_result.uri(),
2251            crate::FreshnessSettings::default(),
2252            DiagnosticSeverities::default(),
2253            PublishTime::now(),
2254        );
2255
2256        assert_eq!(diagnostics.len(), 1);
2257        assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::WARNING));
2258        assert!(diagnostics[0].message.starts_with("Invalid package name"));
2259        assert!(!diagnostics[0].message.contains("Unknown package"));
2260    }
2261
2262    #[test]
2263    fn test_generate_diagnostics_from_cache_outdated_version() {
2264        use std::collections::HashMap;
2265        use tower_lsp_server::ls_types::{Position, Range};
2266
2267        let formatter = MockFormatter;
2268
2269        let parse_result = MockParseResult {
2270            deps: vec![MockDep {
2271                name: "serde".into(),
2272                version_req: "1.0".into(),
2273                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2274                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2275            }],
2276            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2277        };
2278
2279        let mut cached_versions = HashMap::new();
2280        cached_versions.insert("serde".into(), PackageVersions::latest_only("2.0.0"));
2281
2282        let resolved_versions = HashMap::new();
2283
2284        let diagnostics = generate_diagnostics_from_cache(
2285            &parse_result,
2286            VersionData::new(&cached_versions, &resolved_versions),
2287            &formatter,
2288            parse_result.uri(),
2289            crate::freshness::FreshnessSettings::default(),
2290            DiagnosticSeverities::default(),
2291            PublishTime::now(),
2292        );
2293
2294        assert_eq!(diagnostics.len(), 1);
2295        assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::HINT));
2296        assert!(diagnostics[0].message.contains("Newer version available"));
2297        assert!(diagnostics[0].message.contains("2.0.0"));
2298    }
2299
2300    /// Issue #227 §4.3: an outdated dependency whose `latest` was published within the
2301    /// configured cooldown window gets the extra context appended, severity unchanged.
2302    #[test]
2303    fn test_generate_diagnostics_from_cache_outdated_within_cooldown_appends_context() {
2304        use std::collections::HashMap;
2305        use tower_lsp_server::ls_types::{Position, Range};
2306
2307        let formatter = MockFormatter;
2308
2309        let parse_result = MockParseResult {
2310            deps: vec![MockDep {
2311                name: "serde".into(),
2312                version_req: "1.0".into(),
2313                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2314                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2315            }],
2316            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2317        };
2318
2319        let mut cached_versions = HashMap::new();
2320        cached_versions.insert(
2321            "serde".into(),
2322            PackageVersions {
2323                latest: "2.0.0".into(),
2324                available: Arc::from(vec!["2.0.0".into()]),
2325                yanked: Arc::from(Vec::new()),
2326                // 1 hour ago — well within the default 3-day cooldown.
2327                published_at: Some(PublishTime::from_unix_secs(
2328                    PublishTime::now().as_unix_secs() - 60 * 60,
2329                )),
2330            },
2331        );
2332        let resolved_versions = HashMap::new();
2333
2334        let diagnostics = generate_diagnostics_from_cache(
2335            &parse_result,
2336            VersionData::new(&cached_versions, &resolved_versions),
2337            &formatter,
2338            parse_result.uri(),
2339            crate::freshness::FreshnessSettings::default(),
2340            DiagnosticSeverities::default(),
2341            PublishTime::now(),
2342        );
2343
2344        assert_eq!(diagnostics.len(), 1);
2345        assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::HINT));
2346        assert_eq!(
2347            diagnostics[0].message,
2348            "Newer version available: 2.0.0 (published 1 hour ago — still within the release cooldown window)"
2349        );
2350        // Guards against reintroducing the "ago ago" duplication bug found while
2351        // writing this test — `format_relative_age` already appends "ago".
2352        assert!(!diagnostics[0].message.contains("ago ago"));
2353    }
2354
2355    /// Same setup, but `latest` was published well outside the cooldown window — the
2356    /// message must stay exactly the pre-feature text.
2357    #[test]
2358    fn test_generate_diagnostics_from_cache_outdated_outside_cooldown_plain_message() {
2359        use std::collections::HashMap;
2360        use tower_lsp_server::ls_types::{Position, Range};
2361
2362        let formatter = MockFormatter;
2363
2364        let parse_result = MockParseResult {
2365            deps: vec![MockDep {
2366                name: "serde".into(),
2367                version_req: "1.0".into(),
2368                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2369                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2370            }],
2371            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2372        };
2373
2374        let mut cached_versions = HashMap::new();
2375        cached_versions.insert(
2376            "serde".into(),
2377            PackageVersions {
2378                latest: "2.0.0".into(),
2379                available: Arc::from(vec!["2.0.0".into()]),
2380                yanked: Arc::from(Vec::new()),
2381                // 10 days ago — outside the default 3-day cooldown.
2382                published_at: Some(PublishTime::from_unix_secs(
2383                    PublishTime::now().as_unix_secs() - 10 * 24 * 60 * 60,
2384                )),
2385            },
2386        );
2387        let resolved_versions = HashMap::new();
2388
2389        let diagnostics = generate_diagnostics_from_cache(
2390            &parse_result,
2391            VersionData::new(&cached_versions, &resolved_versions),
2392            &formatter,
2393            parse_result.uri(),
2394            crate::freshness::FreshnessSettings::default(),
2395            DiagnosticSeverities::default(),
2396            PublishTime::now(),
2397        );
2398
2399        assert_eq!(diagnostics.len(), 1);
2400        assert_eq!(diagnostics[0].message, "Newer version available: 2.0.0");
2401    }
2402
2403    /// `freshness.enabled: false` suppresses the cooldown differentiation even when the
2404    /// publish age would otherwise qualify.
2405    #[test]
2406    fn test_generate_diagnostics_from_cache_outdated_freshness_disabled_plain_message() {
2407        use std::collections::HashMap;
2408        use tower_lsp_server::ls_types::{Position, Range};
2409
2410        let formatter = MockFormatter;
2411
2412        let parse_result = MockParseResult {
2413            deps: vec![MockDep {
2414                name: "serde".into(),
2415                version_req: "1.0".into(),
2416                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2417                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2418            }],
2419            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2420        };
2421
2422        let mut cached_versions = HashMap::new();
2423        cached_versions.insert(
2424            "serde".into(),
2425            PackageVersions {
2426                latest: "2.0.0".into(),
2427                available: Arc::from(vec!["2.0.0".into()]),
2428                yanked: Arc::from(Vec::new()),
2429                published_at: Some(PublishTime::from_unix_secs(
2430                    PublishTime::now().as_unix_secs() - 60 * 60,
2431                )),
2432            },
2433        );
2434        let resolved_versions = HashMap::new();
2435
2436        let diagnostics = generate_diagnostics_from_cache(
2437            &parse_result,
2438            VersionData::new(&cached_versions, &resolved_versions),
2439            &formatter,
2440            parse_result.uri(),
2441            crate::freshness::FreshnessSettings {
2442                enabled: false,
2443                ..crate::freshness::FreshnessSettings::default()
2444            },
2445            DiagnosticSeverities::default(),
2446            PublishTime::now(),
2447        );
2448
2449        assert_eq!(diagnostics.len(), 1);
2450        assert_eq!(diagnostics[0].message, "Newer version available: 2.0.0");
2451    }
2452
2453    /// Deterministic boundary test (issue #227 M4): `now` is threaded in as a parameter
2454    /// rather than read internally, so `published_at`/`now`/`cooldown_secs` can be pinned
2455    /// to fixed absolute values with no wall-clock dependency. `age == cooldown_secs`
2456    /// exactly must NOT be within cooldown — the bound is exclusive (`age < cooldown`).
2457    #[test]
2458    fn test_generate_diagnostics_from_cache_outdated_cooldown_boundary_is_exclusive() {
2459        use std::collections::HashMap;
2460        use tower_lsp_server::ls_types::{Position, Range};
2461
2462        const COOLDOWN_SECS: u64 = 100;
2463        let now = PublishTime::from_unix_secs(10_000);
2464        let published_at_at_boundary =
2465            PublishTime::from_unix_secs(10_000 - COOLDOWN_SECS.cast_signed());
2466
2467        let formatter = MockFormatter;
2468        let parse_result = MockParseResult {
2469            deps: vec![MockDep {
2470                name: "serde".into(),
2471                version_req: "1.0".into(),
2472                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2473                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2474            }],
2475            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2476        };
2477
2478        let mut cached_versions = HashMap::new();
2479        cached_versions.insert(
2480            "serde".into(),
2481            PackageVersions {
2482                latest: "2.0.0".into(),
2483                available: Arc::from(vec!["2.0.0".into()]),
2484                yanked: Arc::from(Vec::new()),
2485                published_at: Some(published_at_at_boundary),
2486            },
2487        );
2488        let resolved_versions = HashMap::new();
2489
2490        let diagnostics = generate_diagnostics_from_cache(
2491            &parse_result,
2492            VersionData::new(&cached_versions, &resolved_versions),
2493            &formatter,
2494            parse_result.uri(),
2495            crate::freshness::FreshnessSettings {
2496                enabled: true,
2497                cooldown_secs: COOLDOWN_SECS,
2498            },
2499            DiagnosticSeverities::default(),
2500            now,
2501        );
2502
2503        assert_eq!(diagnostics.len(), 1);
2504        assert_eq!(
2505            diagnostics[0].message, "Newer version available: 2.0.0",
2506            "age exactly equal to cooldown_secs must not be within cooldown"
2507        );
2508    }
2509
2510    /// Same fixture, one second younger — must flip to the within-cooldown message.
2511    #[test]
2512    fn test_generate_diagnostics_from_cache_outdated_cooldown_boundary_one_second_inside() {
2513        use std::collections::HashMap;
2514        use tower_lsp_server::ls_types::{Position, Range};
2515
2516        const COOLDOWN_SECS: u64 = 100;
2517        let now = PublishTime::from_unix_secs(10_000);
2518        let published_at_just_inside =
2519            PublishTime::from_unix_secs(10_000 - (COOLDOWN_SECS.cast_signed() - 1));
2520
2521        let formatter = MockFormatter;
2522        let parse_result = MockParseResult {
2523            deps: vec![MockDep {
2524                name: "serde".into(),
2525                version_req: "1.0".into(),
2526                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2527                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2528            }],
2529            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2530        };
2531
2532        let mut cached_versions = HashMap::new();
2533        cached_versions.insert(
2534            "serde".into(),
2535            PackageVersions {
2536                latest: "2.0.0".into(),
2537                available: Arc::from(vec!["2.0.0".into()]),
2538                yanked: Arc::from(Vec::new()),
2539                published_at: Some(published_at_just_inside),
2540            },
2541        );
2542        let resolved_versions = HashMap::new();
2543
2544        let diagnostics = generate_diagnostics_from_cache(
2545            &parse_result,
2546            VersionData::new(&cached_versions, &resolved_versions),
2547            &formatter,
2548            parse_result.uri(),
2549            crate::freshness::FreshnessSettings {
2550                enabled: true,
2551                cooldown_secs: COOLDOWN_SECS,
2552            },
2553            DiagnosticSeverities::default(),
2554            now,
2555        );
2556
2557        assert_eq!(diagnostics.len(), 1);
2558        assert_eq!(
2559            diagnostics[0].message,
2560            "Newer version available: 2.0.0 (published 1 minute ago — still within the release cooldown window)",
2561            "age == cooldown_secs - 1 must be within cooldown"
2562        );
2563    }
2564
2565    #[test]
2566    fn test_generate_diagnostics_from_cache_up_to_date() {
2567        use std::collections::HashMap;
2568        use tower_lsp_server::ls_types::{Position, Range};
2569
2570        let formatter = MockFormatter;
2571
2572        let parse_result = MockParseResult {
2573            deps: vec![MockDep {
2574                name: "serde".into(),
2575                version_req: "^1.0".into(),
2576                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2577                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2578            }],
2579            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2580        };
2581
2582        let mut cached_versions = HashMap::new();
2583        cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.214"));
2584
2585        let resolved_versions = HashMap::new();
2586
2587        let diagnostics = generate_diagnostics_from_cache(
2588            &parse_result,
2589            VersionData::new(&cached_versions, &resolved_versions),
2590            &formatter,
2591            parse_result.uri(),
2592            crate::freshness::FreshnessSettings::default(),
2593            DiagnosticSeverities::default(),
2594            PublishTime::now(),
2595        );
2596
2597        assert!(
2598            diagnostics.is_empty(),
2599            "Expected no diagnostics for up-to-date dependency"
2600        );
2601    }
2602
2603    #[test]
2604    fn test_generate_diagnostics_from_cache_multiple_deps() {
2605        use std::collections::HashMap;
2606        use tower_lsp_server::ls_types::{Position, Range};
2607
2608        let formatter = MockFormatter;
2609
2610        let parse_result = MockParseResult {
2611            deps: vec![
2612                MockDep {
2613                    name: "serde".into(),
2614                    version_req: "^1.0".into(),
2615                    version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2616                    name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2617                },
2618                MockDep {
2619                    name: "tokio".into(),
2620                    version_req: "1.0".into(),
2621                    version_range: Range::new(Position::new(1, 10), Position::new(1, 20)),
2622                    name_range: Range::new(Position::new(1, 0), Position::new(1, 5)),
2623                },
2624                MockDep {
2625                    name: "unknown".into(),
2626                    version_req: "1.0".into(),
2627                    version_range: Range::new(Position::new(2, 10), Position::new(2, 20)),
2628                    name_range: Range::new(Position::new(2, 0), Position::new(2, 7)),
2629                },
2630            ],
2631            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2632        };
2633
2634        let mut cached_versions = HashMap::new();
2635        cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.214"));
2636        cached_versions.insert("tokio".into(), PackageVersions::latest_only("2.0.0"));
2637
2638        let resolved_versions = HashMap::new();
2639
2640        let diagnostics = generate_diagnostics_from_cache(
2641            &parse_result,
2642            VersionData::new(&cached_versions, &resolved_versions),
2643            &formatter,
2644            parse_result.uri(),
2645            crate::freshness::FreshnessSettings::default(),
2646            DiagnosticSeverities::default(),
2647            PublishTime::now(),
2648        );
2649
2650        assert_eq!(diagnostics.len(), 2);
2651
2652        // Dependency-loop order (not rule order): "serde" is up to date and emits
2653        // nothing, so index 0 is "tokio"'s R7 outdated diagnostic and index 1 is
2654        // "unknown"'s R5d unknown-package diagnostic — the same order as `deps`.
2655        assert_eq!(diagnostics[0].message, "Newer version available: 2.0.0");
2656        assert_eq!(diagnostics[0].code, None);
2657        assert_eq!(diagnostics[1].message, "Unknown package 'unknown'");
2658        assert_eq!(diagnostics[1].code, None);
2659    }
2660
2661    #[test]
2662    fn test_generate_diagnostics_from_cache_unresolved_emits_no_diagnostic() {
2663        use std::collections::HashMap;
2664        use tower_lsp_server::ls_types::{Position, Range};
2665
2666        let formatter = MockUnresolvedFormatter;
2667
2668        let parse_result = MockParseResult {
2669            deps: vec![MockDep {
2670                name: "spring-boot-starter".into(),
2671                version_req: "$missing".into(),
2672                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2673                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2674            }],
2675            uri: crate::test_util::test_uri("/test/libs.versions.toml"),
2676        };
2677
2678        let mut cached_versions = HashMap::new();
2679        cached_versions.insert(
2680            "spring-boot-starter".into(),
2681            PackageVersions::latest_only("3.2.0"),
2682        );
2683
2684        let resolved_versions = HashMap::new();
2685
2686        let diagnostics = generate_diagnostics_from_cache(
2687            &parse_result,
2688            VersionData::new(&cached_versions, &resolved_versions),
2689            &formatter,
2690            parse_result.uri(),
2691            crate::freshness::FreshnessSettings::default(),
2692            DiagnosticSeverities::default(),
2693            PublishTime::now(),
2694        );
2695
2696        assert!(
2697            diagnostics.is_empty(),
2698            "Expected no diagnostics for an unresolved requirement, got: {diagnostics:?}"
2699        );
2700    }
2701
2702    #[test]
2703    fn test_generate_diagnostics_from_cache_yanked_uses_configured_severity() {
2704        use std::collections::HashMap;
2705        use tower_lsp_server::ls_types::{Position, Range};
2706
2707        let formatter = MockFormatter;
2708
2709        let parse_result = MockParseResult {
2710            deps: vec![MockDep {
2711                name: "serde".into(),
2712                version_req: "1.0.5".into(),
2713                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2714                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2715            }],
2716            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2717        };
2718
2719        let cached_versions = HashMap::new();
2720        let resolved_versions = HashMap::new();
2721        let outcomes =
2722            DependencyOutcomes::new().with_yanked("serde", ("1.0.5".into(), RemovalStatus::Yanked));
2723
2724        let severities = DiagnosticSeverities {
2725            yanked: DiagnosticSeverity::ERROR,
2726            ..DiagnosticSeverities::default()
2727        };
2728
2729        let diagnostics = generate_diagnostics_from_cache(
2730            &parse_result,
2731            VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
2732            &formatter,
2733            parse_result.uri(),
2734            crate::freshness::FreshnessSettings::default(),
2735            severities,
2736            PublishTime::now(),
2737        );
2738
2739        let yanked_diag = diagnostics
2740            .iter()
2741            .find(|d| d.message.starts_with(formatter.yanked_message()))
2742            .expect("expected a yanked diagnostic");
2743        assert_eq!(yanked_diag.severity, Some(DiagnosticSeverity::ERROR));
2744        assert!(yanked_diag.message.contains("1.0.5"));
2745    }
2746
2747    #[test]
2748    fn test_generate_diagnostics_from_cache_yanked_default_severity_unchanged() {
2749        use std::collections::HashMap;
2750        use tower_lsp_server::ls_types::{Position, Range};
2751
2752        let formatter = MockFormatter;
2753
2754        let parse_result = MockParseResult {
2755            deps: vec![MockDep {
2756                name: "serde".into(),
2757                version_req: "1.0.5".into(),
2758                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2759                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2760            }],
2761            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2762        };
2763
2764        let cached_versions = HashMap::new();
2765        let resolved_versions = HashMap::new();
2766        let outcomes =
2767            DependencyOutcomes::new().with_yanked("serde", ("1.0.5".into(), RemovalStatus::Yanked));
2768
2769        let diagnostics = generate_diagnostics_from_cache(
2770            &parse_result,
2771            VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
2772            &formatter,
2773            parse_result.uri(),
2774            crate::freshness::FreshnessSettings::default(),
2775            DiagnosticSeverities::default(),
2776            PublishTime::now(),
2777        );
2778
2779        let yanked_diag = diagnostics
2780            .iter()
2781            .find(|d| d.message.starts_with(formatter.yanked_message()))
2782            .expect("expected a yanked diagnostic");
2783        assert_eq!(yanked_diag.severity, Some(DiagnosticSeverity::WARNING));
2784    }
2785
2786    #[test]
2787    fn test_generate_diagnostics_from_cache_no_yanked_map_emits_no_yanked_diagnostic() {
2788        use std::collections::HashMap;
2789        use tower_lsp_server::ls_types::{Position, Range};
2790
2791        // Regression guard for the four handlers (hover, completion, code_lens,
2792        // inlay_hints) that keep calling `VersionData::new` without
2793        // `.with_outcomes(..)` — `outcomes: None` must never produce a diagnostic.
2794        let formatter = MockFormatter;
2795
2796        let parse_result = MockParseResult {
2797            deps: vec![MockDep {
2798                name: "serde".into(),
2799                version_req: "1.0.5".into(),
2800                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2801                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2802            }],
2803            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2804        };
2805
2806        let mut cached_versions = HashMap::new();
2807        cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.5"));
2808        let resolved_versions = HashMap::new();
2809
2810        let diagnostics = generate_diagnostics_from_cache(
2811            &parse_result,
2812            VersionData::new(&cached_versions, &resolved_versions),
2813            &formatter,
2814            parse_result.uri(),
2815            crate::freshness::FreshnessSettings::default(),
2816            DiagnosticSeverities::default(),
2817            PublishTime::now(),
2818        );
2819
2820        assert!(
2821            !diagnostics
2822                .iter()
2823                .any(|d| d.message.starts_with(formatter.yanked_message())),
2824            "Expected no yanked diagnostic when `yanked` is None, got: {diagnostics:?}"
2825        );
2826    }
2827
2828    #[test]
2829    fn test_generate_diagnostics_from_cache_yanked_and_outdated_both_emitted() {
2830        use std::collections::HashMap;
2831        use tower_lsp_server::ls_types::{Position, Range};
2832
2833        // Proves the yanked push sits before the early-`continue`s, so a dep
2834        // that is both yanked (in-use version) and outdated (vs. latest)
2835        // gets both diagnostics.
2836        let formatter = MockFormatter;
2837
2838        let parse_result = MockParseResult {
2839            deps: vec![MockDep {
2840                name: "serde".into(),
2841                version_req: "1.0.5".into(),
2842                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2843                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2844            }],
2845            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2846        };
2847
2848        let mut cached_versions = HashMap::new();
2849        cached_versions.insert("serde".into(), PackageVersions::latest_only("2.0.0"));
2850        let resolved_versions = HashMap::new();
2851        let outcomes =
2852            DependencyOutcomes::new().with_yanked("serde", ("1.0.5".into(), RemovalStatus::Yanked));
2853
2854        let diagnostics = generate_diagnostics_from_cache(
2855            &parse_result,
2856            VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
2857            &formatter,
2858            parse_result.uri(),
2859            crate::freshness::FreshnessSettings::default(),
2860            DiagnosticSeverities::default(),
2861            PublishTime::now(),
2862        );
2863
2864        assert_eq!(
2865            diagnostics.len(),
2866            2,
2867            "expected both diagnostics: {diagnostics:?}"
2868        );
2869        // R4 (in-use-yanked, #263) has no `continue` and runs before R7 (outdated) in
2870        // the orchestrator, so index 0 is always the yanked finding and index 1 is
2871        // always the outdated finding — never the reverse.
2872        assert_eq!(
2873            diagnostics[0].message,
2874            format!("{} (1.0.5)", formatter.yanked_message())
2875        );
2876        assert_eq!(diagnostics[0].code, None);
2877        assert_eq!(diagnostics[1].message, "Newer version available: 2.0.0");
2878        assert_eq!(diagnostics[1].code, None);
2879    }
2880
2881    /// T2 (D5 collision): an exact-pin dependency whose package is both package-level
2882    /// deprecated and whose in-use-version yanked finding carries `AdvisoryDeprecated`
2883    /// (npm's real shape — its yanked map is always sourced from `from_advisory`, never
2884    /// `from_yanked`) must produce **exactly one** diagnostic: the deprecation
2885    /// diagnostic suppresses the yanked one, since the two are one signal.
2886    #[test]
2887    fn test_generate_diagnostics_from_cache_deprecation_suppresses_advisory_deprecated_yanked_finding()
2888     {
2889        use std::collections::HashMap;
2890        use tower_lsp_server::ls_types::{Position, Range};
2891
2892        let formatter = MockFormatter;
2893        let parse_result = MockParseResult {
2894            deps: vec![MockDep {
2895                name: "left-pad".into(),
2896                version_req: "1.3.0".into(),
2897                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2898                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2899            }],
2900            uri: crate::test_util::test_uri("/test/package.json"),
2901        };
2902
2903        let mut cached_versions = HashMap::new();
2904        cached_versions.insert("left-pad".into(), PackageVersions::latest_only("1.3.0"));
2905        let resolved_versions = HashMap::new();
2906        let outcomes = DependencyOutcomes::new()
2907            .with_yanked(
2908                "left-pad",
2909                ("1.3.0".into(), RemovalStatus::AdvisoryDeprecated),
2910            )
2911            .with_deprecation(
2912                "left-pad",
2913                Deprecation {
2914                    reason: Some("use String.prototype.padStart()".to_string()),
2915                    replacement: None,
2916                },
2917            );
2918
2919        let diagnostics = generate_diagnostics_from_cache(
2920            &parse_result,
2921            VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
2922            &formatter,
2923            parse_result.uri(),
2924            crate::freshness::FreshnessSettings::default(),
2925            DiagnosticSeverities::default(),
2926            PublishTime::now(),
2927        );
2928
2929        assert_eq!(
2930            diagnostics.len(),
2931            1,
2932            "the deprecation diagnostic must suppress the AdvisoryDeprecated yanked \
2933             finding, not double-report: {diagnostics:?}"
2934        );
2935        assert!(
2936            diagnostics[0]
2937                .message
2938                .starts_with(formatter.deprecated_message())
2939        );
2940    }
2941
2942    /// T6 (D5 gate): a synthetic `Yanked` finding — unreachable for any Phase-1
2943    /// ecosystem, but the guard for the PyPI fast-follow, the first ecosystem with both
2944    /// real yanks and package deprecation — must **never** be suppressed by a
2945    /// package-level deprecation finding on the same dependency. Both diagnostics fire.
2946    #[test]
2947    fn test_generate_diagnostics_from_cache_deprecation_never_suppresses_real_yanked_finding() {
2948        use std::collections::HashMap;
2949        use tower_lsp_server::ls_types::{Position, Range};
2950
2951        let formatter = MockFormatter;
2952        let parse_result = MockParseResult {
2953            deps: vec![MockDep {
2954                name: "pkg".into(),
2955                version_req: "1.0.0".into(),
2956                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
2957                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2958            }],
2959            uri: crate::test_util::test_uri("/test/pyproject.toml"),
2960        };
2961
2962        let mut cached_versions = HashMap::new();
2963        cached_versions.insert("pkg".into(), PackageVersions::latest_only("1.0.0"));
2964        let resolved_versions = HashMap::new();
2965        let outcomes = DependencyOutcomes::new()
2966            .with_yanked("pkg", ("1.0.0".into(), RemovalStatus::Yanked))
2967            .with_deprecation(
2968                "pkg",
2969                Deprecation {
2970                    reason: Some("project archived".to_string()),
2971                    replacement: None,
2972                },
2973            );
2974
2975        let diagnostics = generate_diagnostics_from_cache(
2976            &parse_result,
2977            VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
2978            &formatter,
2979            parse_result.uri(),
2980            crate::freshness::FreshnessSettings::default(),
2981            DiagnosticSeverities::default(),
2982            PublishTime::now(),
2983        );
2984
2985        assert_eq!(
2986            diagnostics.len(),
2987            2,
2988            "a genuine Yanked finding must never be hidden behind a package-level \
2989             deprecation notice: {diagnostics:?}"
2990        );
2991        // R3 (deprecation) always runs before R4 (in-use-yanked) in the orchestrator,
2992        // so index 0 is the deprecation finding and index 1 is the yanked finding.
2993        assert_eq!(
2994            diagnostics[0].message,
2995            format!("{}: project archived", formatter.deprecated_message())
2996        );
2997        assert_eq!(
2998            diagnostics[0].code,
2999            Some(NumberOrString::String(DEPRECATED_DIAGNOSTIC_CODE.into()))
3000        );
3001        assert_eq!(
3002            diagnostics[1].message,
3003            format!("{} (1.0.0)", formatter.yanked_message())
3004        );
3005        assert_eq!(diagnostics[1].code, None);
3006    }
3007
3008    /// T6b (D5 gate, severity independence): on an npm-shaped fixture where the two
3009    /// signals genuinely are one (`AdvisoryDeprecated`), suppression must be identical
3010    /// regardless of the configured severities — a user setting `deprecated_severity:
3011    /// hint` cannot thereby silence a `yanked_severity: error` finding, because severity
3012    /// is not an input to the suppression decision at all.
3013    #[test]
3014    fn test_generate_diagnostics_from_cache_deprecation_suppression_is_severity_independent() {
3015        use std::collections::HashMap;
3016        use tower_lsp_server::ls_types::{Position, Range};
3017
3018        let formatter = MockFormatter;
3019        let parse_result = MockParseResult {
3020            deps: vec![MockDep {
3021                name: "left-pad".into(),
3022                version_req: "1.3.0".into(),
3023                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
3024                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
3025            }],
3026            uri: crate::test_util::test_uri("/test/package.json"),
3027        };
3028
3029        let mut cached_versions = HashMap::new();
3030        cached_versions.insert("left-pad".into(), PackageVersions::latest_only("1.3.0"));
3031        let resolved_versions = HashMap::new();
3032        let outcomes = DependencyOutcomes::new()
3033            .with_yanked(
3034                "left-pad",
3035                ("1.3.0".into(), RemovalStatus::AdvisoryDeprecated),
3036            )
3037            .with_deprecation(
3038                "left-pad",
3039                Deprecation {
3040                    reason: Some("use String.prototype.padStart()".to_string()),
3041                    replacement: None,
3042                },
3043            );
3044        let versions =
3045            VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes);
3046
3047        let default_severities = DiagnosticSeverities::default();
3048        let inverted_severities = DiagnosticSeverities {
3049            deprecated: DiagnosticSeverity::HINT,
3050            yanked: DiagnosticSeverity::ERROR,
3051            ..DiagnosticSeverities::default()
3052        };
3053
3054        for severities in [default_severities, inverted_severities] {
3055            let diagnostics = generate_diagnostics_from_cache(
3056                &parse_result,
3057                versions,
3058                &formatter,
3059                parse_result.uri(),
3060                crate::freshness::FreshnessSettings::default(),
3061                severities,
3062                PublishTime::now(),
3063            );
3064            assert_eq!(
3065                diagnostics.len(),
3066                1,
3067                "suppression must not depend on severity configuration: {diagnostics:?}"
3068            );
3069            assert!(
3070                diagnostics[0]
3071                    .message
3072                    .starts_with(formatter.deprecated_message())
3073            );
3074        }
3075    }
3076
3077    /// I4: `versions.outcomes`' deprecation channel is name-keyed, so a git/path/SDK/workspace dependency
3078    /// whose name coincidentally matches an unrelated registry-resolved package's
3079    /// deprecation finding must not surface that diagnostic — the same #248 hazard
3080    /// `unsatisfiable`/`unknown`/`yanked_only` already guard against.
3081    #[test]
3082    fn test_generate_diagnostics_from_cache_deprecation_skipped_for_non_registry_sources() {
3083        use crate::parser::DependencySource;
3084
3085        let mut cached_versions = HashMap::new();
3086        cached_versions.insert("dep".into(), PackageVersions::latest_only("1.0.0"));
3087        let resolved_versions = HashMap::new();
3088        let outcomes = DependencyOutcomes::new().with_deprecation(
3089            "dep",
3090            Deprecation {
3091                reason: Some("archived".to_string()),
3092                replacement: None,
3093            },
3094        );
3095        let uri = crate::test_util::test_uri("/test/Cargo.toml");
3096
3097        for source in [
3098            DependencySource::Path {
3099                path: "../local".into(),
3100            },
3101            DependencySource::Git {
3102                url: "https://example.com/repo.git".into(),
3103                rev: None,
3104            },
3105            DependencySource::Sdk {
3106                sdk: "flutter".into(),
3107            },
3108            DependencySource::Workspace,
3109        ] {
3110            let parse_result = SingleDepParseResult {
3111                dep: NonRegistryDep(dep_at("dep"), source.clone()),
3112                uri: uri.clone(),
3113            };
3114            let diagnostics = generate_diagnostics_from_cache(
3115                &parse_result,
3116                VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
3117                &MockFormatter,
3118                parse_result.uri(),
3119                crate::freshness::FreshnessSettings::default(),
3120                DiagnosticSeverities::default(),
3121                PublishTime::now(),
3122            );
3123            assert!(
3124                diagnostics
3125                    .iter()
3126                    .all(|d| !d.message.starts_with(MockFormatter.deprecated_message())),
3127                "source {source:?} must never surface the deprecation diagnostic for a \
3128                 coincidentally-named registry package: {diagnostics:?}"
3129            );
3130        }
3131
3132        // Control: the same fixture on a Registry-source dependency DOES produce the
3133        // diagnostic, proving the loop above isn't vacuously passing.
3134        let registry_parse_result = SingleDepParseResult {
3135            dep: dep_at("dep"),
3136            uri,
3137        };
3138        let diagnostics = generate_diagnostics_from_cache(
3139            &registry_parse_result,
3140            VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
3141            &MockFormatter,
3142            registry_parse_result.uri(),
3143            crate::freshness::FreshnessSettings::default(),
3144            DiagnosticSeverities::default(),
3145            PublishTime::now(),
3146        );
3147        assert!(
3148            diagnostics
3149                .iter()
3150                .any(|d| d.message.starts_with(MockFormatter.deprecated_message())),
3151            "control case: a Registry-source dependency must still produce the diagnostic"
3152        );
3153    }
3154
3155    #[test]
3156    fn test_generate_diagnostics_from_cache_yanked_no_version_range_uses_name_range() {
3157        use std::collections::HashMap;
3158        use tower_lsp_server::ls_types::{Position, Range};
3159
3160        let formatter = MockFormatter;
3161        let name_range = Range::new(Position::new(0, 0), Position::new(0, 5));
3162
3163        let parse_result = MockMarkedParseResult {
3164            dep: MockMarkedDep {
3165                name: "serde".into(),
3166                name_range,
3167                markers: None,
3168            },
3169            uri: crate::test_util::test_uri("/test/pyproject.toml"),
3170        };
3171
3172        let cached_versions = HashMap::new();
3173        let resolved_versions = HashMap::new();
3174        let outcomes =
3175            DependencyOutcomes::new().with_yanked("serde", ("1.0.5".into(), RemovalStatus::Yanked));
3176
3177        let diagnostics = generate_diagnostics_from_cache(
3178            &parse_result,
3179            VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
3180            &formatter,
3181            parse_result.uri(),
3182            crate::freshness::FreshnessSettings::default(),
3183            DiagnosticSeverities::default(),
3184            PublishTime::now(),
3185        );
3186
3187        let yanked_diag = diagnostics
3188            .iter()
3189            .find(|d| d.message.starts_with(formatter.yanked_message()))
3190            .expect("expected a yanked diagnostic even without a version_range");
3191        assert_eq!(yanked_diag.range, name_range);
3192    }
3193
3194    #[test]
3195    fn test_generate_diagnostics_from_cache_yanked_normalized_name_keying() {
3196        use std::collections::HashMap;
3197        use tower_lsp_server::ls_types::{Position, Range};
3198
3199        /// Mirrors a Composer/NuGet/Swift-shaped formatter whose normalized
3200        /// name differs from the manifest-declared raw name.
3201        struct MockLowercaseFormatter;
3202        impl PackageNaming for MockLowercaseFormatter {
3203            fn normalize_package_name(&self, name: &PackageName) -> String {
3204                name.to_string().to_lowercase()
3205            }
3206        }
3207
3208        impl PackageRendering for MockLowercaseFormatter {
3209            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
3210                version.to_string()
3211            }
3212
3213            fn package_url(&self, name: &PackageName) -> String {
3214                format!("https://example.com/{name}")
3215            }
3216        }
3217
3218        impl RequirementResolution for MockLowercaseFormatter {}
3219
3220        impl DiagnosticMessages for MockLowercaseFormatter {}
3221
3222        impl DiagnosticPolicy for MockLowercaseFormatter {}
3223
3224        impl SourcePolicy for MockLowercaseFormatter {}
3225
3226        impl OsvNaming for MockLowercaseFormatter {}
3227
3228        let formatter = MockLowercaseFormatter;
3229
3230        let parse_result = MockParseResult {
3231            deps: vec![MockDep {
3232                name: "Newtonsoft.Json".into(),
3233                version_req: "13.0.1".into(),
3234                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
3235                name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
3236            }],
3237            uri: crate::test_util::test_uri("/test/project.csproj"),
3238        };
3239
3240        let cached_versions = HashMap::new();
3241        let resolved_versions = HashMap::new();
3242        // Keyed by the *normalized* (lowercase) name, not the raw manifest name.
3243        let outcomes = DependencyOutcomes::new()
3244            .with_yanked("newtonsoft.json", ("13.0.1".into(), RemovalStatus::Yanked));
3245
3246        let diagnostics = generate_diagnostics_from_cache(
3247            &parse_result,
3248            VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
3249            &formatter,
3250            parse_result.uri(),
3251            crate::freshness::FreshnessSettings::default(),
3252            DiagnosticSeverities::default(),
3253            PublishTime::now(),
3254        );
3255
3256        assert!(
3257            diagnostics
3258                .iter()
3259                .any(|d| d.message.starts_with(formatter.yanked_message())),
3260            "expected normalized-name lookup to resolve the yanked entry, got: {diagnostics:?}"
3261        );
3262    }
3263
3264    #[test]
3265    fn test_generate_diagnostics_from_cache_yanked_not_shared_across_duplicate_name_occurrences() {
3266        // #394 S1: two occurrences of `time` (e.g. under `[dependencies]` and
3267        // `[dev-dependencies]`) pinned to different exact versions, only one
3268        // of which is actually yanked. `yanked` is name-keyed (single value),
3269        // recording only "0.1.43" — the occurrence pinned to "0.1.44" must
3270        // NOT also render "yanked (0.1.43)" just because it shares the name;
3271        // that version string doesn't even appear on its line.
3272        use std::collections::HashMap;
3273        use tower_lsp_server::ls_types::{Position, Range};
3274
3275        let formatter = MockFormatter;
3276
3277        let parse_result = MockParseResult {
3278            deps: vec![
3279                MockDep {
3280                    name: "time".into(),
3281                    version_req: "=0.1.43".into(),
3282                    version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
3283                    name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
3284                },
3285                MockDep {
3286                    name: "time".into(),
3287                    version_req: "=0.1.44".into(),
3288                    version_range: Range::new(Position::new(3, 10), Position::new(3, 20)),
3289                    name_range: Range::new(Position::new(3, 0), Position::new(3, 5)),
3290                },
3291            ],
3292            uri: crate::test_util::test_uri("/test/Cargo.toml"),
3293        };
3294
3295        let cached_versions = HashMap::new();
3296        let resolved_versions = HashMap::new();
3297        let outcomes =
3298            DependencyOutcomes::new().with_yanked("time", ("0.1.43".into(), RemovalStatus::Yanked));
3299
3300        let diagnostics = generate_diagnostics_from_cache(
3301            &parse_result,
3302            VersionData::new(&cached_versions, &resolved_versions)
3303                .with_outcomes(&outcomes)
3304                .with_ecosystem(crate::EcosystemId::Cargo),
3305            &formatter,
3306            parse_result.uri(),
3307            crate::freshness::FreshnessSettings::default(),
3308            DiagnosticSeverities::default(),
3309            PublishTime::now(),
3310        );
3311
3312        let yanked_diags: Vec<_> = diagnostics
3313            .iter()
3314            .filter(|d| d.message.starts_with(formatter.yanked_message()))
3315            .collect();
3316        assert_eq!(
3317            yanked_diags.len(),
3318            1,
3319            "exactly one occurrence must get the yanked diagnostic, got: {diagnostics:?}"
3320        );
3321        assert_eq!(
3322            yanked_diags[0].range.start.line, 0,
3323            "must land on the yanked occurrence's own line"
3324        );
3325        assert!(yanked_diags[0].message.contains("0.1.43"));
3326    }
3327
3328    #[test]
3329    fn test_generate_diagnostics_from_cache_yanked_no_ecosystem_keeps_pre_394_behavior() {
3330        // Without `with_ecosystem` (most test fixtures, and any caller that
3331        // predates #394), the consistency check is skipped entirely and both
3332        // occurrences render the shared name-keyed finding — the exact
3333        // pre-#394 behavior, preserved deliberately for backward
3334        // compatibility rather than silently tightened.
3335        use std::collections::HashMap;
3336        use tower_lsp_server::ls_types::{Position, Range};
3337
3338        let formatter = MockFormatter;
3339
3340        let parse_result = MockParseResult {
3341            deps: vec![
3342                MockDep {
3343                    name: "time".into(),
3344                    version_req: "=0.1.43".into(),
3345                    version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
3346                    name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
3347                },
3348                MockDep {
3349                    name: "time".into(),
3350                    version_req: "=0.1.44".into(),
3351                    version_range: Range::new(Position::new(3, 10), Position::new(3, 20)),
3352                    name_range: Range::new(Position::new(3, 0), Position::new(3, 5)),
3353                },
3354            ],
3355            uri: crate::test_util::test_uri("/test/Cargo.toml"),
3356        };
3357
3358        let cached_versions = HashMap::new();
3359        let resolved_versions = HashMap::new();
3360        let outcomes =
3361            DependencyOutcomes::new().with_yanked("time", ("0.1.43".into(), RemovalStatus::Yanked));
3362
3363        let diagnostics = generate_diagnostics_from_cache(
3364            &parse_result,
3365            VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
3366            &formatter,
3367            parse_result.uri(),
3368            crate::freshness::FreshnessSettings::default(),
3369            DiagnosticSeverities::default(),
3370            PublishTime::now(),
3371        );
3372
3373        let yanked_diags = diagnostics
3374            .iter()
3375            .filter(|d| d.message.starts_with(formatter.yanked_message()))
3376            .count();
3377        assert_eq!(
3378            yanked_diags, 2,
3379            "no `ecosystem` set: both occurrences share the finding as before #394"
3380        );
3381    }
3382
3383    #[test]
3384    fn test_generate_diagnostics_unsatisfiable_enriched_with_matching_prerelease() {
3385        let cached_versions = {
3386            let mut m = HashMap::new();
3387            m.insert(
3388                "dep".into(),
3389                PackageVersions {
3390                    latest: "1.5.0".into(),
3391                    available: Arc::from(vec!["2.0.0-rc.1".into(), "1.5.0".into(), "1.4.0".into()]),
3392                    yanked: Arc::from(Vec::new()),
3393                    published_at: None,
3394                },
3395            );
3396            m
3397        };
3398        let resolved_versions = HashMap::new();
3399        let uri = crate::test_util::test_uri("/test/Cargo.toml");
3400        let mut dependency = dep_at("dep");
3401        dependency.version_req = VersionReq::new("^2.0.0");
3402        let parse_result = SingleDepParseResult {
3403            dep: dependency,
3404            uri,
3405        };
3406
3407        let diagnostics = generate_diagnostics_from_cache(
3408            &parse_result,
3409            VersionData::new(&cached_versions, &resolved_versions),
3410            &StrictSemverFormatter,
3411            parse_result.uri(),
3412            crate::freshness::FreshnessSettings::default(),
3413            DiagnosticSeverities::default(),
3414            PublishTime::now(),
3415        );
3416
3417        let message = diagnostics
3418            .iter()
3419            .find(|d| d.message.contains("No published version satisfies"))
3420            .map(|d| d.message.as_str())
3421            .expect("unsatisfiable WARNING must fire");
3422        assert!(
3423            message.contains("2.0.0-rc.1") && message.contains("pre-release"),
3424            "message must mention the matching pre-release, got: {message}"
3425        );
3426    }
3427
3428    #[test]
3429    fn test_generate_diagnostics_unsatisfiable_no_enrichment_without_matching_prerelease() {
3430        let cached_versions = {
3431            let mut m = HashMap::new();
3432            m.insert(
3433                "dep".into(),
3434                PackageVersions {
3435                    latest: "1.5.0".into(),
3436                    available: Arc::from(vec!["1.5.0".into(), "1.4.0".into()]),
3437                    yanked: Arc::from(Vec::new()),
3438                    published_at: None,
3439                },
3440            );
3441            m
3442        };
3443        let resolved_versions = HashMap::new();
3444        let uri = crate::test_util::test_uri("/test/Cargo.toml");
3445        let mut dependency = dep_at("dep");
3446        dependency.version_req = VersionReq::new("^2.0.0");
3447        let parse_result = SingleDepParseResult {
3448            dep: dependency,
3449            uri,
3450        };
3451
3452        let diagnostics = generate_diagnostics_from_cache(
3453            &parse_result,
3454            VersionData::new(&cached_versions, &resolved_versions),
3455            &StrictSemverFormatter,
3456            parse_result.uri(),
3457            crate::freshness::FreshnessSettings::default(),
3458            DiagnosticSeverities::default(),
3459            PublishTime::now(),
3460        );
3461
3462        let message = diagnostics
3463            .iter()
3464            .find(|d| d.message.contains("No published version satisfies"))
3465            .map(|d| d.message.as_str())
3466            .expect("unsatisfiable WARNING must fire");
3467        assert!(
3468            !message.contains("pre-release"),
3469            "message must not mention a pre-release when none satisfies, got: {message}"
3470        );
3471    }
3472
3473    #[test]
3474    fn test_generate_diagnostics_unsatisfiable_skipped_for_non_registry_sources() {
3475        use crate::parser::DependencySource;
3476
3477        let cached_versions = {
3478            let mut m = HashMap::new();
3479            m.insert("dep".into(), PackageVersions::latest_only("9.9.9"));
3480            m
3481        };
3482        let resolved_versions = HashMap::new();
3483        let uri = crate::test_util::test_uri("/test/Cargo.toml");
3484
3485        // Requirement "1.0.0" against available ["9.9.9"] is unsatisfiable
3486        // under ExactMatchFormatter — proven by the Registry-source case below.
3487        for source in [
3488            DependencySource::Path {
3489                path: "../local".into(),
3490            },
3491            DependencySource::Git {
3492                url: "https://example.com/repo.git".into(),
3493                rev: None,
3494            },
3495            DependencySource::Url {
3496                url: "https://example.com/pkg.tar.gz".into(),
3497            },
3498            DependencySource::Sdk {
3499                sdk: "flutter".into(),
3500            },
3501            DependencySource::Workspace,
3502            DependencySource::CustomRegistry {
3503                url: "my-corp".into(),
3504            },
3505        ] {
3506            let parse_result = SingleDepParseResult {
3507                dep: NonRegistryDep(dep_at("dep"), source.clone()),
3508                uri: uri.clone(),
3509            };
3510            let diagnostics = generate_diagnostics_from_cache(
3511                &parse_result,
3512                VersionData::new(&cached_versions, &resolved_versions),
3513                &ExactMatchFormatter,
3514                parse_result.uri(),
3515                crate::freshness::FreshnessSettings::default(),
3516                DiagnosticSeverities::default(),
3517                PublishTime::now(),
3518            );
3519            assert!(
3520                diagnostics
3521                    .iter()
3522                    .all(|d| !d.message.contains("No published version satisfies")),
3523                "source {source:?} must never produce the unsatisfiable-requirement WARNING"
3524            );
3525        }
3526
3527        // Control: the same requirement/available pair on a Registry-source
3528        // dependency DOES produce the WARNING, proving the loop above isn't
3529        // vacuously passing because the fixture never triggers it at all.
3530        let registry_parse_result = SingleDepParseResult {
3531            dep: dep_at("dep"),
3532            uri,
3533        };
3534        let diagnostics = generate_diagnostics_from_cache(
3535            &registry_parse_result,
3536            VersionData::new(&cached_versions, &resolved_versions),
3537            &ExactMatchFormatter,
3538            registry_parse_result.uri(),
3539            crate::freshness::FreshnessSettings::default(),
3540            DiagnosticSeverities::default(),
3541            PublishTime::now(),
3542        );
3543        assert!(
3544            diagnostics
3545                .iter()
3546                .any(|d| d.message.contains("No published version satisfies")),
3547            "control case: a Registry-source dependency must still produce the WARNING"
3548        );
3549    }
3550
3551    #[test]
3552    fn test_generate_diagnostics_unknown_package_skipped_for_non_registry_sources() {
3553        use crate::parser::DependencySource;
3554
3555        // No cache entry at all for "dep" — simulates a `CustomRegistry`
3556        // dependency, which this LSP never fetches from a real registry.
3557        let cached_versions = HashMap::new();
3558        let resolved_versions = HashMap::new();
3559        let uri = crate::test_util::test_uri("/test/Cargo.toml");
3560
3561        let parse_result = SingleDepParseResult {
3562            dep: NonRegistryDep(
3563                dep_at("dep"),
3564                DependencySource::CustomRegistry {
3565                    url: "my-corp".into(),
3566                },
3567            ),
3568            uri: uri.clone(),
3569        };
3570        let diagnostics = generate_diagnostics_from_cache(
3571            &parse_result,
3572            VersionData::new(&cached_versions, &resolved_versions),
3573            &MockFormatter,
3574            parse_result.uri(),
3575            crate::freshness::FreshnessSettings::default(),
3576            DiagnosticSeverities::default(),
3577            PublishTime::now(),
3578        );
3579        assert!(
3580            diagnostics
3581                .iter()
3582                .all(|d| !d.message.contains("Unknown package")),
3583            "a CustomRegistry-sourced dependency must never produce the \"Unknown package\" WARNING"
3584        );
3585
3586        // Control: the same missing cache entry on a Registry-source
3587        // dependency DOES produce the WARNING.
3588        let registry_parse_result = SingleDepParseResult {
3589            dep: dep_at("dep"),
3590            uri,
3591        };
3592        let diagnostics = generate_diagnostics_from_cache(
3593            &registry_parse_result,
3594            VersionData::new(&cached_versions, &resolved_versions),
3595            &MockFormatter,
3596            registry_parse_result.uri(),
3597            crate::freshness::FreshnessSettings::default(),
3598            DiagnosticSeverities::default(),
3599            PublishTime::now(),
3600        );
3601        assert!(
3602            diagnostics
3603                .iter()
3604                .any(|d| d.message.contains("Unknown package")),
3605            "control case: a Registry-source dependency must still produce the WARNING"
3606        );
3607    }
3608
3609    #[test]
3610    fn test_generate_diagnostics_invalid_name_still_reported_for_non_registry_sources() {
3611        use crate::parser::DependencySource;
3612
3613        // Invalid-name validation is pure syntax checking, independent of
3614        // registry data — it must still fire even when the source is not
3615        // resolvable (unlike "Unknown package", which requires a real lookup).
3616        let cached_versions = HashMap::new();
3617        let resolved_versions = HashMap::new();
3618        let uri = crate::test_util::test_uri("/test/package.json");
3619
3620        let parse_result = SingleDepParseResult {
3621            dep: NonRegistryDep(
3622                dep_at("dep"),
3623                DependencySource::CustomRegistry {
3624                    url: "my-corp".into(),
3625                },
3626            ),
3627            uri,
3628        };
3629        let diagnostics = generate_diagnostics_from_cache(
3630            &parse_result,
3631            VersionData::new(&cached_versions, &resolved_versions),
3632            &RejectingFormatter,
3633            parse_result.uri(),
3634            crate::freshness::FreshnessSettings::default(),
3635            DiagnosticSeverities::default(),
3636            PublishTime::now(),
3637        );
3638        assert_eq!(diagnostics.len(), 1);
3639        assert!(diagnostics[0].message.starts_with("Invalid package name"));
3640    }
3641
3642    #[test]
3643    fn test_generate_diagnostics_outdated_skipped_for_non_registry_sources() {
3644        use crate::parser::DependencySource;
3645
3646        // "dep" resolves to a coincidentally-matching cache entry with a newer
3647        // "latest", as would happen for a Cargo path dependency that happens
3648        // to share a name with an unrelated published crate.
3649        let cached_versions = {
3650            let mut m = HashMap::new();
3651            m.insert("dep".into(), PackageVersions::latest_only("9.9.9"));
3652            m
3653        };
3654        let resolved_versions = HashMap::new();
3655        let uri = crate::test_util::test_uri("/test/Cargo.toml");
3656
3657        let parse_result = SingleDepParseResult {
3658            dep: NonRegistryDep(
3659                dep_at("dep"),
3660                DependencySource::CustomRegistry {
3661                    url: "my-corp".into(),
3662                },
3663            ),
3664            uri: uri.clone(),
3665        };
3666        let diagnostics = generate_diagnostics_from_cache(
3667            &parse_result,
3668            VersionData::new(&cached_versions, &resolved_versions),
3669            &MockFormatter,
3670            parse_result.uri(),
3671            crate::freshness::FreshnessSettings::default(),
3672            DiagnosticSeverities::default(),
3673            PublishTime::now(),
3674        );
3675        assert!(
3676            diagnostics
3677                .iter()
3678                .all(|d| !d.message.contains("Newer version available")),
3679            "a CustomRegistry-sourced dependency must never produce the \"Outdated\" WARNING"
3680        );
3681
3682        // Control: the same requirement/cache pair on a Registry-source
3683        // dependency DOES produce the WARNING.
3684        let registry_parse_result = SingleDepParseResult {
3685            dep: dep_at("dep"),
3686            uri,
3687        };
3688        let diagnostics = generate_diagnostics_from_cache(
3689            &registry_parse_result,
3690            VersionData::new(&cached_versions, &resolved_versions),
3691            &MockFormatter,
3692            registry_parse_result.uri(),
3693            crate::freshness::FreshnessSettings::default(),
3694            DiagnosticSeverities::default(),
3695            PublishTime::now(),
3696        );
3697        assert!(
3698            diagnostics
3699                .iter()
3700                .any(|d| d.message.contains("Newer version available")),
3701            "control case: a Registry-source dependency must still produce the WARNING"
3702        );
3703    }
3704
3705    #[test]
3706    fn test_generate_diagnostics_vulnerable_dependency_emits_advisory_diagnostic_even_without_registry_data()
3707     {
3708        use crate::osv::{
3709            Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity,
3710            VulnerabilityMap,
3711        };
3712
3713        let formatter = MockFormatter;
3714        let parse_result = MockParseResult {
3715            deps: vec![dep_at("vulnerable-pkg")],
3716            uri: crate::test_util::test_uri("/test/Cargo.toml"),
3717        };
3718
3719        // Registry data is entirely absent (as if the registry fetch failed),
3720        // which must never suppress the OSV finding.
3721        let cached_versions = HashMap::new();
3722        let resolved_versions = HashMap::new();
3723
3724        let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
3725        vulns.insert(
3726            "vulnerable-pkg".to_string(),
3727            ScanOutcome::Vulnerable(DependencyVulnerabilities {
3728                advisories: Capped::new(
3729                    vec![sample_advisory("RUSTSEC-2020-0071", VulnSeverity::High)],
3730                    1,
3731                ),
3732                fix_target_status: UpgradeStatus::NotChecked,
3733                upgrade_status: UpgradeStatus::NotChecked,
3734            }),
3735        );
3736
3737        let diagnostics = generate_diagnostics_from_cache(
3738            &parse_result,
3739            VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
3740            &formatter,
3741            parse_result.uri(),
3742            crate::FreshnessSettings::default(),
3743            DiagnosticSeverities::default(),
3744            PublishTime::now(),
3745        );
3746
3747        let vuln_diag = diagnostics
3748            .iter()
3749            .find(|d| d.message.contains("RUSTSEC-2020-0071"))
3750            .expect("vulnerability diagnostic must be emitted even without registry data");
3751        assert_eq!(vuln_diag.severity, Some(DiagnosticSeverity::WARNING));
3752        assert_eq!(
3753            vuln_diag.code,
3754            Some(NumberOrString::String("RUSTSEC-2020-0071".to_string()))
3755        );
3756    }
3757
3758    #[test]
3759    fn test_generate_diagnostics_advisory_cap_emits_more_count_from_total_known() {
3760        use crate::osv::{
3761            ADVISORY_DISPLAY_CAP, Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus,
3762            VulnSeverity, VulnerabilityMap,
3763        };
3764
3765        let formatter = MockFormatter;
3766        let parse_result = MockParseResult {
3767            deps: vec![dep_at("noisy-pkg")],
3768            uri: crate::test_util::test_uri("/test/Cargo.toml"),
3769        };
3770        let cached_versions = HashMap::new();
3771        let resolved_versions = HashMap::new();
3772
3773        let advisories: Vec<_> = (0..ADVISORY_DISPLAY_CAP)
3774            .map(|i| sample_advisory(&format!("ADV-{i}"), VulnSeverity::Low))
3775            .collect();
3776
3777        let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
3778        vulns.insert(
3779            "noisy-pkg".to_string(),
3780            ScanOutcome::Vulnerable(DependencyVulnerabilities {
3781                advisories: Capped::new(advisories, 40),
3782                fix_target_status: UpgradeStatus::NotChecked,
3783                upgrade_status: UpgradeStatus::NotChecked,
3784            }),
3785        );
3786
3787        let diagnostics = generate_diagnostics_from_cache(
3788            &parse_result,
3789            VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
3790            &formatter,
3791            parse_result.uri(),
3792            crate::FreshnessSettings::default(),
3793            DiagnosticSeverities::default(),
3794            PublishTime::now(),
3795        );
3796
3797        let more_diag = diagnostics
3798            .iter()
3799            .find(|d| d.message.contains("more advisories"))
3800            .expect("expected a trailing +N more advisories diagnostic");
3801        assert!(
3802            more_diag.message.contains("+35"),
3803            "got: {}",
3804            more_diag.message
3805        );
3806    }
3807
3808    #[test]
3809    fn test_generate_diagnostics_vulnerability_not_shared_across_duplicate_name_occurrences() {
3810        // #394 S2: two occurrences of `pkg` (e.g. under `[dependencies]` and
3811        // `[dev-dependencies]`) pinned to different versions — one vulnerable,
3812        // one patched. Built via `vulnerability_keys` the same way
3813        // `deps-lsp`'s `build_scan_targets` would, so each occurrence's OSV
3814        // result lands under its own key instead of colliding on the plain
3815        // name. The patched occurrence must render no advisory diagnostic.
3816        use crate::osv::{
3817            Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity,
3818            VulnerabilityMap, vulnerability_keys,
3819        };
3820        use tower_lsp_server::ls_types::{Position, Range};
3821
3822        let formatter = MockFormatter;
3823
3824        let vulnerable_dep = MockDep {
3825            name: "pkg".into(),
3826            version_req: "=1.0.0".into(),
3827            version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
3828            name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
3829        };
3830        let patched_dep = MockDep {
3831            name: "pkg".into(),
3832            version_req: "=2.0.0".into(),
3833            version_range: Range::new(Position::new(3, 10), Position::new(3, 20)),
3834            name_range: Range::new(Position::new(3, 0), Position::new(3, 5)),
3835        };
3836        let parse_result = MockParseResult {
3837            deps: vec![vulnerable_dep, patched_dep],
3838            uri: crate::test_util::test_uri("/test/Cargo.toml"),
3839        };
3840
3841        let cached_versions = HashMap::new();
3842        let resolved_versions = HashMap::new();
3843
3844        let keys = vulnerability_keys(
3845            &parse_result,
3846            &resolved_versions,
3847            &formatter,
3848            crate::EcosystemId::Cargo,
3849        );
3850        let deps = parse_result.dependencies();
3851        let vulnerable_key = keys.get(&deps[0].name_range()).unwrap().clone();
3852        let patched_key = keys.get(&deps[1].name_range()).unwrap().clone();
3853        assert_ne!(
3854            vulnerable_key, patched_key,
3855            "differently-versioned occurrences of one name must get distinct keys"
3856        );
3857
3858        let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
3859        vulns.insert(
3860            vulnerable_key,
3861            ScanOutcome::Vulnerable(DependencyVulnerabilities {
3862                advisories: Capped::new(
3863                    vec![sample_advisory("RUSTSEC-2020-0071", VulnSeverity::High)],
3864                    1,
3865                ),
3866                fix_target_status: UpgradeStatus::NotChecked,
3867                upgrade_status: UpgradeStatus::NotChecked,
3868            }),
3869        );
3870        vulns.insert(patched_key, ScanOutcome::Clean);
3871
3872        let diagnostics = generate_diagnostics_from_cache(
3873            &parse_result,
3874            VersionData::new(&cached_versions, &resolved_versions)
3875                .with_vulnerabilities(&vulns)
3876                .with_ecosystem(crate::EcosystemId::Cargo),
3877            &formatter,
3878            parse_result.uri(),
3879            crate::FreshnessSettings::default(),
3880            DiagnosticSeverities::default(),
3881            PublishTime::now(),
3882        );
3883
3884        let advisory_diags: Vec<_> = diagnostics
3885            .iter()
3886            .filter(|d| d.message.contains("RUSTSEC-2020-0071"))
3887            .collect();
3888        assert_eq!(
3889            advisory_diags.len(),
3890            1,
3891            "exactly one occurrence must get the advisory diagnostic, got: {diagnostics:?}"
3892        );
3893        assert_eq!(
3894            advisory_diags[0].range.start.line, 0,
3895            "must land on the vulnerable occurrence's own line, not the patched one"
3896        );
3897    }
3898
3899    #[test]
3900    fn test_generate_diagnostics_skipped_outcome_emits_no_vulnerability_diagnostic() {
3901        use crate::osv::{ScanOutcome, SkipReason, VulnerabilityMap};
3902
3903        let formatter = MockFormatter;
3904        let parse_result = MockParseResult {
3905            deps: vec![dep_at("git-pkg")],
3906            uri: crate::test_util::test_uri("/test/Cargo.toml"),
3907        };
3908        let mut cached_versions = HashMap::new();
3909        cached_versions.insert("git-pkg".into(), PackageVersions::latest_only("1.0.0"));
3910        let resolved_versions = HashMap::new();
3911
3912        let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
3913        vulns.insert(
3914            "git-pkg".to_string(),
3915            ScanOutcome::Skipped(SkipReason::NonRegistrySource),
3916        );
3917
3918        let diagnostics = generate_diagnostics_from_cache(
3919            &parse_result,
3920            VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
3921            &formatter,
3922            parse_result.uri(),
3923            crate::FreshnessSettings::default(),
3924            DiagnosticSeverities::default(),
3925            PublishTime::now(),
3926        );
3927
3928        assert!(
3929            diagnostics.iter().all(|d| d.code.is_none()),
3930            "a Skipped outcome must never render an advisory diagnostic"
3931        );
3932    }
3933
3934    /// Table-driven coverage for `requirement_is_unsatisfiable` (plan §4), using a
3935    /// formatter whose `compile_requirement` is configured per test via a closure-backed
3936    /// matcher, rather than one of the fixed ecosystem formatters.
3937    mod requirement_is_unsatisfiable_tests {
3938        use super::*;
3939
3940        type Decide = Arc<dyn Fn(&str) -> Option<bool> + Send + Sync>;
3941
3942        /// A matcher backed by a type-erased closure, so each test can express its own
3943        /// per-candidate decision table without a new named type per test.
3944        struct ClosureMatcher(Decide);
3945
3946        impl RequirementMatcher for ClosureMatcher {
3947            fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
3948                (self.0)(version.as_str())
3949            }
3950        }
3951
3952        /// A formatter whose `compile_requirement` is `None` (requirement is treated as
3953        /// unmodellable) unless `requirement.as_str() == "modelled"`, in which case it
3954        /// returns a `ClosureMatcher` wrapping `decide`. `requirement_is_unresolved` fires
3955        /// on the literal string `"unresolved"`.
3956        struct TableFormatter {
3957            decide: Decide,
3958        }
3959
3960        impl TableFormatter {
3961            fn new(decide: impl Fn(&str) -> Option<bool> + Send + Sync + 'static) -> Self {
3962                Self {
3963                    decide: Arc::new(decide),
3964                }
3965            }
3966        }
3967
3968        impl PackageNaming for TableFormatter {}
3969
3970        impl PackageRendering for TableFormatter {
3971            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
3972                version.to_string()
3973            }
3974
3975            fn package_url(&self, name: &PackageName) -> String {
3976                name.to_string()
3977            }
3978        }
3979
3980        impl RequirementResolution for TableFormatter {
3981            fn requirement_is_unresolved(&self, requirement: &VersionReq) -> bool {
3982                requirement.as_str() == "unresolved"
3983            }
3984
3985            fn compile_requirement(
3986                &self,
3987                requirement: &VersionReq,
3988            ) -> Option<Box<dyn RequirementMatcher>> {
3989                if requirement.as_str() != "modelled" {
3990                    return None;
3991                }
3992                Some(Box::new(ClosureMatcher(Arc::clone(&self.decide)))
3993                    as Box<dyn RequirementMatcher>)
3994            }
3995        }
3996
3997        impl DiagnosticMessages for TableFormatter {}
3998
3999        impl DiagnosticPolicy for TableFormatter {}
4000
4001        impl SourcePolicy for TableFormatter {}
4002
4003        impl OsvNaming for TableFormatter {}
4004
4005        fn versions(strs: &[&str]) -> Vec<ConcreteVersion> {
4006            strs.iter().map(|s| (*s).into()).collect()
4007        }
4008
4009        #[test]
4010        fn test_empty_available_list_is_false() {
4011            let formatter = TableFormatter::new(|_v| Some(true));
4012            assert!(!requirement_is_unsatisfiable(
4013                &formatter,
4014                &VersionReq::new("modelled"),
4015                &[],
4016            ));
4017        }
4018
4019        #[test]
4020        fn test_empty_requirement_string_is_false() {
4021            let formatter = TableFormatter::new(|_v| Some(false));
4022            assert!(!requirement_is_unsatisfiable(
4023                &formatter,
4024                &VersionReq::new(""),
4025                &versions(&["1.0.0"]),
4026            ));
4027        }
4028
4029        /// S-1 (security): an oversized requirement is rejected before `compile_requirement`
4030        /// is even called, bounding the cost of an adversarial/corrupted requirement string
4031        /// regardless of how expensive that ecosystem's matcher is per candidate.
4032        #[test]
4033        fn test_oversized_requirement_is_false_without_compiling() {
4034            let formatter =
4035                TableFormatter::new(|_v| panic!("must not compile/scan an oversized requirement"));
4036            let oversized = "1".repeat(MAX_REQUIREMENT_LEN + 1);
4037            assert!(!requirement_is_unsatisfiable(
4038                &formatter,
4039                &VersionReq::new(oversized),
4040                &versions(&["1.0.0"]),
4041            ));
4042        }
4043
4044        #[test]
4045        fn test_unresolved_requirement_is_false() {
4046            let formatter = TableFormatter::new(|_v| Some(false));
4047            assert!(!requirement_is_unsatisfiable(
4048                &formatter,
4049                &VersionReq::new("unresolved"),
4050                &versions(&["1.0.0"]),
4051            ));
4052        }
4053
4054        #[test]
4055        fn test_compile_requirement_none_is_false() {
4056            let formatter = TableFormatter::new(|_v| Some(false));
4057            assert!(!requirement_is_unsatisfiable(
4058                &formatter,
4059                &VersionReq::new("not-modelled"),
4060                &versions(&["1.0.0"]),
4061            ));
4062        }
4063
4064        #[test]
4065        fn test_all_candidates_decided_false_is_true() {
4066            let formatter = TableFormatter::new(|_v| Some(false));
4067            assert!(requirement_is_unsatisfiable(
4068                &formatter,
4069                &VersionReq::new("modelled"),
4070                &versions(&["1.0.0", "2.0.0", "3.0.0"]),
4071            ));
4072        }
4073
4074        #[test]
4075        fn test_one_match_among_many_non_matches_is_false() {
4076            let formatter = TableFormatter::new(|v| Some(v == "2.0.0"));
4077            assert!(!requirement_is_unsatisfiable(
4078                &formatter,
4079                &VersionReq::new("modelled"),
4080                &versions(&["1.0.0", "2.0.0", "3.0.0"]),
4081            ));
4082        }
4083
4084        /// S2 regression: every candidate unparseable means nothing was decided, so the
4085        /// verdict must be `false` (no diagnostic), not a vacuous `true`.
4086        #[test]
4087        fn test_all_candidates_unparseable_is_false() {
4088            let formatter = TableFormatter::new(|_v| None);
4089            assert!(!requirement_is_unsatisfiable(
4090                &formatter,
4091                &VersionReq::new("modelled"),
4092                &versions(&["1.0.0", "2.0.0"]),
4093            ));
4094        }
4095
4096        /// S2 regression, other half: a single junk entry among otherwise-all-`Some(false)`
4097        /// candidates is skipped, not fatal to the whole scan.
4098        #[test]
4099        fn test_one_unparseable_candidate_among_false_is_still_true() {
4100            let formatter = TableFormatter::new(|v| if v == "junk" { None } else { Some(false) });
4101            assert!(requirement_is_unsatisfiable(
4102                &formatter,
4103                &VersionReq::new("modelled"),
4104                &versions(&["1.0.0", "junk", "2.0.0"]),
4105            ));
4106        }
4107
4108        /// §1.3: a match on a candidate that happens to be yanked still counts as
4109        /// satisfied — `available` carries no yanked flag, so this is exercised the same
4110        /// way any other match is: the matcher deciding `Some(true)` for that entry.
4111        #[test]
4112        fn test_match_on_yanked_only_candidate_is_false() {
4113            let formatter = TableFormatter::new(|v| Some(v == "1.0.0-yanked"));
4114            assert!(!requirement_is_unsatisfiable(
4115                &formatter,
4116                &VersionReq::new("modelled"),
4117                &versions(&["1.0.0-yanked"]),
4118            ));
4119        }
4120
4121        /// §1.2: same, for a prerelease-only match.
4122        #[test]
4123        fn test_match_on_prerelease_only_candidate_is_false() {
4124            let formatter = TableFormatter::new(|v| Some(v == "2.0.0-beta.1"));
4125            assert!(!requirement_is_unsatisfiable(
4126                &formatter,
4127                &VersionReq::new("modelled"),
4128                &versions(&["2.0.0-beta.1"]),
4129            ));
4130        }
4131
4132        #[test]
4133        fn test_scan_short_circuits_on_first_match() {
4134            let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4135            let calls_clone = Arc::clone(&calls);
4136            let formatter = TableFormatter::new(move |v| {
4137                calls_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4138                Some(v == "1.0.0")
4139            });
4140            let result = requirement_is_unsatisfiable(
4141                &formatter,
4142                &VersionReq::new("modelled"),
4143                &versions(&["1.0.0", "0.9.0", "0.8.0"]),
4144            );
4145            assert!(!result);
4146            assert_eq!(
4147                calls.load(std::sync::atomic::Ordering::SeqCst),
4148                1,
4149                "must stop scanning at the first Some(true)"
4150            );
4151        }
4152    }
4153
4154    /// Coverage for `matching_prerelease_would_satisfy` and `semver_prerelease_base` (#299):
4155    /// enriching the unsatisfiable-requirement WARNING for strict-SemVer ecosystems with a
4156    /// mention of a published pre-release that would satisfy the requirement's stable core.
4157    mod matching_prerelease_would_satisfy_tests {
4158        use super::*;
4159
4160        /// Same matcher as `StrictSemverFormatter` (defined in the parent `tests` module and
4161        /// shared with the `generate_diagnostics_from_cache` end-to-end coverage), but not
4162        /// opted into `strict_semver_prerelease_exclusion` — mirrors Maven/NuGet/Composer/
4163        /// Gradle, which must never get the enrichment.
4164        struct NonStrictFormatter;
4165        impl PackageNaming for NonStrictFormatter {}
4166
4167        impl PackageRendering for NonStrictFormatter {
4168            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
4169                version.to_string()
4170            }
4171
4172            fn package_url(&self, name: &PackageName) -> String {
4173                name.to_string()
4174            }
4175        }
4176
4177        impl RequirementResolution for NonStrictFormatter {
4178            fn compile_requirement(
4179                &self,
4180                requirement: &VersionReq,
4181            ) -> Option<Box<dyn RequirementMatcher>> {
4182                requirement
4183                    .as_str()
4184                    .parse::<semver::VersionReq>()
4185                    .ok()
4186                    .map(|req| Box::new(RealSemverMatcher(req)) as Box<dyn RequirementMatcher>)
4187            }
4188        }
4189
4190        impl DiagnosticMessages for NonStrictFormatter {}
4191
4192        impl DiagnosticPolicy for NonStrictFormatter {}
4193
4194        impl SourcePolicy for NonStrictFormatter {}
4195
4196        impl OsvNaming for NonStrictFormatter {}
4197
4198        fn versions(strs: &[&str]) -> Vec<ConcreteVersion> {
4199            strs.iter().map(|s| (*s).into()).collect()
4200        }
4201
4202        fn yanked_versions(strs: &[&str]) -> Vec<(ConcreteVersion, RemovalStatus)> {
4203            strs.iter()
4204                .map(|s| ((*s).into(), RemovalStatus::Yanked))
4205                .collect()
4206        }
4207
4208        #[test]
4209        fn test_semver_prerelease_base() {
4210            assert_eq!(semver_prerelease_base("2.0.0-rc.1"), Some("2.0.0"));
4211            assert_eq!(semver_prerelease_base("2.0.0-rc.1+build.5"), Some("2.0.0"));
4212            assert_eq!(semver_prerelease_base("2.0.0"), None);
4213            assert_eq!(semver_prerelease_base("2.0.0+build.5"), None);
4214        }
4215
4216        #[test]
4217        fn test_requirement_names_prerelease() {
4218            assert!(requirement_names_prerelease("2.0.0-rc.5"));
4219            assert!(requirement_names_prerelease("^2.0.0-rc.5"));
4220            assert!(requirement_names_prerelease("~2.0.0-rc.5"));
4221            assert!(requirement_names_prerelease(">=2.0.0-rc.5"));
4222            assert!(requirement_names_prerelease("=2.0.0-rc.5"));
4223            assert!(!requirement_names_prerelease("^2.0.0"));
4224            assert!(!requirement_names_prerelease(">=1.0.0, <2.0.0"));
4225            // npm's whitespace-padded hyphen range operator must not be mistaken for an
4226            // embedded pre-release tag.
4227            assert!(!requirement_names_prerelease("1.2.3 - 2.3.4"));
4228        }
4229
4230        /// (a) No pre-release exists among `available` — no enrichment.
4231        #[test]
4232        fn test_no_prerelease_available_returns_none() {
4233            assert_eq!(
4234                matching_prerelease_would_satisfy(
4235                    &StrictSemverFormatter,
4236                    &VersionReq::new("^2.0.0"),
4237                    &versions(&["1.5.0", "1.4.0"]),
4238                    &[],
4239                ),
4240                None
4241            );
4242        }
4243
4244        /// (b) A pre-release exists that would satisfy the requirement's stable core — the
4245        /// scenario from the issue's example (`^2.0.0` vs. published `2.0.0-rc.1`).
4246        #[test]
4247        fn test_matching_prerelease_is_found() {
4248            assert_eq!(
4249                matching_prerelease_would_satisfy(
4250                    &StrictSemverFormatter,
4251                    &VersionReq::new("^2.0.0"),
4252                    &versions(&["2.0.0-rc.1", "1.5.0"]),
4253                    &[],
4254                ),
4255                Some("2.0.0-rc.1".to_string())
4256            );
4257        }
4258
4259        /// The newest matching pre-release wins when several are published (`available` is
4260        /// newest-first).
4261        #[test]
4262        fn test_returns_newest_matching_prerelease_first() {
4263            assert_eq!(
4264                matching_prerelease_would_satisfy(
4265                    &StrictSemverFormatter,
4266                    &VersionReq::new("^2.0.0"),
4267                    &versions(&["2.0.0-rc.2", "2.0.0-rc.1", "1.5.0"]),
4268                    &[],
4269                ),
4270                Some("2.0.0-rc.2".to_string())
4271            );
4272        }
4273
4274        /// A published pre-release whose stable core still fails the requirement (wrong
4275        /// major version) must not be surfaced.
4276        #[test]
4277        fn test_non_matching_prerelease_returns_none() {
4278            assert_eq!(
4279                matching_prerelease_would_satisfy(
4280                    &StrictSemverFormatter,
4281                    &VersionReq::new("^2.0.0"),
4282                    &versions(&["3.0.0-rc.1", "1.5.0"]),
4283                    &[],
4284                ),
4285                None
4286            );
4287        }
4288
4289        /// (c) The requirement is itself an exact pin to a nonexistent pre-release
4290        /// (`=2.0.0-rc.5`) — existing #206 behavior. A published pre-release with a
4291        /// different tag must not be surfaced, since the requirement already names a
4292        /// pre-release tag (`requirement_names_prerelease` bails before even compiling).
4293        #[test]
4294        fn test_exact_prerelease_pin_does_not_misfire_on_unrelated_prerelease() {
4295            assert_eq!(
4296                matching_prerelease_would_satisfy(
4297                    &StrictSemverFormatter,
4298                    &VersionReq::new("=2.0.0-rc.5"),
4299                    &versions(&["2.0.0-rc.1", "1.5.0"]),
4300                    &[],
4301                ),
4302                None
4303            );
4304        }
4305
4306        /// S1 regression: a `^`-ranged requirement whose floor itself names a pre-release
4307        /// tag must not enrich, even though `2.0.0-rc.1`'s stable core (`2.0.0`) would
4308        /// satisfy `^2.0.0-rc.5` (verified against real `semver` 1.0.28) — the real reason
4309        /// `2.0.0-rc.1` doesn't match is ordering against the requirement's own explicit
4310        /// floor (`rc.1 < rc.5`), not SemVer's default pre-release exclusion.
4311        #[test]
4312        fn test_caret_requirement_naming_prerelease_returns_none() {
4313            assert_eq!(
4314                matching_prerelease_would_satisfy(
4315                    &StrictSemverFormatter,
4316                    &VersionReq::new("^2.0.0-rc.5"),
4317                    &versions(&["2.0.0-rc.1", "1.5.0"]),
4318                    &[],
4319                ),
4320                None
4321            );
4322        }
4323
4324        /// S1 regression, `~` shape.
4325        #[test]
4326        fn test_tilde_requirement_naming_prerelease_returns_none() {
4327            assert_eq!(
4328                matching_prerelease_would_satisfy(
4329                    &StrictSemverFormatter,
4330                    &VersionReq::new("~2.0.0-rc.5"),
4331                    &versions(&["2.0.0-rc.1", "1.5.0"]),
4332                    &[],
4333                ),
4334                None
4335            );
4336        }
4337
4338        /// S1 regression, `>=` shape.
4339        #[test]
4340        fn test_gte_requirement_naming_prerelease_returns_none() {
4341            assert_eq!(
4342                matching_prerelease_would_satisfy(
4343                    &StrictSemverFormatter,
4344                    &VersionReq::new(">=2.0.0-rc.5"),
4345                    &versions(&["2.0.0-rc.1", "1.5.0"]),
4346                    &[],
4347                ),
4348                None
4349            );
4350        }
4351
4352        /// M1: a matching pre-release that is itself yanked must not be surfaced — it isn't
4353        /// actually usable, so naming it as "would satisfy" would be misleading.
4354        #[test]
4355        fn test_yanked_matching_prerelease_is_skipped() {
4356            assert_eq!(
4357                matching_prerelease_would_satisfy(
4358                    &StrictSemverFormatter,
4359                    &VersionReq::new("^2.0.0"),
4360                    &versions(&["2.0.0-rc.1", "1.5.0"]),
4361                    &yanked_versions(&["2.0.0-rc.1"]),
4362                ),
4363                None
4364            );
4365        }
4366
4367        /// M1: a yanked pre-release is skipped in favor of an older, non-yanked matching one.
4368        #[test]
4369        fn test_yanked_matching_prerelease_falls_back_to_non_yanked() {
4370            assert_eq!(
4371                matching_prerelease_would_satisfy(
4372                    &StrictSemverFormatter,
4373                    &VersionReq::new("^2.0.0"),
4374                    &versions(&["2.0.0-rc.2", "2.0.0-rc.1", "1.5.0"]),
4375                    &yanked_versions(&["2.0.0-rc.2"]),
4376                ),
4377                Some("2.0.0-rc.1".to_string())
4378            );
4379        }
4380
4381        /// Ecosystems that have not opted in (Maven/NuGet/Composer/Gradle) never get the
4382        /// enrichment, even against a requirement/available pair that would otherwise match.
4383        #[test]
4384        fn test_non_opted_in_ecosystem_returns_none() {
4385            assert_eq!(
4386                matching_prerelease_would_satisfy(
4387                    &NonStrictFormatter,
4388                    &VersionReq::new("^2.0.0"),
4389                    &versions(&["2.0.0-rc.1", "1.5.0"]),
4390                    &[],
4391                ),
4392                None
4393            );
4394        }
4395    }
4396
4397    /// Coverage for `requirement_matches_only_yanked` and its wiring into
4398    /// `generate_diagnostics_from_cache` (issue #247): the cache-only diagnostics path's
4399    /// substitute for the network path's `current.removal_status()` check in `generate_diagnostics`,
4400    /// which never fires against a real registry because `Registry::get_latest_matching`
4401    /// filters yanked entries out by contract on every current implementation (#233). This
4402    /// scans `available`/`yanked` directly instead, so it observes yanked entries that
4403    /// `get_latest_matching` never returns.
4404    mod requirement_matches_only_yanked_tests {
4405        use super::*;
4406
4407        type Decide = Arc<dyn Fn(&str) -> Option<bool> + Send + Sync>;
4408
4409        struct ClosureMatcher(Decide);
4410
4411        impl RequirementMatcher for ClosureMatcher {
4412            fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
4413                (self.0)(version.as_str())
4414            }
4415        }
4416
4417        /// Same shape as `requirement_is_unsatisfiable_tests::TableFormatter`:
4418        /// `compile_requirement` only opts in for the literal requirement string `"modelled"`.
4419        struct TableFormatter {
4420            decide: Decide,
4421        }
4422
4423        impl TableFormatter {
4424            fn new(decide: impl Fn(&str) -> Option<bool> + Send + Sync + 'static) -> Self {
4425                Self {
4426                    decide: Arc::new(decide),
4427                }
4428            }
4429        }
4430
4431        impl PackageNaming for TableFormatter {}
4432
4433        impl PackageRendering for TableFormatter {
4434            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
4435                version.to_string()
4436            }
4437
4438            fn package_url(&self, name: &PackageName) -> String {
4439                name.to_string()
4440            }
4441        }
4442
4443        impl RequirementResolution for TableFormatter {
4444            fn requirement_is_unresolved(&self, requirement: &VersionReq) -> bool {
4445                requirement.as_str() == "unresolved"
4446            }
4447
4448            fn compile_requirement(
4449                &self,
4450                requirement: &VersionReq,
4451            ) -> Option<Box<dyn RequirementMatcher>> {
4452                if requirement.as_str() != "modelled" {
4453                    return None;
4454                }
4455                Some(Box::new(ClosureMatcher(Arc::clone(&self.decide)))
4456                    as Box<dyn RequirementMatcher>)
4457            }
4458        }
4459
4460        impl DiagnosticMessages for TableFormatter {}
4461
4462        impl DiagnosticPolicy for TableFormatter {}
4463
4464        impl SourcePolicy for TableFormatter {}
4465
4466        impl OsvNaming for TableFormatter {}
4467
4468        fn versions(strs: &[&str]) -> Vec<ConcreteVersion> {
4469            strs.iter().map(|s| (*s).into()).collect()
4470        }
4471
4472        fn yanked_versions(strs: &[&str]) -> Vec<(ConcreteVersion, RemovalStatus)> {
4473            strs.iter()
4474                .map(|s| ((*s).into(), RemovalStatus::Yanked))
4475                .collect()
4476        }
4477
4478        #[test]
4479        fn test_yanked_only_match_is_true() {
4480            let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
4481            assert_eq!(
4482                requirement_matches_only_yanked(
4483                    &formatter,
4484                    &VersionReq::new("modelled"),
4485                    &versions(&["1.2.1"]),
4486                    &yanked_versions(&["1.2.1"]),
4487                ),
4488                Some(RemovalStatus::Yanked)
4489            );
4490        }
4491
4492        /// #437 M2 regression: a registry response can carry duplicate entries sharing the
4493        /// same version string (see `lifecycle.rs`'s in-use-version scan, which guards
4494        /// against exactly this). If such a pair has mixed statuses, the aggregate must
4495        /// prefer `Yanked`, not just take whichever entry happens to come first.
4496        #[test]
4497        fn test_duplicate_version_prefers_yanked_status_regardless_of_order() {
4498            let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
4499
4500            let advisory_then_yanked = vec![
4501                ("1.2.1".into(), RemovalStatus::AdvisoryDeprecated),
4502                ("1.2.1".into(), RemovalStatus::Yanked),
4503            ];
4504            assert_eq!(
4505                requirement_matches_only_yanked(
4506                    &formatter,
4507                    &VersionReq::new("modelled"),
4508                    &versions(&["1.2.1"]),
4509                    &advisory_then_yanked,
4510                ),
4511                Some(RemovalStatus::Yanked),
4512                "Yanked must win when it comes after AdvisoryDeprecated in the duplicate list"
4513            );
4514
4515            let yanked_then_advisory = vec![
4516                ("1.2.1".into(), RemovalStatus::Yanked),
4517                ("1.2.1".into(), RemovalStatus::AdvisoryDeprecated),
4518            ];
4519            assert_eq!(
4520                requirement_matches_only_yanked(
4521                    &formatter,
4522                    &VersionReq::new("modelled"),
4523                    &versions(&["1.2.1"]),
4524                    &yanked_then_advisory,
4525                ),
4526                Some(RemovalStatus::Yanked),
4527                "Yanked must win when it comes before AdvisoryDeprecated in the duplicate list"
4528            );
4529        }
4530
4531        #[test]
4532        fn test_no_match_is_false() {
4533            let formatter = TableFormatter::new(|_v| Some(false));
4534            assert!(
4535                requirement_matches_only_yanked(
4536                    &formatter,
4537                    &VersionReq::new("modelled"),
4538                    &versions(&["1.2.1"]),
4539                    &yanked_versions(&["1.2.1"]),
4540                )
4541                .is_none()
4542            );
4543        }
4544
4545        #[test]
4546        fn test_match_on_non_yanked_alongside_yanked_is_false() {
4547            // "^1.0" matches both a yanked 1.0.0 and a non-yanked 1.0.1 — a non-yanked
4548            // alternative exists, so this must not be reported as "yanked-only".
4549            let formatter = TableFormatter::new(|v| Some(v == "1.0.0" || v == "1.0.1"));
4550            assert!(
4551                requirement_matches_only_yanked(
4552                    &formatter,
4553                    &VersionReq::new("modelled"),
4554                    &versions(&["1.0.1", "1.0.0"]),
4555                    &yanked_versions(&["1.0.0"]),
4556                )
4557                .is_none()
4558            );
4559        }
4560
4561        #[test]
4562        fn test_scan_continues_past_a_yanked_match_to_find_a_non_yanked_alternative() {
4563            // Same as above but with the yanked candidate ordered first, so a scan that
4564            // stopped at the first `Some(true)` (as `requirement_is_unsatisfiable` does) would
4565            // wrongly report "yanked-only" here.
4566            let formatter = TableFormatter::new(|v| Some(v == "1.0.0" || v == "1.0.1"));
4567            assert!(
4568                requirement_matches_only_yanked(
4569                    &formatter,
4570                    &VersionReq::new("modelled"),
4571                    &versions(&["1.0.0", "1.0.1"]),
4572                    &yanked_versions(&["1.0.0"]),
4573                )
4574                .is_none()
4575            );
4576        }
4577
4578        #[test]
4579        fn test_empty_yanked_list_is_false_without_compiling() {
4580            let formatter =
4581                TableFormatter::new(|_v| panic!("must not compile/scan when yanked is empty"));
4582            assert!(
4583                requirement_matches_only_yanked(
4584                    &formatter,
4585                    &VersionReq::new("modelled"),
4586                    &versions(&["1.0.0"]),
4587                    &[],
4588                )
4589                .is_none()
4590            );
4591        }
4592
4593        #[test]
4594        fn test_empty_available_list_is_false() {
4595            let formatter = TableFormatter::new(|_v| Some(true));
4596            assert!(
4597                requirement_matches_only_yanked(
4598                    &formatter,
4599                    &VersionReq::new("modelled"),
4600                    &[],
4601                    &yanked_versions(&["1.0.0"]),
4602                )
4603                .is_none()
4604            );
4605        }
4606
4607        #[test]
4608        fn test_unresolved_requirement_is_false() {
4609            let formatter = TableFormatter::new(|_v| Some(true));
4610            assert!(
4611                requirement_matches_only_yanked(
4612                    &formatter,
4613                    &VersionReq::new("unresolved"),
4614                    &versions(&["1.0.0"]),
4615                    &yanked_versions(&["1.0.0"]),
4616                )
4617                .is_none()
4618            );
4619        }
4620
4621        #[test]
4622        fn test_compile_requirement_none_is_false() {
4623            let formatter = TableFormatter::new(|_v| Some(true));
4624            assert!(
4625                requirement_matches_only_yanked(
4626                    &formatter,
4627                    &VersionReq::new("not-modelled"),
4628                    &versions(&["1.0.0"]),
4629                    &yanked_versions(&["1.0.0"]),
4630                )
4631                .is_none()
4632            );
4633        }
4634
4635        /// End-to-end: `generate_diagnostics_from_cache` emits the yanked diagnostic (default
4636        /// severity, `formatter.yanked_message()` plus a "; latest is X" suffix mirroring the
4637        /// sibling unsatisfiable diagnostic's actionability) and nothing else for a dependency
4638        /// whose requirement matches only a yanked version.
4639        #[test]
4640        fn test_generate_diagnostics_from_cache_yanked_only_match_fires_yanked_diagnostic() {
4641            let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
4642
4643            let parse_result = MockParseResult {
4644                deps: vec![MockDep {
4645                    name: "serde".into(),
4646                    version_req: "modelled".into(),
4647                    version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
4648                    name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
4649                }],
4650                uri: crate::test_util::test_uri("/test/Cargo.toml"),
4651            };
4652
4653            let mut cached_versions = HashMap::new();
4654            cached_versions.insert(
4655                "serde".into(),
4656                PackageVersions {
4657                    latest: "2.0.0".into(),
4658                    available: Arc::from(vec!["2.0.0".into(), "1.2.1".into()]),
4659                    yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::Yanked)]),
4660                    published_at: None,
4661                },
4662            );
4663            let resolved_versions = HashMap::new();
4664
4665            let diagnostics = generate_diagnostics_from_cache(
4666                &parse_result,
4667                VersionData::new(&cached_versions, &resolved_versions),
4668                &formatter,
4669                parse_result.uri(),
4670                crate::freshness::FreshnessSettings::default(),
4671                DiagnosticSeverities::default(),
4672                PublishTime::now(),
4673            );
4674
4675            assert_eq!(diagnostics.len(), 1, "expected exactly one diagnostic");
4676            assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::WARNING));
4677            assert_eq!(
4678                diagnostics[0].message,
4679                format!("{}; latest is 2.0.0", formatter.yanked_message())
4680            );
4681        }
4682
4683        /// #437 (formerly I1, D5 gate regression): a package-level deprecation finding must
4684        /// NOT suppress the #247 `requirement_matches_only_yanked` check when there is no
4685        /// corresponding #263 (`versions.outcomes` yanked) entry to justify it AND the #247 match's own
4686        /// status is `Yanked` — a genuine hard yank must never be hidden behind a deprecation
4687        /// notice, exactly like D5 guards for #263. #247 now reads its own `RemovalStatus` per
4688        /// entry from `package_versions.yanked` (see `PackageVersions::yanked`), so "no #263
4689        /// finding for this name" is no longer a reason to fire unconditionally — it's simply
4690        /// evidence that #247 must decide for itself, from its own matched entry's status
4691        /// (the PyPI range-satisfiable-only-by-a-real-yank-with-no-exact-pin case).
4692        #[test]
4693        fn test_generate_diagnostics_from_cache_deprecation_never_suppresses_yanked_only_match_without_263_entry()
4694         {
4695            let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
4696
4697            let parse_result = MockParseResult {
4698                deps: vec![MockDep {
4699                    name: "serde".into(),
4700                    version_req: "modelled".into(),
4701                    version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
4702                    name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
4703                }],
4704                uri: crate::test_util::test_uri("/test/Cargo.toml"),
4705            };
4706
4707            let mut cached_versions = HashMap::new();
4708            cached_versions.insert(
4709                "serde".into(),
4710                PackageVersions {
4711                    latest: "2.0.0".into(),
4712                    available: Arc::from(vec!["2.0.0".into(), "1.2.1".into()]),
4713                    yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::Yanked)]),
4714                    published_at: None,
4715                },
4716            );
4717            let resolved_versions = HashMap::new();
4718            // Deliberately no yanked entry: no #263 (in-use-version) finding for "serde" —
4719            // the bug this test guards against is suppression that vacuously fires on this
4720            // case.
4721            let outcomes = DependencyOutcomes::new().with_deprecation(
4722                "serde",
4723                Deprecation {
4724                    reason: Some("archived".to_string()),
4725                    replacement: None,
4726                },
4727            );
4728
4729            let diagnostics = generate_diagnostics_from_cache(
4730                &parse_result,
4731                VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
4732                &formatter,
4733                parse_result.uri(),
4734                crate::freshness::FreshnessSettings::default(),
4735                DiagnosticSeverities::default(),
4736                PublishTime::now(),
4737            );
4738
4739            assert_eq!(
4740                diagnostics.len(),
4741                2,
4742                "expected exactly the deprecation diagnostic plus the #247 yanked-only \
4743                 match, got: {diagnostics:?}"
4744            );
4745            // R3 (deprecation) always runs before R6b (#247, yanked-only) in the
4746            // orchestrator, so index 0 is the deprecation finding and index 1 is the
4747            // #247 match — R6b's own status (`Yanked`) fires regardless of
4748            // `deprecation_found`, unlike R4's D5 gate.
4749            assert_eq!(
4750                diagnostics[0].message,
4751                format!("{}: archived", formatter.deprecated_message()),
4752                "a genuine Yanked #247 match must still fire, without a #263 entry: {diagnostics:?}"
4753            );
4754            assert_eq!(
4755                diagnostics[0].code,
4756                Some(NumberOrString::String(DEPRECATED_DIAGNOSTIC_CODE.into()))
4757            );
4758            assert_eq!(
4759                diagnostics[1].message,
4760                format!("{}; latest is 2.0.0", formatter.yanked_message())
4761            );
4762            assert_eq!(diagnostics[1].code, None);
4763        }
4764
4765        /// #437 companion: unlike the `Yanked` case above, a #247 match whose own status is
4766        /// `AdvisoryDeprecated` DOES yield to a co-occurring package-level deprecation
4767        /// finding, even with no #263 entry — mirroring D5's exact polarity for the #263
4768        /// check, now applied independently by #247 from its own matched entry's status.
4769        #[test]
4770        fn test_generate_diagnostics_from_cache_deprecation_suppresses_yanked_only_match_without_263_entry()
4771         {
4772            let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
4773
4774            let parse_result = MockParseResult {
4775                deps: vec![MockDep {
4776                    name: "serde".into(),
4777                    version_req: "modelled".into(),
4778                    version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
4779                    name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
4780                }],
4781                uri: crate::test_util::test_uri("/test/Cargo.toml"),
4782            };
4783
4784            let mut cached_versions = HashMap::new();
4785            cached_versions.insert(
4786                "serde".into(),
4787                PackageVersions {
4788                    latest: "2.0.0".into(),
4789                    available: Arc::from(vec!["2.0.0".into(), "1.2.1".into()]),
4790                    yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::AdvisoryDeprecated)]),
4791                    published_at: None,
4792                },
4793            );
4794            let resolved_versions = HashMap::new();
4795            // Deliberately no yanked entry: no #263 (in-use-version) finding for "serde" —
4796            // proves the suppression decision comes from #247's own matched-entry status,
4797            // not the #263 map.
4798            let outcomes = DependencyOutcomes::new().with_deprecation(
4799                "serde",
4800                Deprecation {
4801                    reason: Some("archived".to_string()),
4802                    replacement: None,
4803                },
4804            );
4805
4806            let diagnostics = generate_diagnostics_from_cache(
4807                &parse_result,
4808                VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
4809                &formatter,
4810                parse_result.uri(),
4811                crate::freshness::FreshnessSettings::default(),
4812                DiagnosticSeverities::default(),
4813                PublishTime::now(),
4814            );
4815
4816            assert!(
4817                diagnostics
4818                    .iter()
4819                    .all(|d| !d.message.starts_with(formatter.yanked_message())),
4820                "an AdvisoryDeprecated #247 match must yield to the co-occurring deprecation \
4821                 finding, got: {diagnostics:?}"
4822            );
4823            assert!(
4824                diagnostics
4825                    .iter()
4826                    .any(|d| d.message.starts_with(formatter.deprecated_message())),
4827                "the deprecation finding must still fire: {diagnostics:?}"
4828            );
4829        }
4830
4831        /// #437 S1 regression: a D5-suppressed #263 finding (in-use version is
4832        /// `AdvisoryDeprecated`, deprecation co-occurs, so #263 pushes nothing) must NOT gate
4833        /// off a same-dependency #247 finding whose own matched entry is genuinely `Yanked`.
4834        /// An earlier version of this fix folded `deprecation_suppresses_yanked` into the same
4835        /// sentinel used for #247's dedup guard, which reintroduced #437's exact failure mode
4836        /// through this branch: the in-use version ("2.0.0") is merely deprecated and its own
4837        /// diagnostic is correctly suppressed, but the requirement ("modelled") is separately
4838        /// satisfiable only by a different, genuinely yanked version ("1.2.1") — that must
4839        /// still fire.
4840        #[test]
4841        fn test_generate_diagnostics_from_cache_suppressed_263_entry_does_not_gate_off_yanked_247_match()
4842         {
4843            let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
4844
4845            let parse_result = MockParseResult {
4846                deps: vec![MockDep {
4847                    name: "serde".into(),
4848                    version_req: "modelled".into(),
4849                    version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
4850                    name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
4851                }],
4852                uri: crate::test_util::test_uri("/test/Cargo.toml"),
4853            };
4854
4855            let mut cached_versions = HashMap::new();
4856            cached_versions.insert(
4857                "serde".into(),
4858                PackageVersions {
4859                    latest: "2.0.0".into(),
4860                    available: Arc::from(vec!["2.0.0".into(), "1.2.1".into()]),
4861                    yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::Yanked)]),
4862                    published_at: None,
4863                },
4864            );
4865            let resolved_versions = HashMap::new();
4866            // #263 entry for a *different* version ("2.0.0") than the one #247 matches
4867            // ("1.2.1"), with status `AdvisoryDeprecated` — this is the D5-suppressed case.
4868            let outcomes = DependencyOutcomes::new()
4869                .with_yanked("serde", ("2.0.0".into(), RemovalStatus::AdvisoryDeprecated))
4870                .with_deprecation(
4871                    "serde",
4872                    Deprecation {
4873                        reason: Some("archived".to_string()),
4874                        replacement: None,
4875                    },
4876                );
4877
4878            let diagnostics = generate_diagnostics_from_cache(
4879                &parse_result,
4880                VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
4881                &formatter,
4882                parse_result.uri(),
4883                crate::freshness::FreshnessSettings::default(),
4884                DiagnosticSeverities::default(),
4885                PublishTime::now(),
4886            );
4887
4888            assert_eq!(
4889                diagnostics.len(),
4890                2,
4891                "expected exactly the deprecation diagnostic plus the #247 yanked-only \
4892                 match — the D5-suppressed #263 in-use-version diagnostic (for the \
4893                 deprecated 2.0.0) must stay suppressed and contribute nothing, got: \
4894                 {diagnostics:?}"
4895            );
4896            // R3 (deprecation) always runs before R6b (#247, yanked-only) in the
4897            // orchestrator, so index 0 is the deprecation finding and index 1 is the
4898            // #247 match against the genuinely Yanked 1.2.1 — fired independently of
4899            // R4's D5-suppressed #263 finding for the unrelated 2.0.0 entry.
4900            assert_eq!(
4901                diagnostics[0].message,
4902                format!("{}: archived", formatter.deprecated_message()),
4903                "the package-level deprecation finding must still fire: {diagnostics:?}"
4904            );
4905            assert_eq!(
4906                diagnostics[0].code,
4907                Some(NumberOrString::String(DEPRECATED_DIAGNOSTIC_CODE.into()))
4908            );
4909            assert_eq!(
4910                diagnostics[1].message,
4911                format!("{}; latest is 2.0.0", formatter.yanked_message()),
4912                "the #247 match against the genuinely Yanked 1.2.1 must still fire even though \
4913                 the unrelated #263 in-use-version finding was D5-suppressed, got: {diagnostics:?}"
4914            );
4915            assert_eq!(diagnostics[1].code, None);
4916        }
4917
4918        /// #247 vs. #263 dedup: a dependency whose in-use version (lock-file-resolved, or an
4919        /// exact pin) is yanked *and* is the only version satisfying its own requirement
4920        /// triggers both the in-use-version check (`versions.outcomes` yanked, #263) and the
4921        /// requirement-only-satisfiable-by-yanked check (`requirement_matches_only_yanked`,
4922        /// #247). Exactly one diagnostic must be emitted, not two.
4923        #[test]
4924        fn test_generate_diagnostics_from_cache_yanked_dedup_in_use_and_requirement_only_match() {
4925            let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
4926
4927            let parse_result = MockParseResult {
4928                deps: vec![MockDep {
4929                    name: "serde".into(),
4930                    version_req: "modelled".into(),
4931                    version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
4932                    name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
4933                }],
4934                uri: crate::test_util::test_uri("/test/Cargo.toml"),
4935            };
4936
4937            let mut cached_versions = HashMap::new();
4938            cached_versions.insert(
4939                "serde".into(),
4940                PackageVersions {
4941                    latest: "2.0.0".into(),
4942                    available: Arc::from(vec!["2.0.0".into(), "1.2.1".into()]),
4943                    yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::Yanked)]),
4944                    published_at: None,
4945                },
4946            );
4947            let resolved_versions = HashMap::new();
4948            let outcomes = DependencyOutcomes::new()
4949                .with_yanked("serde", ("1.2.1".into(), RemovalStatus::Yanked));
4950
4951            let diagnostics = generate_diagnostics_from_cache(
4952                &parse_result,
4953                VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
4954                &formatter,
4955                parse_result.uri(),
4956                crate::freshness::FreshnessSettings::default(),
4957                DiagnosticSeverities::default(),
4958                PublishTime::now(),
4959            );
4960
4961            // Two diagnostics are expected, not one: the in-use-version check (#263) has
4962            // no `continue`, so it co-emits alongside the ordinary "outdated" diagnostic
4963            // for the same dependency (the fixture's declared requirement "modelled"
4964            // does not itself equal `latest` "2.0.0", so `requirement_status` reports
4965            // `Outdated`) — this is #263's deliberate, separately-tested policy, see
4966            // `test_generate_diagnostics_from_cache_yanked_and_outdated_both_emitted`.
4967            // What this test actually proves is narrower: exactly one *yanked*
4968            // diagnostic, not two — #247's `yanked_only` check must not also fire.
4969            assert_eq!(
4970                diagnostics.len(),
4971                2,
4972                "expected exactly the yanked diagnostic plus the co-emitted outdated \
4973                 diagnostic (#263's policy), got: {diagnostics:?}"
4974            );
4975            // R4 (#263, in-use-yanked) runs before R7 (outdated); R6b (#247,
4976            // yanked-only) is dedup-suppressed by R4 having already emitted, so it
4977            // contributes nothing at any index. Index 0 is therefore always the
4978            // in-use-version-yanked finding and index 1 the outdated finding.
4979            assert_eq!(
4980                diagnostics[0].message,
4981                format!("{} (1.2.1)", formatter.yanked_message()),
4982                "expected the in-use-version check (#263) to run first and win, got: {diagnostics:?}"
4983            );
4984            assert_eq!(diagnostics[0].code, None);
4985            assert_eq!(
4986                diagnostics[1].message, "Newer version available: 2.0.0",
4987                "expected the co-emitted outdated diagnostic, got: {diagnostics:?}"
4988            );
4989            assert_eq!(diagnostics[1].code, None);
4990        }
4991
4992        /// `severities.yanked` reaches the emitted diagnostic on the cache-only path, the same
4993        /// way `outdated_severity`/`unknown_severity`/`unsatisfiable_severity` already do.
4994        #[test]
4995        fn test_generate_diagnostics_from_cache_yanked_only_match_uses_configured_severity() {
4996            let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
4997
4998            let parse_result = MockParseResult {
4999                deps: vec![MockDep {
5000                    name: "serde".into(),
5001                    version_req: "modelled".into(),
5002                    version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
5003                    name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
5004                }],
5005                uri: crate::test_util::test_uri("/test/Cargo.toml"),
5006            };
5007
5008            let mut cached_versions = HashMap::new();
5009            cached_versions.insert(
5010                "serde".into(),
5011                PackageVersions {
5012                    latest: "1.2.1".into(),
5013                    available: Arc::from(vec!["1.2.1".into()]),
5014                    yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::Yanked)]),
5015                    published_at: None,
5016                },
5017            );
5018            let resolved_versions = HashMap::new();
5019
5020            let severities = DiagnosticSeverities {
5021                yanked: DiagnosticSeverity::ERROR,
5022                ..DiagnosticSeverities::default()
5023            };
5024
5025            let diagnostics = generate_diagnostics_from_cache(
5026                &parse_result,
5027                VersionData::new(&cached_versions, &resolved_versions),
5028                &formatter,
5029                parse_result.uri(),
5030                crate::freshness::FreshnessSettings::default(),
5031                severities,
5032                PublishTime::now(),
5033            );
5034
5035            assert_eq!(diagnostics.len(), 1);
5036            assert_eq!(diagnostics[0].severity, Some(DiagnosticSeverity::ERROR));
5037        }
5038
5039        /// When a non-yanked version also satisfies the requirement, no yanked diagnostic
5040        /// fires — the dependency falls through to the ordinary outdated/up-to-date check.
5041        #[test]
5042        fn test_generate_diagnostics_from_cache_match_with_non_yanked_alternative_skips_yanked_diagnostic()
5043         {
5044            let formatter = TableFormatter::new(|v| Some(v == "1.0.0" || v == "1.0.1"));
5045
5046            let parse_result = MockParseResult {
5047                deps: vec![MockDep {
5048                    name: "serde".into(),
5049                    version_req: "modelled".into(),
5050                    version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
5051                    name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
5052                }],
5053                uri: crate::test_util::test_uri("/test/Cargo.toml"),
5054            };
5055
5056            let mut cached_versions = HashMap::new();
5057            cached_versions.insert(
5058                "serde".into(),
5059                PackageVersions {
5060                    latest: "2.0.0".into(),
5061                    available: Arc::from(vec!["2.0.0".into(), "1.0.1".into(), "1.0.0".into()]),
5062                    yanked: Arc::from(vec![("1.0.0".into(), RemovalStatus::Yanked)]),
5063                    published_at: None,
5064                },
5065            );
5066            let resolved_versions = HashMap::new();
5067
5068            let diagnostics = generate_diagnostics_from_cache(
5069                &parse_result,
5070                VersionData::new(&cached_versions, &resolved_versions),
5071                &formatter,
5072                parse_result.uri(),
5073                crate::freshness::FreshnessSettings::default(),
5074                DiagnosticSeverities::default(),
5075                PublishTime::now(),
5076            );
5077
5078            assert!(
5079                !diagnostics
5080                    .iter()
5081                    .any(|d| d.message.starts_with(formatter.yanked_message())),
5082                "a non-yanked match exists, so no yanked diagnostic should fire, got: {diagnostics:?}"
5083            );
5084        }
5085
5086        /// M1 regression: an undecided candidate (`matcher.matches` returns `None`) must not
5087        /// be silently skipped — it might have been a genuine non-yanked match this scan
5088        /// could not evaluate, so it disqualifies a `true` verdict entirely.
5089        #[test]
5090        fn test_undecided_candidate_prevents_true_verdict() {
5091            let formatter = TableFormatter::new(|v| match v {
5092                "1.2.1" => Some(true),
5093                "unparseable" => None,
5094                _ => Some(false),
5095            });
5096            assert!(
5097                requirement_matches_only_yanked(
5098                    &formatter,
5099                    &VersionReq::new("modelled"),
5100                    &versions(&["1.2.1", "unparseable"]),
5101                    &yanked_versions(&["1.2.1"]),
5102                )
5103                .is_none()
5104            );
5105        }
5106
5107        /// Same scenario, but the undecided candidate is scanned before the yanked match —
5108        /// proves the early `return false` on a non-yanked match doesn't accidentally mask
5109        /// this case, and that the `saw_undecided` flag survives regardless of scan order.
5110        #[test]
5111        fn test_undecided_candidate_before_match_still_prevents_true_verdict() {
5112            let formatter = TableFormatter::new(|v| match v {
5113                "1.2.1" => Some(true),
5114                "unparseable" => None,
5115                _ => Some(false),
5116            });
5117            assert!(
5118                requirement_matches_only_yanked(
5119                    &formatter,
5120                    &VersionReq::new("modelled"),
5121                    &versions(&["unparseable", "1.2.1"]),
5122                    &yanked_versions(&["1.2.1"]),
5123                )
5124                .is_none()
5125            );
5126        }
5127
5128        #[test]
5129        fn test_oversized_requirement_is_false_without_compiling() {
5130            let formatter =
5131                TableFormatter::new(|_v| panic!("must not compile/scan an oversized requirement"));
5132            let oversized = "1".repeat(MAX_REQUIREMENT_LEN + 1);
5133            assert!(
5134                requirement_matches_only_yanked(
5135                    &formatter,
5136                    &VersionReq::new(oversized),
5137                    &versions(&["1.0.0"]),
5138                    &yanked_versions(&["1.0.0"]),
5139                )
5140                .is_none()
5141            );
5142        }
5143
5144        /// The yanked-only-match diagnostic must never fire for a non-registry-resolvable
5145        /// dependency source (path/git/URL/SDK/workspace) — the same guard
5146        /// `requirement_is_unsatisfiable` already has (see
5147        /// `test_generate_diagnostics_unsatisfiable_skipped_for_non_registry_sources`).
5148        #[test]
5149        fn test_yanked_only_match_skipped_for_non_registry_sources() {
5150            let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
5151            let uri = crate::test_util::test_uri("/test/Cargo.toml");
5152
5153            let mut cached_versions = HashMap::new();
5154            cached_versions.insert(
5155                "dep".into(),
5156                PackageVersions {
5157                    latest: "2.0.0".into(),
5158                    available: Arc::from(vec!["2.0.0".into(), "1.2.1".into()]),
5159                    yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::Yanked)]),
5160                    published_at: None,
5161                },
5162            );
5163            let resolved_versions = HashMap::new();
5164
5165            let dep = MockDep {
5166                name: "dep".into(),
5167                version_req: "modelled".into(),
5168                version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
5169                name_range: Range::new(Position::new(0, 0), Position::new(0, 3)),
5170            };
5171            let parse_result = SingleDepParseResult {
5172                dep: NonRegistryDep(
5173                    dep,
5174                    crate::parser::DependencySource::Path {
5175                        path: "../local".into(),
5176                    },
5177                ),
5178                uri,
5179            };
5180
5181            let diagnostics = generate_diagnostics_from_cache(
5182                &parse_result,
5183                VersionData::new(&cached_versions, &resolved_versions),
5184                &formatter,
5185                parse_result.uri(),
5186                crate::freshness::FreshnessSettings::default(),
5187                DiagnosticSeverities::default(),
5188                PublishTime::now(),
5189            );
5190
5191            assert!(
5192                diagnostics
5193                    .iter()
5194                    .all(|d| !d.message.starts_with(formatter.yanked_message())),
5195                "a path dependency must never produce the yanked diagnostic, got: {diagnostics:?}"
5196            );
5197        }
5198
5199        /// The `continue` after emitting the yanked diagnostic must suppress the sibling
5200        /// outdated check for the same dependency — proven directly rather than just
5201        /// inferred from `diagnostics.len() == 1` elsewhere in this module.
5202        #[test]
5203        fn test_yanked_only_match_suppresses_outdated_diagnostic() {
5204            let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
5205
5206            let parse_result = MockParseResult {
5207                deps: vec![MockDep {
5208                    name: "serde".into(),
5209                    version_req: "modelled".into(),
5210                    version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
5211                    name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
5212                }],
5213                uri: crate::test_util::test_uri("/test/Cargo.toml"),
5214            };
5215
5216            let mut cached_versions = HashMap::new();
5217            cached_versions.insert(
5218                "serde".into(),
5219                PackageVersions {
5220                    latest: "2.0.0".into(),
5221                    available: Arc::from(vec!["2.0.0".into(), "1.2.1".into()]),
5222                    yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::Yanked)]),
5223                    published_at: None,
5224                },
5225            );
5226            let resolved_versions = HashMap::new();
5227
5228            let diagnostics = generate_diagnostics_from_cache(
5229                &parse_result,
5230                VersionData::new(&cached_versions, &resolved_versions),
5231                &formatter,
5232                parse_result.uri(),
5233                crate::freshness::FreshnessSettings::default(),
5234                DiagnosticSeverities::default(),
5235                PublishTime::now(),
5236            );
5237
5238            assert!(
5239                !diagnostics
5240                    .iter()
5241                    .any(|d| d.message.contains("Newer version available")),
5242                "the yanked diagnostic must suppress the outdated hint, not add to it, got: {diagnostics:?}"
5243            );
5244        }
5245    }
5246}