Skip to main content

deps_core/lsp_helpers/
mod.rs

1//! Shared LSP response builders.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::Duration;
6
7use crate::error::DepsError;
8use crate::licenses::LicensePolicy;
9use crate::osv::{
10    CandidateStatusMap, CandidateStatuses, LatestStatusMap, ScanOutcome, StructuralSkipReason,
11    UpgradeStatus, VulnKey, VulnSeverity, VulnerabilityMap,
12};
13use crate::position::{Position, Range};
14use crate::{
15    ConcreteVersion, CooldownWindow, Dependency, Deprecation, DepsDevClient, EcosystemId,
16    FetchFailure, GossipFindings, LicenseSource, PackageName, PublishTime, RemovalStatus,
17    TyposquatSignal, VersionReq,
18};
19
20#[cfg(feature = "lsp-responses")]
21mod code_actions;
22#[cfg(feature = "lsp-responses")]
23mod code_lenses;
24mod diagnostics;
25mod formatter;
26mod git_ref;
27#[cfg(feature = "lsp-responses")]
28mod hover;
29#[cfg(feature = "lsp-responses")]
30mod hover_markdown;
31mod in_use_version;
32#[cfg(feature = "lsp-responses")]
33mod inlay_hints;
34#[cfg(test)]
35pub(crate) mod test_support;
36#[cfg(test)]
37#[cfg(feature = "lsp-responses")]
38pub(crate) mod test_support_lsp;
39
40/// Generic replacement for this module's former `TextEdit`-only `dedup_overlapping_edits`
41/// (#1329) — re-exported here so every existing `lsp_helpers::dedup_overlapping_edits(edits,
42/// caller)` call site (e.g. `deps_github_actions::collect_pin_all_to_sha_edits`) keeps
43/// compiling unchanged, with `E = ls_types::TextEdit` inferred from the call's own argument
44/// type.
45#[cfg(feature = "lsp-responses")]
46pub use crate::edit::dedup_overlapping_edits;
47#[cfg(feature = "lsp-responses")]
48pub use code_actions::generate_code_actions;
49#[cfg(feature = "lsp-responses")]
50pub use code_lenses::{
51    PIN_ALL_TO_SHA_COMMAND_ID, PinNoun, build_pin_all_to_sha_lens, collect_update_all_edits,
52    generate_code_lenses,
53};
54pub use diagnostics::{
55    BoundedVersionReq, DEPRECATED_DIAGNOSTIC_CODE, DiagnosticSeverities,
56    LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE, MAX_DIAGNOSTIC_VALUE_CHARS, MAX_REQUIREMENT_LEN,
57    TYPOSQUAT_DIAGNOSTIC_CODE, TyposquatFetchOutcome, UNSATISFIABLE_DIAGNOSTIC_CODE,
58    compile_requirement_unless, fetch_gossip_findings_batch, fetch_typosquat_signals,
59    force_refresh_gossip_findings, generate_diagnostics_from_cache, redact_name_for_diagnostic,
60    redact_requirement_for_diagnostic, requirement_is_unsatisfiable, requirement_len_exceeds_cap,
61    sanitize_advisory_text_for_diagnostic, sanitize_and_truncate_for_diagnostic,
62    truncate_for_diagnostic,
63};
64// `pub` (#1578): `edit.rs`/`in_use_version.rs` share this in-crate, and it is also reused by
65// callers outside `deps-core` that need this same size gate before compiling a requirement
66// (mirroring `requirement_len_exceeds_cap`'s own `pub` rationale) rather than duplicating the
67// length check at their own `compile_bounded_requirement` call site (#1472).
68pub use diagnostics::requirement_is_oversized;
69// `pub(crate)` (not `pub`, matching the constant's own visibility) so `completion.rs` can
70// share this bound with `inlay_hints`/`hover` rather than declaring a duplicate cap.
71// `completion` is itself `#[cfg(feature = "lsp-responses")]` (see `lib.rs`) and is this
72// re-export's only consumer outside `lsp_helpers`, so without this gate a default-features
73// build (e.g. `fuzz`'s workspace check) sees it as unused.
74#[cfg(feature = "lsp-responses")]
75pub(crate) use diagnostics::MAX_VERSION_DIAGNOSTIC_CHARS;
76pub use formatter::{
77    BareMeaning, BuildMetadataPolicy, DiagnosticMessages, DiagnosticPolicy, EcosystemFormatter,
78    OsvNameAvailability, OsvNaming, PackageNaming, PackageRendering, RequirementGate,
79    RequirementResolution, RequirementRewriteShape, SourcePolicy, bare_meaning,
80    classify_requirement_rewrite_shape, format_version_replacing_by_shape, requirement_is_compound,
81    up_to_date_for_comparators_via_compiled_matcher, up_to_date_via_compiled_matcher,
82};
83pub use git_ref::{
84    CommitSha, MAX_FALLBACK_SCAN_BYTES, MarkedScalar, ResolvedPin, ShaPinLookup, SiblingTags,
85    TagIndex, byte_span_to_range, extends_tag, is_full_sha, is_null_tag, is_partial_semver_shaped,
86    is_plain_null, is_tag_shaped, locate_value_span, marker_byte_offset, match_v_prefix_style,
87    short_sha,
88};
89#[cfg(feature = "lsp-responses")]
90pub use git_ref::{
91    ResolvedShaPin, ShaPinning, build_sha_pin_action, sha_pin_text_edit, splice_hover_line,
92    splice_resolved_line,
93};
94#[cfg(feature = "lsp-responses")]
95pub use hover::{CMD_DOT_FOOTER, generate_hover};
96#[cfg(feature = "lsp-responses")]
97pub use hover_markdown::{FieldKind, HoverMarkdown, SafeNumber};
98pub use in_use_version::{
99    InUseVersions, concrete_pin_version, has_unqueryable_resolved_pin, is_full_semver_shape,
100    resolve_in_use_version, resolve_in_use_versions,
101};
102#[cfg(feature = "lsp-responses")]
103pub use inlay_hints::generate_inlay_hints;
104
105/// Maximum number of recent versions hover's "Recent versions" section renders.
106///
107/// Also the walk target for registries (NuGet, npm) that must fetch publish times for
108/// only the versions actually rendered, rather than the entire version history.
109pub const HOVER_RECENT_VERSIONS: usize = 8;
110
111/// A cooldown-cleared, ecosystem-safe, floor-protected fallback candidate (spec 075
112/// `deps-cli update` fallback, FR-001/FR-002).
113///
114/// Computed once, at fetch time, by `deps-engine::classify::fetch::fetch_and_classify_package`
115/// — never at read time (see [`cooldown_disposition`] for the read-time side). [`Self::published_at`]
116/// is never `None`: a candidate without a known publish time is never stored (OQ2's fail-closed
117/// rule for a non-`latest` candidate).
118#[non_exhaustive]
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct CooldownFallback {
121    /// The fallback candidate's version.
122    pub version: ConcreteVersion,
123    /// When the fallback candidate was published. Never `None` — see the type's own doc.
124    pub published_at: PublishTime,
125}
126
127impl CooldownFallback {
128    /// Constructs a `CooldownFallback`.
129    ///
130    /// Needed because [`Self`] is `#[non_exhaustive]`: a struct literal only works inside
131    /// this crate, so every other crate must go through this constructor instead.
132    ///
133    /// # Examples
134    ///
135    /// ```
136    /// use deps_core::PublishTime;
137    /// use deps_core::lsp_helpers::CooldownFallback;
138    ///
139    /// let fallback = CooldownFallback::new("1.1.0".into(), PublishTime::from_unix_secs(1_000));
140    /// assert_eq!(fallback.version, "1.1.0");
141    /// ```
142    #[must_use]
143    pub const fn new(version: ConcreteVersion, published_at: PublishTime) -> Self {
144        Self {
145            version,
146            published_at,
147        }
148    }
149}
150
151/// What blocked [`PackageVersions::latest`] from being usable as-is — drives
152/// check/hover/diagnostics wording. Carried by [`CooldownDisposition::Blocked`].
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum CooldownBlocker {
155    /// GOSSIP reported an active cooldown for this exact version (spec 072 FR-005).
156    Gossip,
157    /// The local [`CooldownWindow`] heuristic reports this version as
158    /// recently published, with no authoritative GOSSIP answer available for it.
159    Local {
160        /// When `latest` was published, per the registry.
161        published_at: PublishTime,
162    },
163}
164
165/// Read-time-only outcome of [`cooldown_disposition`] — never stored on [`PackageVersions`].
166///
167/// Spec 075 NFR-002: a stored disposition would be a fetch-time snapshot, violating spec 072
168/// FR-011's read-time evaluation requirement.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub enum CooldownDisposition<'a> {
171    /// The freshness feature is disabled — cooldown has no effect at all.
172    NotEvaluated,
173    /// `latest` is not blocked by cooldown (no GOSSIP verdict, or a past one; no active local
174    /// heuristic result).
175    Cleared,
176    /// `latest` is currently blocked. `fallback`, when present, is the newest
177    /// cooldown-cleared, ecosystem-safe, floor-protected alternative a caller may offer
178    /// instead (spec 075) — `None` when no such candidate was found at fetch time.
179    Blocked {
180        /// What blocked `latest`.
181        by: CooldownBlocker,
182        /// The fallback candidate, if one was computed and stored for this dependency.
183        fallback: Option<&'a CooldownFallback>,
184    },
185}
186
187/// Outcome of [`cooldown_precedence`]: whether a single version is currently blocked by the
188/// freshness cooldown, and by what.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum CooldownPrecedence {
191    /// Not blocked (no active GOSSIP verdict, or a past one; no active local heuristic result).
192    Cleared,
193    /// Currently blocked, and by what.
194    Blocked(CooldownBlocker),
195}
196
197/// The GOSSIP-vs-local cooldown precedence rule for a single `(name, version)` pair.
198///
199/// An authoritative GOSSIP verdict wins outright, and only when GOSSIP has none does the local
200/// heuristic ([`local_cooldown_precedence`]) apply.
201///
202/// Extracted from [`cooldown_disposition`] (spec 075 FR-004) so `deps-engine`'s fetch-time
203/// fallback-candidate search (`compute_cooldown_fallback`) can share the exact same rule
204/// without first needing a full [`PackageVersions`] — that type doesn't exist yet at fetch
205/// time for any version other than the one being classified (issue #1551).
206///
207/// Takes a [`CooldownWindow`] rather than the [`FreshnessSettings`
208/// enum](crate::FreshnessSettings) so callers naturally go through
209/// [`FreshnessSettings::Enabled`](crate::FreshnessSettings::Enabled) — the only place a window
210/// value is produced — though `CooldownWindow::from_secs` being `pub` means this is a
211/// convention, not a hard type-level guarantee.
212///
213/// # Examples
214///
215/// ```
216/// use deps_core::lsp_helpers::{CooldownPrecedence, cooldown_precedence};
217/// use deps_core::{CooldownWindow, PackageName, PublishTime};
218///
219/// let cleared = cooldown_precedence(
220///     None,
221///     &PackageName::new("left-pad"),
222///     "2.0.0",
223///     Some(PublishTime::from_unix_secs(1_000)),
224///     CooldownWindow::from_secs(500),
225///     PublishTime::from_unix_secs(2_000),
226/// );
227/// assert_eq!(cleared, CooldownPrecedence::Cleared);
228///
229/// let blocked = cooldown_precedence(
230///     None,
231///     &PackageName::new("left-pad"),
232///     "2.0.0",
233///     Some(PublishTime::from_unix_secs(1_000)),
234///     CooldownWindow::from_secs(5_000),
235///     PublishTime::from_unix_secs(2_000),
236/// );
237/// assert!(matches!(blocked, CooldownPrecedence::Blocked(_)));
238/// ```
239#[must_use]
240pub fn cooldown_precedence(
241    gossip_prefetch: Option<&HashMap<PackageName, GossipFindings>>,
242    name: &PackageName,
243    version: &str,
244    published_at: Option<PublishTime>,
245    cooldown: CooldownWindow,
246    now: PublishTime,
247) -> CooldownPrecedence {
248    match gossip_cooldown_for(gossip_prefetch, name, version, now) {
249        GossipCooldownLookup::Active => CooldownPrecedence::Blocked(CooldownBlocker::Gossip),
250        GossipCooldownLookup::NotActive => CooldownPrecedence::Cleared,
251        GossipCooldownLookup::Unavailable => local_cooldown_precedence(published_at, cooldown, now),
252    }
253}
254
255/// The local freshness heuristic alone (`published_at` age against `cooldown`), without GOSSIP.
256///
257/// Extracted from [`cooldown_precedence`]'s `Unavailable` arm so every site that evaluates the
258/// local rule by itself — that arm, and [`cooldown_disposition`]'s read-time fallback
259/// re-evaluation — shares one implementation rather than reimplementing the age comparison.
260///
261/// # Examples
262///
263/// ```
264/// use deps_core::lsp_helpers::{CooldownPrecedence, local_cooldown_precedence};
265/// use deps_core::{CooldownWindow, PublishTime};
266///
267/// let now = PublishTime::from_unix_secs(2_000);
268/// let cooldown = CooldownWindow::from_secs(500);
269///
270/// // No known publish time: never blocked.
271/// assert_eq!(
272///     local_cooldown_precedence(None, cooldown, now),
273///     CooldownPrecedence::Cleared
274/// );
275///
276/// // Published recently: blocked.
277/// let recent = PublishTime::from_unix_secs(1_900);
278/// assert!(matches!(
279///     local_cooldown_precedence(Some(recent), cooldown, now),
280///     CooldownPrecedence::Blocked(_)
281/// ));
282///
283/// // Age exactly equal to the window: cleared (the bound is exclusive).
284/// let at_boundary = PublishTime::from_unix_secs(1_500);
285/// assert_eq!(
286///     local_cooldown_precedence(Some(at_boundary), cooldown, now),
287///     CooldownPrecedence::Cleared
288/// );
289/// ```
290#[must_use]
291pub fn local_cooldown_precedence(
292    published_at: Option<PublishTime>,
293    cooldown: CooldownWindow,
294    now: PublishTime,
295) -> CooldownPrecedence {
296    match published_at {
297        Some(published_at) if cooldown.contains(published_at.age_secs_from(now)) => {
298            CooldownPrecedence::Blocked(CooldownBlocker::Local { published_at })
299        }
300        _ => CooldownPrecedence::Cleared,
301    }
302}
303
304/// Evaluates, at read time, whether [`PackageVersions::latest`] is blocked by the freshness
305/// cooldown and whether a stored [`PackageVersions::cooldown_fallback`] candidate is available.
306///
307/// Spec 075 FR-004's single precedence function, shared by `apply_outdated_rule`
308/// (check/diagnostics wording) and `deps-cli update`'s planner so the two commands can no
309/// longer disagree (spec 075 NFR-001 steps 0-4; OSV verification is deliberately NOT this
310/// function's job — see spec 075 FR-010/FR-011). The GOSSIP-vs-local precedence itself is
311/// [`cooldown_precedence`]; this function adds the read-time fallback re-evaluation on top.
312///
313/// # Examples
314///
315/// ```
316/// use deps_core::lsp_helpers::{CooldownDisposition, PackageVersions, cooldown_disposition};
317/// use deps_core::{FreshnessSettings, PackageName, PublishTime};
318/// use std::sync::Arc;
319///
320/// let versions = PackageVersions::new(
321///     "2.0.0".into(),
322///     Arc::from(vec!["2.0.0".into(), "1.0.0".into()]),
323/// )
324/// .with_published_at(PublishTime::from_unix_secs(1_000));
325///
326/// let disposition = cooldown_disposition(
327///     &versions,
328///     &PackageName::new("left-pad"),
329///     FreshnessSettings::Enabled {
330///         cooldown: deps_core::CooldownWindow::from_secs(500),
331///     },
332///     None,
333///     PublishTime::from_unix_secs(2_000),
334/// );
335/// assert_eq!(disposition, CooldownDisposition::Cleared);
336///
337/// let blocked = cooldown_disposition(
338///     &versions,
339///     &PackageName::new("left-pad"),
340///     FreshnessSettings::Enabled {
341///         cooldown: deps_core::CooldownWindow::from_secs(5_000),
342///     },
343///     None,
344///     PublishTime::from_unix_secs(2_000),
345/// );
346/// assert!(matches!(blocked, CooldownDisposition::Blocked { .. }));
347/// ```
348#[must_use]
349pub fn cooldown_disposition<'a>(
350    versions: &'a PackageVersions,
351    name: &PackageName,
352    freshness: crate::freshness::FreshnessSettings,
353    gossip_prefetch: Option<&HashMap<PackageName, GossipFindings>>,
354    now: PublishTime,
355) -> CooldownDisposition<'a> {
356    let crate::freshness::FreshnessSettings::Enabled { cooldown } = freshness else {
357        return CooldownDisposition::NotEvaluated;
358    };
359
360    // NFR-002 (M3): the stored fallback candidate is itself re-evaluated at read time — a
361    // cooldown window narrowed between fetch and read (or, for a long-lived LSP cache, simply
362    // enough wall-clock time passing that the fetch-time computation has gone stale in the
363    // *other* direction the caller didn't ask about) must never let a candidate that no
364    // longer clears the window keep reading as usable. This can only make the outcome
365    // stricter, never less safe — a `None` fallback stays `None`.
366    let fallback = versions.cooldown_fallback.as_ref().filter(|fallback| {
367        matches!(
368            local_cooldown_precedence(Some(fallback.published_at), cooldown, now),
369            CooldownPrecedence::Cleared
370        )
371    });
372
373    match cooldown_precedence(
374        gossip_prefetch,
375        name,
376        versions.latest.as_str(),
377        versions.published_at,
378        cooldown,
379        now,
380    ) {
381        CooldownPrecedence::Cleared => CooldownDisposition::Cleared,
382        CooldownPrecedence::Blocked(by) => CooldownDisposition::Blocked { by, fallback },
383    }
384}
385
386/// Result of [`fallback_edit_excludes_newer`] (spec 076 FR-023): whether the ecosystem's
387/// default-rendered fallback edit is safe to write.
388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389pub enum FallbackEditVerdict {
390    /// The edit is safe: applying and re-parsing it still excludes every known `available`
391    /// version newer than the fallback candidate.
392    Writable,
393    /// The edit was rejected — see [`FallbackEditRejection`] for which check failed.
394    Rejected(FallbackEditRejection),
395}
396
397/// Which [`fallback_edit_excludes_newer`] check rejected the fallback edit, in evaluation
398/// order (first failure wins; phase 2 is only reached once every phase-1 check passes).
399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400pub enum FallbackEditRejection {
401    /// Precondition (round-4 critic D5, fix-cycle M2): `candidate`'s span does not equal this
402    /// occurrence's own `Dependency::version_range()` — the edit was built for a different
403    /// occurrence (or a stale one), so nothing below is meaningful for THIS dependency.
404    /// Production always passes equality (the edit is always built from the same dependency's
405    /// own `version_range()`); this guards the public API against a caller passing a
406    /// mismatched edit.
407    CandidateSpanMismatch,
408    /// Fix-cycle (issue #1561/CWE-1284 class): `fallback` itself is absent from `available` —
409    /// a stale or inconsistent candidate computed against a version list that has since
410    /// changed. Checked once, shared by both d0 and d1, before either can read `fallback`'s
411    /// position: never infer "not a downgrade" from the absence of evidence.
412    FallbackUnlisted,
413    /// Phase 1, a0 pre-check (issue #1580, CWE-400 defense-in-depth mirroring #1472/#1578):
414    /// R0's raw string exceeds [`crate::lsp_helpers::MAX_REQUIREMENT_LEN`] —
415    /// `compile_bounded_requirement` is never called for it. A size-based fail-closed guard, distinct
416    /// from [`Self::OriginalUncompilable`] (a confirmed parse failure on an attempted compile).
417    OriginalOversized,
418    /// Phase 1, a0: the ORIGINAL declared requirement has no compiled matcher (e.g. GitHub
419    /// Actions/GitLab CI tag pins) — unchanged from spec 075.
420    OriginalUncompilable,
421    /// Phase 1, c0: the original requirement already reads the fallback as current (a
422    /// below-floor fallback, e.g. NuGet's bare-floor shape).
423    OriginalAlreadyUpToDate,
424    /// Phase 1, d0: the original requirement's own floor (the OLDEST `available` entry it
425    /// still matches) is newer than `fallback` — writing it would be a downgrade below what R0
426    /// already resolves to, or no listed entry evidences the floor at all (issues #1564/#1561:
427    /// fixed from an earlier "reject if anything newer also matches" reading, which rejected
428    /// nearly every permissive/auto-following requirement, and from a "no evidence means safe"
429    /// vacuous accept on an unlisted declared pin).
430    OriginalResolvesPastFallback,
431    /// Fix-cycle (#1571): the original declared requirement admits some entry strictly newer
432    /// than `fallback`, or a matcher-reported `!=`-style term explicitly bans `fallback`
433    /// (`RequirementMatcher::explicitly_excludes`), yet `fallback` itself fails
434    /// `r0_matcher.matches` — `fallback` sits inside R0's nominal span (or one of its `||`
435    /// branches, for an ecosystem with alternation) but is punched out of it (a `!=` term, or a
436    /// `||`-alternation gap), which a bare floor-position comparison (d0 above) cannot see since
437    /// neither hole shape moves the floor. Distinct from the legitimate case where `fallback`
438    /// fails `r0_matcher.matches` only because it exceeds every branch's ceiling entirely
439    /// (FR-025's out-of-range acceptance).
440    OriginalExcludesFallback,
441    /// The edited manifest could not be re-parsed (FR-024) — [`crate::edit::ManifestReparse`]
442    /// returned `None`, e.g. an `Ecosystem::parse_manifest_sync` override or default that itself
443    /// fails to parse the edited scratch copy.
444    ReparseFailed,
445    /// The re-parsed manifest did not contain exactly one dependency matching this occurrence's
446    /// `(normalized name, version_range.start)`.
447    OccurrenceNotUnique,
448    /// Phase 2, a1 pre-check: same guard as [`Self::OriginalOversized`], applied to R1 (the
449    /// re-parsed EDITED requirement) before its own `compile_bounded_requirement` attempt.
450    EditedOversized,
451    /// Phase 2, a1: the re-parsed, EDITED requirement has no compiled matcher.
452    EditedUncompilable,
453    /// Phase 2, b1: the edited requirement's matcher does not accept the fallback itself — the
454    /// written edit does not actually express the fallback version.
455    EditedExcludesFallback,
456    /// Phase 2, d1: the edited (WRITTEN) requirement admits some `available` entry strictly
457    /// newer than the fallback — the edit auto-follows back into an in-cooldown version. Unlike
458    /// d0, this is NOT a floor comparison: an auto-following range (Cargo's caret, Dart's caret,
459    /// PyPI's default `>=X,<next`) has its floor AT the fallback itself by construction (the
460    /// edit was rendered FROM `fallback`), so a floor check alone would never catch a range that
461    /// also admits something newer above it — exactly the case round-1 critic S1 exists to
462    /// reject (spec 076 §1's documented, intentional fail-closed outcome for these ecosystems).
463    EditedAdmitsNewer,
464}
465
466/// Spec 076 FR-023/FR-024: THE uniform guard deciding whether a cooldown-fallback candidate's
467/// default-rendered edit is safe to write.
468///
469/// Applied identically to spec 075's lockfile-resolved path and spec 076's no-lockfile path —
470/// no per-ecosystem override or retry (FR-025).
471///
472/// Two phases, evaluated in this exact order, first failure wins:
473///
474/// - **Phase 1**, on `R0 = dep.version_requirement()` (the ORIGINAL declared requirement, no
475///   parse needed): a0-pre (issue #1580, CWE-400 defense-in-depth) R0 must not be
476///   [`BoundedVersionReq::new`] (i.e. not [`requirement_is_oversized`]) — `compile_bounded_requirement`
477///   is never called for an oversized requirement; a0 `compile_bounded_requirement(R0)` must be
478///   `Some`; c0 `is_bounded_requirement_up_to_date(R0, fallback)` must be `false` (closes a NuGet bare-floor gap,
479///   spec 076 round-1 critic S2); d0 no `available` entry STRICTLY newer than `fallback` may
480///   satisfy `bounded_requirement_already_resolves_to(R0, entry)` (anti-downgrade — writing `fallback`
481///   must not move resolution backward relative to what R0 already resolves to); fix-cycle #1571:
482///   when `fallback` itself does not match R0's compiled matcher, R0 must not directly report
483///   `fallback` as `!=`-excluded ([`RequirementMatcher::explicitly_excludes`]) and it must not
484///   be the case that some entry strictly newer than `fallback` still matches R0 — otherwise
485///   `fallback` sits inside R0's nominal span (or a `||` branch's) but not R0 itself (a `!=`
486///   term or a `||`-alternation gap), which the floor comparison alone cannot see
487///   ([`FallbackEditRejection::OriginalExcludesFallback`]).
488/// - **Re-parse** (only if phase 1 passes, FR-024): `candidate` is applied to a scratch copy of
489///   `content`, the copy is re-parsed via `reparse`, and the edited occurrence is located by
490///   `(formatter.normalize_package_name(dep.name()), version_range().start)` — NOT `name_range`,
491///   since some grammars (NuGet's `Version`-before-`Include` attribute order, Maven's XML
492///   element order, Gradle's map notation) put the version before the name, so `name_range`
493///   would shift under the edit and silently fail closed. Exactly one match is required; zero
494///   or more than one is [`FallbackEditRejection::OccurrenceNotUnique`].
495/// - **Phase 2**, on `R1` = the located occurrence's re-parsed requirement: a1-pre, the same
496///   [`BoundedVersionReq::new`] guard as a0-pre, applied to R1 before its own
497///   `compile_bounded_requirement` attempt; a1 `compile_bounded_requirement(R1)` must be `Some`; b1 R1's
498///   matcher must accept `fallback` itself
499///   (`Some(true)`) — proving the written edit actually expresses `fallback`, failing closed for
500///   an unmodellable/unsatisfiable written requirement; d1 no `available` entry STRICTLY newer
501///   than `fallback` may satisfy `bounded_requirement_already_resolves_to(R1, entry)` (the edit must not
502///   auto-follow back into a known newer, still-in-cooldown version).
503///
504/// Yanked entries in `available` are NOT excluded from the d0/d1 scans — conservative,
505/// fail-closed: a yanked newer version admitted by the requirement still rejects the fallback.
506///
507/// # Examples
508///
509/// ```
510/// use deps_core::edit::ManifestEdit;
511/// use deps_core::lsp_helpers::{
512///     BoundedVersionReq, DiagnosticMessages, DiagnosticPolicy, FallbackEditRejection, FallbackEditVerdict,
513///     OsvNaming, PackageNaming, PackageRendering, RequirementMatcher, RequirementResolution,
514///     SourcePolicy, fallback_edit_excludes_newer,
515/// };
516/// use deps_core::position::{Position, Range};
517/// use deps_core::{ConcreteVersion, Dependency, PackageName, ParseResult, VersionReq};
518///
519/// struct ExactMatcher(String);
520/// impl RequirementMatcher for ExactMatcher {
521///     fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
522///         Some(version.as_str() == self.0)
523///     }
524///     fn strict_prerelease_exclusion(&self) -> bool {
525///         false
526///     }
527/// }
528///
529/// struct ExactFormatter;
530/// impl PackageNaming for ExactFormatter {}
531/// impl PackageRendering for ExactFormatter {
532///     fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
533///         version.to_string()
534///     }
535///     fn package_url(&self, name: &PackageName) -> String {
536///         name.as_str().to_string()
537///     }
538/// }
539/// impl RequirementResolution for ExactFormatter {
540///     fn compile_bounded_requirement(
541///         &self,
542///         requirement: BoundedVersionReq<'_>,
543///     ) -> Option<Box<dyn RequirementMatcher>> {
544///         Some(Box::new(ExactMatcher(requirement.as_str().to_string())))
545///     }
546/// }
547/// impl DiagnosticMessages for ExactFormatter {}
548/// impl DiagnosticPolicy for ExactFormatter {}
549/// impl SourcePolicy for ExactFormatter {}
550/// impl OsvNaming for ExactFormatter {}
551///
552/// struct ExactDependency;
553/// impl Dependency for ExactDependency {
554///     fn name(&self) -> &PackageName {
555///         static NAME: std::sync::LazyLock<PackageName> =
556///             std::sync::LazyLock::new(|| PackageName::new("pkg"));
557///         &NAME
558///     }
559///     fn name_range(&self) -> Range {
560///         Range::default()
561///     }
562///     fn version_requirement(&self) -> Option<&VersionReq> {
563///         static REQ: std::sync::LazyLock<VersionReq> =
564///             std::sync::LazyLock::new(|| VersionReq::new("1.0.0"));
565///         Some(&REQ)
566///     }
567///     fn version_range(&self) -> Option<Range> {
568///         Some(Range::new(Position::new(0, 0), Position::new(0, 6)))
569///     }
570///     fn source(&self) -> deps_core::parser::DependencySource {
571///         deps_core::parser::DependencySource::Registry
572///     }
573///     fn as_any(&self) -> &dyn std::any::Any {
574///         self
575///     }
576/// }
577///
578/// let formatter = ExactFormatter;
579/// let dep = ExactDependency;
580/// let candidate = ManifestEdit {
581///     range: Range::new(Position::new(0, 0), Position::new(0, 6)),
582///     new_text: "1.1.0".to_string(),
583/// };
584/// let fallback = ConcreteVersion::new("1.1.0");
585/// let available = [ConcreteVersion::new("1.1.0"), ConcreteVersion::new("1.0.0")];
586/// // A stub `ManifestReparse` that always fails — demonstrates the fail-closed re-parse gate.
587/// let reparse = |_content: &str| -> Option<Box<dyn ParseResult>> { None };
588///
589/// let verdict = fallback_edit_excludes_newer(
590///     &formatter,
591///     &reparse,
592///     "1.0.0",
593///     &dep,
594///     &candidate,
595///     &fallback,
596///     &available,
597/// );
598/// assert_eq!(
599///     verdict,
600///     FallbackEditVerdict::Rejected(FallbackEditRejection::ReparseFailed)
601/// );
602/// ```
603#[must_use]
604pub fn fallback_edit_excludes_newer(
605    formatter: &dyn crate::lsp_helpers::EcosystemFormatter,
606    reparse: &dyn crate::edit::ManifestReparse,
607    content: &str,
608    dep: &dyn Dependency,
609    candidate: &crate::edit::ManifestEdit,
610    fallback: &ConcreteVersion,
611    available: &[ConcreteVersion],
612) -> FallbackEditVerdict {
613    use FallbackEditRejection::{
614        CandidateSpanMismatch, EditedAdmitsNewer, EditedExcludesFallback, EditedOversized,
615        EditedUncompilable, FallbackUnlisted, OccurrenceNotUnique, OriginalAlreadyUpToDate,
616        OriginalExcludesFallback, OriginalOversized, OriginalResolvesPastFallback,
617        OriginalUncompilable, ReparseFailed,
618    };
619
620    // Precondition (D5, fix-cycle M2): `candidate` must target THIS occurrence's own span.
621    if Some(candidate.range) != dep.version_range() {
622        return FallbackEditVerdict::Rejected(CandidateSpanMismatch);
623    }
624
625    // Phase 1: R0, the ORIGINAL declared requirement — no parse needed.
626    let Some(r0) = dep.version_requirement() else {
627        return FallbackEditVerdict::Rejected(OriginalUncompilable);
628    };
629    // a0-pre (issue #1580, CWE-400 defense-in-depth mirroring #1472/#1578): fail closed before
630    // `compile_bounded_requirement` ever sees an oversized requirement string.
631    let Some(r0) = BoundedVersionReq::new(r0) else {
632        return FallbackEditVerdict::Rejected(OriginalOversized);
633    };
634    let Some(r0_matcher) = formatter.compile_bounded_requirement(r0) else {
635        return FallbackEditVerdict::Rejected(OriginalUncompilable);
636    };
637
638    // Fix-cycle (issues #1564/#1561, CWE-1284 class): `fallback`'s own position is looked up
639    // ONCE here and shared by d0/d1 below — fails closed instead of letting a "no evidence"
640    // absence read as "safe" the way `available.iter().take_while(|v| *v != fallback)` did when
641    // `fallback` was never found at all (it would scan to the end without ever short-circuiting,
642    // silently changing what the scan even means). Ordered after a0 so an ecosystem with no
643    // compiled requirement model (GitHub Actions, GitLab CI) logs the more informative
644    // `OriginalUncompilable` instead of `FallbackUnlisted` — same final `Rejected` outcome either
645    // way, this only affects the `tracing::debug!` reason.
646    let Some(fallback_pos) = available.iter().position(|v| v == fallback) else {
647        return FallbackEditVerdict::Rejected(FallbackUnlisted);
648    };
649
650    if formatter.is_bounded_requirement_up_to_date(r0, fallback) {
651        return FallbackEditVerdict::Rejected(OriginalAlreadyUpToDate);
652    }
653    // d0 (issues #1564/#1561 fix, matching #1565's shipped floor-comparison exactly): a FLOOR
654    // comparison over R0's raw admitted-set membership (`compile_bounded_requirement(..).matches`), not
655    // "does anything newer also match" and NOT `bounded_requirement_already_resolves_to` — that stricter
656    // predicate is deliberately always `false` for a floor-shaped requirement (NuGet's bare
657    // `Version="1.0.0"`, see its `bounded_requirement_already_resolves_to` doc), which would make this
658    // scan find no floor at all and fail closed on every floor-type ecosystem. `available` is
659    // newest-first, so the requirement's own floor — the OLDEST entry it still admits — sits at
660    // the LARGEST matching index. Accept iff that floor is at or after `fallback`'s own index
661    // (`fallback` is not older than what R0, left unedited, already resolves to); `None`
662    // (nothing evidences the floor at all, e.g. an unlisted declared pin) fails closed the same
663    // as a floor strictly newer than `fallback`.
664    let r0_floor = available
665        .iter()
666        .rposition(|v| r0_matcher.matches(v) == Some(true));
667    if r0_floor.is_none_or(|floor| floor < fallback_pos) {
668        return FallbackEditVerdict::Rejected(OriginalResolvesPastFallback);
669    }
670    // Fix-cycle (#1571): R0 admitting `fallback` inside its nominal span yet not `fallback`
671    // itself — a `!=`-style exclusion (PyPI/Composer/Bundler) or a `||`-alternation gap
672    // (Composer) punches a hole rather than shifting the floor, so the floor comparison above
673    // alone cannot see it. Two independent signals, either one rejects. `r0_floor ==
674    // Some(fallback_pos)` (code-review perf finding) means the floor scan above already proved
675    // `fallback` matches R0 — the floor is BY DEFINITION a matching index, so no need to call
676    // `matches` on `fallback` again for that common case.
677    let fallback_matches_r0 =
678        r0_floor == Some(fallback_pos) || r0_matcher.matches(fallback) == Some(true);
679    if !fallback_matches_r0 {
680        // (i) Intensional: the matcher itself names `fallback` as explicitly banned (critic
681        // fix-cycle S1 — `r0_matcher.explicitly_excludes` is the only way to tell a `!=` hole
682        // apart from a legitimate ceiling-exceeding fallback when NO listed `available` entry
683        // happens to evidence the hole either, e.g. `>=1.0,!=1.5.0,<2.0` with `fallback` ==
684        // 1.5.0 and `available` = [2.0.0, 1.5.0, 1.0.0] — 2.0.0 fails `matches` too (it's past
685        // the `<2.0` ceiling), so (ii) below is vacuous and only this direct check catches it).
686        if r0_matcher.explicitly_excludes(fallback) {
687            return FallbackEditVerdict::Rejected(OriginalExcludesFallback);
688        }
689        // (ii) Extensional, structural fallback net: some LISTED entry strictly newer than
690        // `fallback` still matches R0 (e.g. `r0_floor` (4) >= `fallback_pos` (2) passed above
691        // even though R0 explicitly excludes `fallback`, or a `||`-alternation gap like
692        // `^1.0 || ^3.0` with `fallback` = 2.5.0). A legitimate out-of-range fallback (e.g.
693        // Cargo's `^1.0` with fallback 2.5.0) has no R0-matching entry newer than it at all —
694        // FR-025's intentional ceiling-exceeding acceptance — so this never misfires on that
695        // case.
696        #[allow(clippy::indexing_slicing)]
697        let newer_than_fallback = &available[..fallback_pos];
698        if newer_than_fallback
699            .iter()
700            .any(|v| r0_matcher.matches(v) == Some(true))
701        {
702            return FallbackEditVerdict::Rejected(OriginalExcludesFallback);
703        }
704    }
705
706    // Re-parse (FR-024): apply the candidate edit to a scratch copy and re-parse it.
707    let edited_content = crate::edit::apply_edits(content, std::slice::from_ref(candidate));
708    let Some(parsed) = reparse.reparse(&edited_content) else {
709        return FallbackEditVerdict::Rejected(ReparseFailed);
710    };
711
712    // Locate the edited occurrence by (normalized name, version_range().start) — invariant
713    // under an edit that only changes text at or after it, unlike `name_range` (round-4 critic
714    // M3: NuGet/Maven/Gradle grammars can put the version before the name).
715    let key = formatter.normalize_package_name(dep.name());
716    let orig_start = dep.version_range().map(|r| r.start);
717    let mut candidates = parsed.dependencies().into_iter().filter(|edited_dep| {
718        formatter.normalize_package_name(edited_dep.name()) == key
719            && edited_dep.version_range().map(|r| r.start) == orig_start
720    });
721    let Some(edited_dep) = candidates.next() else {
722        return FallbackEditVerdict::Rejected(OccurrenceNotUnique);
723    };
724    if candidates.next().is_some() {
725        return FallbackEditVerdict::Rejected(OccurrenceNotUnique);
726    }
727
728    // Phase 2: R1, the re-parsed EDITED requirement.
729    let Some(r1) = edited_dep.version_requirement() else {
730        return FallbackEditVerdict::Rejected(EditedUncompilable);
731    };
732    // a1-pre: same guard as a0-pre, applied to R1.
733    let Some(r1) = BoundedVersionReq::new(r1) else {
734        return FallbackEditVerdict::Rejected(EditedOversized);
735    };
736    let Some(matcher) = formatter.compile_bounded_requirement(r1) else {
737        return FallbackEditVerdict::Rejected(EditedUncompilable);
738    };
739    if matcher.matches(fallback) != Some(true) {
740        return FallbackEditVerdict::Rejected(EditedExcludesFallback);
741    }
742    // d1 (round-1 critic S1) is deliberately NOT the d0 floor comparison: an auto-following
743    // range's floor sits at `fallback` itself by construction (the edit was rendered FROM
744    // `fallback`), so a floor check alone would never see that the SAME range also admits an
745    // entry strictly newer — the auto-follow case this check exists to catch. `fallback_pos` is
746    // `available.iter().position(..)`'s own result, so it is always `<= available.len()` and the
747    // slice below (every entry strictly newer than `fallback`, since `available` is newest-first)
748    // never panics — exact, not the fragile `take_while(|v| *v != fallback)` this replaced.
749    #[allow(clippy::indexing_slicing)]
750    let strictly_newer = &available[..fallback_pos];
751    if strictly_newer
752        .iter()
753        .any(|v| formatter.bounded_requirement_already_resolves_to(r1, v))
754    {
755        return FallbackEditVerdict::Rejected(EditedAdmitsNewer);
756    }
757
758    FallbackEditVerdict::Writable
759}
760
761/// Registry version data for one package, fetched together in a single round trip.
762///
763/// `latest` and `available` are deliberately asymmetric — this is load-bearing, not an
764/// oversight:
765/// - `latest` comes from this ecosystem's own `Registry::select_latest_matching(.., "*")`
766///   pick, which excludes yanked (and, for semver/node-semver `*`, prerelease) versions —
767///   the same value `get_latest_matching` returned before this type existed.
768/// - `available` is the **unfiltered** `get_versions` output: every published version,
769///   newest-first, yanked and prerelease entries included.
770///
771/// The unsatisfiable-requirement check (see `crate::lsp_helpers::requirement_is_unsatisfiable`)
772/// scans `available` and deliberately does not filter it: a requirement that only matches a
773/// yanked or prerelease version is still satisfied, so filtering `available` the same way
774/// `latest` is filtered would produce false "no published version satisfies" warnings.
775///
776/// `yanked` is the subset of `available` (same version-string encoding) that the registry
777/// reported as yanked/deprecated, paired with each entry's [`RemovalStatus`]. It exists
778/// because `Registry::get_latest_matching` — the call that used to populate this cache —
779/// filters yanked entries out by contract on every current registry implementation, so a
780/// per-version yanked flag threaded through *that* call would always read `false` (see
781/// #233). `available` now comes from the unfiltered `get_versions` instead, which does
782/// observe yanked entries, so `yanked` is derived from that same fetch rather than
783/// discarded.
784///
785/// The status rides alongside each version (rather than a bare membership list) so
786/// [`crate::lsp_helpers::generate_diagnostics_from_cache`]'s #247 "requirement satisfiable
787/// only by a yanked version" check can gate its own package-level-deprecation suppression
788/// on `AdvisoryDeprecated` specifically, never on a genuine `Yanked` finding — mirroring
789/// [`VersionData::outcomes`]'s D5 gate for the #263 in-use-version check (see #437).
790#[non_exhaustive]
791#[derive(Debug, Clone, PartialEq, Eq)]
792pub struct PackageVersions {
793    /// Latest usable version for this package.
794    pub latest: ConcreteVersion,
795    /// Every published version, newest-first, unfiltered.
796    pub available: Arc<[ConcreteVersion]>,
797    /// Subset of `available` reported as yanked/deprecated by the registry, each paired
798    /// with its [`RemovalStatus`].
799    pub yanked: Arc<[(ConcreteVersion, RemovalStatus)]>,
800    /// When `latest` was published, if the registry exposes it. `None` when
801    /// the ecosystem doesn't wire [`crate::Version::published_at`] or the fetch
802    /// never ran.
803    ///
804    /// Deliberately a field on this single per-package struct rather than a
805    /// second parallel map keyed alongside `latest` — the earlier two-map
806    /// design (issue #227 critique C3) let `latest` and its age drift apart
807    /// silently whenever one map was updated (e.g. lockfile-resolved
808    /// overwrite) without the other. Bundling them here makes that
809    /// desync impossible: whoever sets `latest` sets `published_at` too.
810    pub published_at: Option<crate::freshness::PublishTime>,
811    /// Set when a version was excluded from being [`Self::latest`] solely because of an
812    /// active GOSSIP cooldown finding whose version was strictly newer than the dependency's
813    /// already-in-use version (`deps-cli` spec 074, FR-003/FR-005's floor-protected filter —
814    /// round 1 correction: there is no local [`CooldownWindow`]-based
815    /// exclusion this unions with, the freshness cooldown window only ever rewords a
816    /// downstream diagnostic message).
817    /// `None` in the common case (GOSSIP disabled, no matching finding, or no active
818    /// cooldown), and in four further no-op cases where nothing was actually held back: the
819    /// protect floor fully neutralized the exclusion because the flagged version was itself
820    /// already in use and still the best available (C1a); no in-use version could be
821    /// resolved at all, so no floor could be constructed and GOSSIP deliberately excludes
822    /// nothing this fetch (C1b — the common case for a range requirement with no lockfile);
823    /// a floor exists but the ecosystem's own selection rules reject every remaining
824    /// candidate above it (S1); or a floor exists and a filtered pick is found, but that
825    /// pick's original (pre-filter) position is older than the floor itself — the floor
826    /// version survived filtering yet was rejected by the ecosystem's own selection rules
827    /// (e.g. an in-use prerelease/yanked version), leaving only an even-older release to be
828    /// picked, which would be a downgrade below the floor (S4). In all four cases the
829    /// unfiltered pick is reused instead. Purely an in-process outcome-reporting field: no
830    /// wire/schema format carries it.
831    pub gossip_excluded_version: Option<ConcreteVersion>,
832    /// The newest cooldown-cleared, ecosystem-safe, floor-protected candidate found for this
833    /// dependency when `latest` may be cooldown-blocked (`deps-cli update`'s fallback, spec
834    /// 075 FR-001/FR-002). Computed unconditionally whenever freshness is enabled, regardless
835    /// of whether `latest` itself is currently blocked — see [`cooldown_disposition`] for the
836    /// read-time decision of whether it is actually needed (spec 075 NFR-002). `None` when
837    /// freshness is disabled, no lockfile-resolved in-use version exists to floor the search
838    /// (spec 075 OQ1), or no candidate passed every guard.
839    pub cooldown_fallback: Option<CooldownFallback>,
840}
841
842impl PackageVersions {
843    /// Constructs a `PackageVersions` from its two required fields, with [`Self::yanked`] and
844    /// [`Self::published_at`] left empty/`None` — chain [`Self::with_yanked`] and/or
845    /// [`Self::with_published_at`] to attach them.
846    ///
847    /// Needed because [`Self`] is `#[non_exhaustive]`: a struct literal only works inside
848    /// this crate, so every other crate (including test code) must go through this
849    /// constructor instead.
850    ///
851    /// # Examples
852    ///
853    /// ```
854    /// use deps_core::{ConcreteVersion, PackageVersions};
855    /// use std::sync::Arc;
856    ///
857    /// let versions = PackageVersions::new(
858    ///     ConcreteVersion::new("2.0.0"),
859    ///     Arc::from(vec![ConcreteVersion::new("2.0.0"), ConcreteVersion::new("1.0.0")]),
860    /// );
861    /// assert_eq!(versions.latest, "2.0.0");
862    /// assert_eq!(versions.available.len(), 2);
863    /// assert!(versions.yanked.is_empty());
864    /// ```
865    #[must_use]
866    pub fn new(latest: ConcreteVersion, available: Arc<[ConcreteVersion]>) -> Self {
867        Self {
868            latest,
869            available,
870            yanked: Arc::from(Vec::new()),
871            published_at: None,
872            gossip_excluded_version: None,
873            cooldown_fallback: None,
874        }
875    }
876
877    /// Attaches the registry's yanked/deprecated-version findings. See [`Self::yanked`].
878    #[must_use]
879    pub fn with_yanked(mut self, yanked: Arc<[(ConcreteVersion, RemovalStatus)]>) -> Self {
880        self.yanked = yanked;
881        self
882    }
883
884    /// Attaches when `latest` was published. See [`Self::published_at`].
885    #[must_use]
886    pub const fn with_published_at(mut self, published_at: crate::freshness::PublishTime) -> Self {
887        self.published_at = Some(published_at);
888        self
889    }
890
891    /// Attaches the version a GOSSIP cooldown finding excluded from being [`Self::latest`].
892    /// See [`Self::gossip_excluded_version`].
893    #[must_use]
894    pub fn with_gossip_excluded_version(mut self, excluded: ConcreteVersion) -> Self {
895        self.gossip_excluded_version = Some(excluded);
896        self
897    }
898
899    /// Attaches the computed cooldown-fallback candidate. See [`Self::cooldown_fallback`].
900    #[must_use]
901    pub fn with_cooldown_fallback(mut self, fallback: CooldownFallback) -> Self {
902        self.cooldown_fallback = Some(fallback);
903        self
904    }
905
906    /// Builds a `PackageVersions` from only the "latest" version string, with `available`
907    /// populated as the single-element list `[latest]`.
908    ///
909    /// **Test-only in intent.** The one-element `available` this produces is a real, if
910    /// small, version list — it is not "empty/unknown", so `requirement_is_unsatisfiable`
911    /// will evaluate a requirement against it. Do **not** use this for a lock-file-only
912    /// population path that has no real version list to offer (use
913    /// [`latest_without_list`](Self::latest_without_list) there instead, which leaves
914    /// `available` genuinely empty) — that exact substitution is the false-positive N5 was
915    /// written to prevent: it would let the unsatisfiable-requirement check produce a
916    /// verdict against a fabricated one-entry list before any registry fetch has run. Real
917    /// registry fetches always populate `available` from the full `get_versions` result
918    /// instead of using either constructor.
919    ///
920    /// # Examples
921    ///
922    /// ```
923    /// use deps_core::{ConcreteVersion, PackageVersions};
924    ///
925    /// let versions = PackageVersions::latest_only("1.0.214");
926    /// assert_eq!(versions.latest, "1.0.214");
927    /// assert_eq!(&*versions.available, &[ConcreteVersion::new("1.0.214")]);
928    /// ```
929    pub fn latest_only(latest: impl Into<ConcreteVersion>) -> Self {
930        let latest = latest.into();
931        let available = Arc::from(vec![latest.clone()]);
932        Self {
933            latest,
934            available,
935            yanked: Arc::from(Vec::new()),
936            published_at: None,
937            gossip_excluded_version: None,
938            cooldown_fallback: None,
939        }
940    }
941
942    /// Builds a `PackageVersions` with no version list — used where only the "latest" value
943    /// is known and probing further would be misleading, notably the lock-file population
944    /// path (`crates/deps-lsp/src/document/lifecycle.rs`), which must not populate a
945    /// plausible-looking one-element `available` list before any registry fetch has run: the
946    /// unsatisfiable-requirement check treats an empty `available` as "still loading, skip".
947    ///
948    /// # Examples
949    ///
950    /// ```
951    /// use deps_core::PackageVersions;
952    ///
953    /// let versions = PackageVersions::latest_without_list("1.0.195");
954    /// assert_eq!(versions.latest, "1.0.195");
955    /// assert!(versions.available.is_empty());
956    /// ```
957    pub fn latest_without_list(latest: impl Into<ConcreteVersion>) -> Self {
958        Self {
959            latest: latest.into(),
960            available: Arc::from(Vec::new()),
961            yanked: Arc::from(Vec::new()),
962            published_at: None,
963            gossip_excluded_version: None,
964            cooldown_fallback: None,
965        }
966    }
967}
968
969/// Everything the lifecycle fetch learned about one package, keyed by its normalized name.
970///
971/// All three channels can hold simultaneously for the same package — this is load-bearing,
972/// not an incidental shape. The D5 status gate in
973/// [`generate_diagnostics_from_cache`] reads the [`Self::deprecation`] and [`Self::yanked`]
974/// entries for the same normalized name together and compares their [`RemovalStatus`], and on
975/// the didChange path a surviving deprecation can coexist with a later fetch failure. A single
976/// enum variant per package could not express that overlap.
977#[non_exhaustive]
978#[derive(Debug, Clone, Default, PartialEq)]
979pub struct DependencyOutcome {
980    /// The version found yanked (the in-use version, or `latest` when only `latest` itself is
981    /// yanked), paired with its [`RemovalStatus`]. See [`VersionData::outcomes`]'s prior
982    /// semantics (#233/#263).
983    pub yanked: Option<(ConcreteVersion, RemovalStatus)>,
984    /// Package-level deprecation finding (#205). See [`Deprecation`].
985    pub deprecation: Option<Deprecation>,
986    /// Registry fetch errored, timed out, or was never attempted (#267). See [`FetchFailure`].
987    pub fetch_failure: Option<FetchFailure>,
988    /// The registry fetch succeeded (no [`Self::fetch_failure`]) but produced zero
989    /// comparable versions — e.g. a real GitHub repository whose only tags don't parse
990    /// as full `major.minor.patch` semver (`dtolnay/rust-toolchain`'s sole tag `v1`,
991    /// issue #550). Distinct from both an absent [`DependencyOutcome`] entry ("never
992    /// fetched") and [`Self::fetch_failure`] ("couldn't be asked"): this package
993    /// demonstrably exists, so [R5](crate::lsp_helpers::generate_diagnostics_from_cache)
994    /// must not claim it is unknown.
995    pub no_comparable_versions: bool,
996}
997
998impl DependencyOutcome {
999    /// True when none of the four channels are set.
1000    #[must_use]
1001    pub const fn is_empty(&self) -> bool {
1002        self.yanked.is_none()
1003            && self.deprecation.is_none()
1004            && self.fetch_failure.is_none()
1005            && !self.no_comparable_versions
1006    }
1007}
1008
1009/// Normalized-package-name -> [`DependencyOutcome`] map.
1010///
1011/// A newtype rather than a bare `HashMap` so the empty-entry pruning invariant (an entry is
1012/// removed once all three of its channels are cleared) lives in one place, and so test
1013/// fixtures get chainable `with_*` constructors instead of building three ad-hoc `HashMap`s.
1014/// Keyed by plain normalized name — unlike [`crate::osv::VulnerabilityMap`], which is keyed by
1015/// [`crate::osv::VulnKey`] to disambiguate multiple occurrences of one name.
1016#[derive(Debug, Clone, Default, PartialEq)]
1017pub struct DependencyOutcomes(HashMap<String, DependencyOutcome>);
1018
1019impl DependencyOutcomes {
1020    /// Creates an empty map.
1021    #[must_use]
1022    pub fn new() -> Self {
1023        Self::default()
1024    }
1025
1026    /// Looks up the full outcome recorded for `name`.
1027    #[must_use]
1028    pub fn get(&self, name: &str) -> Option<&DependencyOutcome> {
1029        self.0.get(name)
1030    }
1031
1032    /// Looks up the yanked-version finding for `name`.
1033    #[must_use]
1034    pub fn yanked(&self, name: &str) -> Option<&(ConcreteVersion, RemovalStatus)> {
1035        self.0.get(name)?.yanked.as_ref()
1036    }
1037
1038    /// Looks up the package-level deprecation finding for `name`.
1039    #[must_use]
1040    pub fn deprecation(&self, name: &str) -> Option<&Deprecation> {
1041        self.0.get(name)?.deprecation.as_ref()
1042    }
1043
1044    /// Looks up the fetch-failure finding for `name`.
1045    #[must_use]
1046    pub fn fetch_failure(&self, name: &str) -> Option<&FetchFailure> {
1047        self.0.get(name)?.fetch_failure.as_ref()
1048    }
1049
1050    /// True when the registry fetch for `name` succeeded but produced zero comparable
1051    /// versions (#550). See [`DependencyOutcome::no_comparable_versions`].
1052    #[must_use]
1053    pub fn no_comparable_versions(&self, name: &str) -> bool {
1054        self.0.get(name).is_some_and(|o| o.no_comparable_versions)
1055    }
1056
1057    /// Records a yanked-version finding for `name`, creating the entry if absent.
1058    pub fn set_yanked(&mut self, name: String, yanked: (ConcreteVersion, RemovalStatus)) {
1059        self.0.entry(name).or_default().yanked = Some(yanked);
1060    }
1061
1062    /// Records a package-level deprecation finding for `name`, creating the entry if absent.
1063    pub fn set_deprecation(&mut self, name: String, deprecation: Deprecation) {
1064        self.0.entry(name).or_default().deprecation = Some(deprecation);
1065    }
1066
1067    /// Records a fetch-failure finding for `name`, creating the entry if absent.
1068    pub fn set_fetch_failure(&mut self, name: String, failure: FetchFailure) {
1069        self.0.entry(name).or_default().fetch_failure = Some(failure);
1070    }
1071
1072    /// Records a fetch-failure finding for `name` only if one is not already recorded,
1073    /// creating the entry if absent.
1074    pub fn set_fetch_failure_if_absent(&mut self, name: String, failure: FetchFailure) {
1075        self.0
1076            .entry(name)
1077            .or_default()
1078            .fetch_failure
1079            .get_or_insert(failure);
1080    }
1081
1082    /// Records that `name`'s registry fetch succeeded but produced zero comparable
1083    /// versions (#550), creating the entry if absent.
1084    pub fn set_no_comparable_versions(&mut self, name: String) {
1085        self.0.entry(name).or_default().no_comparable_versions = true;
1086    }
1087
1088    /// Clears the no-comparable-versions channel for `name`, pruning the entry if it
1089    /// becomes empty.
1090    pub fn clear_no_comparable_versions(&mut self, name: &str) {
1091        if let Some(entry) = self.0.get_mut(name) {
1092            entry.no_comparable_versions = false;
1093            self.prune(name);
1094        }
1095    }
1096
1097    /// Clears the yanked-version channel for `name`, pruning the entry if it becomes empty.
1098    pub fn clear_yanked(&mut self, name: &str) {
1099        if let Some(entry) = self.0.get_mut(name) {
1100            entry.yanked = None;
1101            self.prune(name);
1102        }
1103    }
1104
1105    /// Clears the deprecation channel for `name`, pruning the entry if it becomes empty.
1106    pub fn clear_deprecation(&mut self, name: &str) {
1107        if let Some(entry) = self.0.get_mut(name) {
1108            entry.deprecation = None;
1109            self.prune(name);
1110        }
1111    }
1112
1113    /// Clears the fetch-failure channel for `name`, pruning the entry if it becomes empty.
1114    pub fn clear_fetch_failure(&mut self, name: &str) {
1115        if let Some(entry) = self.0.get_mut(name) {
1116            entry.fetch_failure = None;
1117            self.prune(name);
1118        }
1119    }
1120
1121    /// Removes the whole entry for `name`, regardless of which channels are set.
1122    pub fn remove(&mut self, name: &str) {
1123        self.0.remove(name);
1124    }
1125
1126    /// Clears the fetch-failure channel for every entry, pruning any that become empty.
1127    ///
1128    /// Used when a forced re-fetch (e.g. a live-reloaded registry-routing setting, deps-lsp
1129    /// issue #592) makes every previously recorded fetch-failure finding untrustworthy: the
1130    /// routing itself changed, so a failure recorded under the old routing must not survive
1131    /// to be merged with results fetched under the new one.
1132    pub fn clear_all_fetch_failures(&mut self) {
1133        let names: Vec<String> = self.0.keys().cloned().collect();
1134        for name in names {
1135            self.clear_fetch_failure(&name);
1136        }
1137    }
1138
1139    fn prune(&mut self, name: &str) {
1140        if self.0.get(name).is_some_and(DependencyOutcome::is_empty) {
1141            self.0.remove(name);
1142        }
1143    }
1144
1145    /// Number of entries currently stored.
1146    #[must_use]
1147    pub fn len(&self) -> usize {
1148        self.0.len()
1149    }
1150
1151    /// True when no entries are stored.
1152    #[must_use]
1153    pub fn is_empty(&self) -> bool {
1154        self.0.is_empty()
1155    }
1156
1157    /// Number of entries with a yanked-version finding, for logging/`Debug`.
1158    #[must_use]
1159    pub fn yanked_count(&self) -> usize {
1160        self.0.values().filter(|o| o.yanked.is_some()).count()
1161    }
1162
1163    /// Number of entries with a deprecation finding, for logging/`Debug`.
1164    #[must_use]
1165    pub fn deprecation_count(&self) -> usize {
1166        self.0.values().filter(|o| o.deprecation.is_some()).count()
1167    }
1168
1169    /// Number of entries with a fetch-failure finding, for logging/`Debug`.
1170    #[must_use]
1171    pub fn fetch_failure_count(&self) -> usize {
1172        self.0
1173            .values()
1174            .filter(|o| o.fetch_failure.is_some())
1175            .count()
1176    }
1177
1178    /// Chainable builder recording a yanked-version finding. Test/fixture ergonomics.
1179    #[must_use]
1180    pub fn with_yanked(
1181        mut self,
1182        name: impl Into<String>,
1183        yanked: (ConcreteVersion, RemovalStatus),
1184    ) -> Self {
1185        self.set_yanked(name.into(), yanked);
1186        self
1187    }
1188
1189    /// Chainable builder recording a package-level deprecation finding. Test/fixture
1190    /// ergonomics.
1191    #[must_use]
1192    pub fn with_deprecation(mut self, name: impl Into<String>, deprecation: Deprecation) -> Self {
1193        self.set_deprecation(name.into(), deprecation);
1194        self
1195    }
1196
1197    /// Chainable builder recording a fetch-failure finding. Test/fixture ergonomics.
1198    #[must_use]
1199    pub fn with_fetch_failure(mut self, name: impl Into<String>, failure: FetchFailure) -> Self {
1200        self.set_fetch_failure(name.into(), failure);
1201        self
1202    }
1203
1204    /// Chainable builder recording a no-comparable-versions finding (#550). Test/fixture
1205    /// ergonomics.
1206    #[must_use]
1207    pub fn with_no_comparable_versions(mut self, name: impl Into<String>) -> Self {
1208        self.set_no_comparable_versions(name.into());
1209        self
1210    }
1211}
1212
1213/// Bundles the two per-package version maps (`cached`, `resolved`) that LSP handlers pass
1214/// together everywhere.
1215///
1216/// Grouping them prevents accidentally swapping the two map arguments at a call site, since
1217/// the compiler can no longer typecheck them positionally.
1218///
1219/// # Examples
1220///
1221/// ```
1222/// use deps_core::{ConcreteVersion, PackageName, PackageVersions, VersionData};
1223/// use std::collections::HashMap;
1224///
1225/// let mut cached = HashMap::new();
1226/// cached.insert(PackageName::new("serde"), PackageVersions::latest_only("1.0.214"));
1227///
1228/// let mut resolved = HashMap::new();
1229/// resolved.insert(PackageName::new("serde"), ConcreteVersion::new("1.0.200"));
1230///
1231/// let versions = VersionData::new(&cached, &resolved);
1232///
1233/// assert_eq!(versions.cached.get("serde").map(|v| v.latest.as_str()), Some("1.0.214"));
1234/// assert_eq!(versions.resolved.get("serde").map(ConcreteVersion::as_str), Some("1.0.200"));
1235/// ```
1236#[non_exhaustive]
1237#[derive(Debug, Clone, Copy)]
1238pub struct VersionData<'a> {
1239    /// Latest known versions and full version lists from the registry, keyed by package name.
1240    pub cached: &'a HashMap<PackageName, PackageVersions>,
1241    /// Versions actually resolved in the lock file, keyed by package name.
1242    pub resolved: &'a HashMap<PackageName, ConcreteVersion>,
1243    /// Every lock-file-resolved version for a package name, when more than one is retained
1244    /// (issue #649) — additive alongside [`Self::resolved`], which stays the single
1245    /// collapsed value for the common case. `None` by default (most call sites have no such
1246    /// map); [`crate::lsp_helpers::resolve_in_use_version`] and [`crate::osv::vulnerability_keys`]
1247    /// consult it to disambiguate two manifest occurrences of one resolved name (e.g. a
1248    /// Cargo `package = "..."` rename pinning an older major) by each occurrence's own
1249    /// `version_requirement()`, falling back to [`Self::resolved`] when a name has at most
1250    /// one candidate.
1251    pub resolved_version_candidates: Option<&'a HashMap<PackageName, Vec<ConcreteVersion>>>,
1252    /// OSV scan results, keyed by normalized package name. `None` when no
1253    /// scan has run yet (e.g. the feature is disabled) — distinct from an
1254    /// empty map, which would mean "scanned, nothing found".
1255    pub vulnerabilities: Option<&'a VulnerabilityMap>,
1256    /// Yanked, deprecation, and fetch-failure findings from the most recent lifecycle fetch,
1257    /// keyed by normalized package name — see [`DependencyOutcome`] for what each channel
1258    /// means and why they must stay readable together off one lookup (D5 in
1259    /// [`generate_diagnostics_from_cache`], #233/#263/#205/#267). `None` when no fetch has run
1260    /// yet — distinct from an empty map, which would mean "checked, nothing found".
1261    pub outcomes: Option<&'a DependencyOutcomes>,
1262    /// This document's ecosystem, when the caller has one to give. `None` in
1263    /// most test fixtures and a handful of ecosystem-crate self-tests that
1264    /// predate this field.
1265    ///
1266    /// Enables two occurrence-aware refinements added for #394 (duplicate
1267    /// dependency names no longer collapsing into one shared finding):
1268    /// [`generate_diagnostics_from_cache`] only emits a yanked-version
1269    /// diagnostic on the occurrence whose own in-use version actually
1270    /// matches the recorded finding (S1), and the vulnerability lookups in
1271    /// `generate_diagnostics_from_cache`, [`generate_hover`], and
1272    /// `generate_code_actions` prefer a version-qualified
1273    /// [`crate::osv::VulnerabilityMap`] key over the plain name when more
1274    /// than one occurrence of a name has a distinct in-use version (S2).
1275    /// When `None`, both fall back to their pre-#394 name-only behavior.
1276    pub ecosystem: Option<EcosystemId>,
1277    /// The current [`NetworkMode`](crate::NetworkMode) (issue #483). When
1278    /// [`NetworkMode::Offline`](crate::NetworkMode::Offline), [`generate_hover`] appends a
1279    /// footer stating that version *and vulnerability* data were not checked — deliberately
1280    /// more specific than a bare "showing cached data" notice, since `hover.rs`'s
1281    /// `Some(ScanOutcome::Skipped(_)) | None` arm renders nothing for an offline OSV skip,
1282    /// which would otherwise look identical to a scanned-and-clean dependency.
1283    pub network: crate::NetworkMode,
1284    /// The deps.dev client to fetch a supply-chain trust signal through
1285    /// (spec 037), when the caller wants hover to attempt one. `None` by
1286    /// default and left `None` by every surface but `handlers/hover.rs`
1287    /// (deps-lsp) — diagnostics, code actions, inlay hints, and code lenses
1288    /// never set this, which is what makes FR-010's hover-only scope
1289    /// structural rather than convention: those surfaces cannot reach
1290    /// deps.dev because they are never handed a client. `&'a Arc<..>`, not
1291    /// `&'a DepsDevClient`, so [`generate_hover`] can clone the `Arc` into a
1292    /// detached background task.
1293    pub trust: Option<&'a Arc<DepsDevClient>>,
1294    /// Background-pre-fetched license data for ecosystems `deps_dev_system` doesn't
1295    /// cover and whose hot-path registry response carries no license field — Dart,
1296    /// Swift, Gradle, Deno (issue #660, spec 010 plan §1 tier 3), keyed by raw
1297    /// (unnormalized) package name. `None` by default. Populated from `DocumentState`'s
1298    /// per-document pre-fetch cache (`document::osv_scan::run_license_prefetch` on
1299    /// document open/change — mirrors [`Self::vulnerabilities`]'s "populated by a
1300    /// background task, read synchronously here" shape) by `handlers/hover.rs` (deps-lsp)
1301    /// for hover, and by `handlers/diagnostics.rs`'s `textDocument/diagnostic` pull path
1302    /// for [`generate_diagnostics_from_cache`]'s license-policy rule (issue #661) — see
1303    /// [`Self::license_policy`].
1304    ///
1305    /// **What version this actually reflects is per-ecosystem, not uniformly the
1306    /// resolved version** (critic S1 — corrects a previous blanket claim here that it
1307    /// "only ever covers the resolved version"): Gradle's Maven Central POM fetch and
1308    /// Deno's JSR per-version API are genuinely fetched at the dependency's resolved/
1309    /// in-use version. Dart's pub.dev `/score` endpoint is per-*package*, not
1310    /// per-version — it reflects pana's detection on whatever pub.dev most recently
1311    /// scored, unrelated to which version is resolved. Swift's GitHub
1312    /// `GET /repos/{owner}/{repo}` reflects the repository's default branch, not the
1313    /// resolved version's tag. Every source here is still a background pre-fetch gated
1314    /// on a version having been resolved at all (no in-use version means nothing to
1315    /// look up), but only Gradle/Deno are actually version-*specific* in what they
1316    /// return.
1317    pub license_prefetch: Option<&'a HashMap<PackageName, Vec<String>>>,
1318    /// How [`Self::license_prefetch`]'s (and, for a resolved-version match, the
1319    /// registry-declared) license strings are sourced (issue #688), consulted by
1320    /// [`generate_hover`] for the "(detected)" qualifier and by
1321    /// [`generate_diagnostics_from_cache`]'s license-policy rule to decide whether
1322    /// [`crate::licenses::resolve_license_entries`] must normalize free text before
1323    /// evaluation. `None` in most test fixtures and any handler that doesn't go through
1324    /// [`crate::Ecosystem::generate_hover`]/[`crate::Ecosystem::generate_diagnostics`]'s
1325    /// default implementation, which is what actually attaches this field via
1326    /// `self.license_source()` — falls back to
1327    /// [`LicenseSource::RegistryDeclaredSpdx`] (`Default`) wherever consumed.
1328    pub license_source: Option<LicenseSource>,
1329    /// SPDX allow-list/deny-list license policy (issue #661, spec 010 Phase 2), when the
1330    /// caller wants diagnostics evaluated against one. `None` by default.
1331    ///
1332    /// Unlike [`Self::trust`], this is **not** hover-only-scoped: `deps-lsp`'s
1333    /// `handlers/diagnostics.rs::generate_diagnostics_internal` attaches the currently
1334    /// configured policy (cached on `ServerState`, kept live-updated by
1335    /// `Backend::initialize`/`did_change_configuration`) unconditionally, so every
1336    /// diagnostics-generation call site — the `textDocument/diagnostic` pull path *and*
1337    /// every push-path background refresh (fetch-completion, watched-config reparse,
1338    /// lock-file change) — evaluates the same policy. An earlier revision scoped this
1339    /// field to the pull path only, following `Self::trust`'s hover-only precedent; that
1340    /// was a false analogy (issue #660/#661 critic C1) — `trust` is safe hover-only
1341    /// because hover has exactly one producer per request, but diagnostics has multiple
1342    /// producers all replacing the same client-visible `publish_diagnostics` set, so a
1343    /// caller-scoped policy meant the license diagnostic flickered in and out on every
1344    /// edit and was invisible to push-only clients.
1345    /// [`generate_diagnostics_from_cache`]'s license-policy rule reads this alongside
1346    /// [`Self::license_prefetch`] — so today it only actually fires for tier-3 ecosystems
1347    /// (Dart/Swift/Deno; Gradle is explicitly excluded, see that rule's doc comment), the
1348    /// only ones `license_prefetch` covers; this field and the rule are otherwise
1349    /// ecosystem-agnostic and need no change as `license_prefetch`'s coverage widens.
1350    pub license_policy: Option<&'a LicensePolicy>,
1351    /// Background-pre-fetched typosquat-suspect signal per declared dependency (issue
1352    /// #1437, spec 071), keyed by raw (unnormalized) package name — mirrors
1353    /// [`Self::license_prefetch`]'s exact shape and rationale (NFR-002: this must never be
1354    /// an inline `.await` on the diagnostics-generation path, so it is resolved ahead of
1355    /// time by a document-lifecycle background task,
1356    /// `deps-lsp::document::typosquat::run_typosquat_prefetch`, and merely read
1357    /// synchronously here — see that function's doc). Consumed directly inside
1358    /// [`generate_diagnostics_from_cache`] itself (not gated behind
1359    /// `Ecosystem::generate_diagnostics`'s default impl), the same way
1360    /// [`Self::license_prefetch`] is, so every ecosystem's `generate_diagnostics`
1361    /// override — not just the shared default — picks up the diagnostic automatically
1362    /// (issue #1437 security-review finding: an override that calls
1363    /// [`generate_diagnostics_from_cache`] directly, as `deps-npm`'s catalog-diagnostics
1364    /// override does, must never be able to silently bypass this). `None` when the feature
1365    /// is disabled, the ecosystem isn't deps.dev-covered, offline, or no dependency in the
1366    /// document cleared the ratio gate.
1367    pub typosquat_prefetch: Option<&'a HashMap<PackageName, TyposquatSignal>>,
1368    /// Background-pre-fetched deps.dev GOSSIP cooldown/low-usage findings per declared
1369    /// dependency (issue #1456, spec 072), keyed by raw (unnormalized) package name —
1370    /// mirrors [`Self::typosquat_prefetch`]'s exact shape and rationale: populated by
1371    /// `deps-lsp::document::gossip_prefetch`, via `DocumentState::signals.gossip_findings`, and
1372    /// merely read synchronously here — never fetched inline on the hover/diagnostics-
1373    /// generation path itself.
1374    ///
1375    /// A lookup hit is only trustworthy for the version it was resolved against
1376    /// (spec 072 FR-008) — the entry's own [`crate::GossipFindings::version`] field must
1377    /// be compared against the version actually being displayed at each call site (hover's
1378    /// `latest_line`, diagnostics' `package_versions.latest`) before use; a mismatch is
1379    /// treated as a cache miss and falls back to [`local_cooldown_precedence`]. `None`
1380    /// when the feature is disabled, offline, the ecosystem isn't deps.dev-covered, or no
1381    /// dependency in the document has a prefetch result yet.
1382    pub gossip_prefetch: Option<&'a HashMap<PackageName, GossipFindings>>,
1383    /// The deps.dev client to fetch hover's live GOSSIP low-usage signal through (issue
1384    /// #1456, spec 072 FR-005/FR-009), when the caller wants hover to attempt one. `None`
1385    /// by default, mirroring [`Self::trust`]'s exact "presence is the gate" shape (FR-010's
1386    /// structural pattern) — set only by `handlers/hover.rs` (deps-lsp) when
1387    /// `GossipConfig.enabled` is set and not offline. Deliberately a **separate** field
1388    /// from [`Self::trust`], not reused: `SupplyChainConfig.enabled` (opt-out, default
1389    /// `true`) and `GossipConfig.enabled` (opt-in, default `false`) are independently
1390    /// switchable — conflating the two fields would let disabling supply-chain trust
1391    /// signals silently disable GOSSIP's low-usage fetch too, or vice versa. `&'a Arc<..>`,
1392    /// not `&'a DepsDevClient`, for the same "clone into a detached background task"
1393    /// reason [`Self::trust`]'s doc gives.
1394    pub gossip_client: Option<&'a Arc<DepsDevClient>>,
1395    /// Phase B's per-key "latest" check result (issue #1517), keyed the same way as
1396    /// [`Self::vulnerabilities`] — see [`crate::osv::LatestStatusMap`]. `None` means OSV
1397    /// checking is disabled or offline for this scan entirely, distinct from `Some(map)` with a
1398    /// missing/`Unverified` entry for one dependency (which [`latest_verdict`] treats as
1399    /// fail-closed, not "not applicable"). Every renderer that surfaces a dependency's `latest`
1400    /// as an upgrade recommendation (hover, diagnostics, code actions, code lens, inlay hints,
1401    /// completion) must consult [`latest_verdict`] with this field before treating that `latest`
1402    /// as safe.
1403    pub latest_status: Option<&'a LatestStatusMap>,
1404    /// Phase B's per-(dependency, candidate-version) OSV check result (#1524) — see
1405    /// [`crate::osv::CandidateStatusMap`]. `None` means OSV checking is disabled or offline for
1406    /// this scan entirely, mirroring [`Self::latest_status`]'s identical distinction. A
1407    /// candidate-offering surface (code actions' "update to X" list, completion's version
1408    /// items) must consult [`candidate_verdict`] with this field for every version it is about
1409    /// to offer, not only the one [`Self::latest_status`] already covers.
1410    pub candidate_status: Option<&'a CandidateStatusMap>,
1411}
1412
1413impl<'a> VersionData<'a> {
1414    /// Creates a new `VersionData` from the cached and resolved version maps.
1415    ///
1416    /// `vulnerabilities` starts `None`; chain [`Self::with_vulnerabilities`]
1417    /// to attach a scan result.
1418    ///
1419    /// # Examples
1420    ///
1421    /// ```
1422    /// use deps_core::VersionData;
1423    /// use std::collections::HashMap;
1424    ///
1425    /// let cached = HashMap::new();
1426    /// let resolved = HashMap::new();
1427    /// let versions = VersionData::new(&cached, &resolved);
1428    /// assert!(versions.cached.is_empty());
1429    /// assert!(versions.vulnerabilities.is_none());
1430    /// ```
1431    pub fn new(
1432        cached: &'a HashMap<PackageName, PackageVersions>,
1433        resolved: &'a HashMap<PackageName, ConcreteVersion>,
1434    ) -> Self {
1435        Self {
1436            cached,
1437            resolved,
1438            resolved_version_candidates: None,
1439            vulnerabilities: None,
1440            outcomes: None,
1441            ecosystem: None,
1442            network: crate::NetworkMode::Online,
1443            trust: None,
1444            license_prefetch: None,
1445            license_source: None,
1446            license_policy: None,
1447            typosquat_prefetch: None,
1448            gossip_prefetch: None,
1449            gossip_client: None,
1450            latest_status: None,
1451            candidate_status: None,
1452        }
1453    }
1454
1455    /// Attaches the per-name lock-file candidates map, enabling the per-occurrence
1456    /// disambiguation described on [`Self::resolved_version_candidates`].
1457    ///
1458    /// # Examples
1459    ///
1460    /// ```
1461    /// use deps_core::VersionData;
1462    /// use std::collections::HashMap;
1463    ///
1464    /// let cached = HashMap::new();
1465    /// let resolved = HashMap::new();
1466    /// let candidates = HashMap::new();
1467    /// let versions =
1468    ///     VersionData::new(&cached, &resolved).with_resolved_version_candidates(&candidates);
1469    /// assert!(versions.resolved_version_candidates.is_some());
1470    /// ```
1471    #[must_use]
1472    pub const fn with_resolved_version_candidates(
1473        mut self,
1474        candidates: &'a HashMap<PackageName, Vec<ConcreteVersion>>,
1475    ) -> Self {
1476        self.resolved_version_candidates = Some(candidates);
1477        self
1478    }
1479
1480    /// Attaches an OSV scan result to this `VersionData`.
1481    ///
1482    /// # Examples
1483    ///
1484    /// ```
1485    /// use deps_core::VersionData;
1486    /// use deps_core::osv::VulnerabilityMap;
1487    /// use std::collections::HashMap;
1488    ///
1489    /// let cached = HashMap::new();
1490    /// let resolved = HashMap::new();
1491    /// let vulns = VulnerabilityMap::new();
1492    /// let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulns);
1493    /// assert!(versions.vulnerabilities.is_some());
1494    /// ```
1495    #[must_use]
1496    pub fn with_vulnerabilities(mut self, vulnerabilities: &'a VulnerabilityMap) -> Self {
1497        self.vulnerabilities = Some(vulnerabilities);
1498        self
1499    }
1500
1501    /// Attaches phase B's per-key "latest" check result to this `VersionData`. See
1502    /// [`Self::latest_status`].
1503    ///
1504    /// # Examples
1505    ///
1506    /// ```
1507    /// use deps_core::VersionData;
1508    /// use deps_core::osv::LatestStatusMap;
1509    /// use std::collections::HashMap;
1510    ///
1511    /// let cached = HashMap::new();
1512    /// let resolved = HashMap::new();
1513    /// let latest_status = LatestStatusMap::new();
1514    /// let versions = VersionData::new(&cached, &resolved).with_latest_status(&latest_status);
1515    /// assert!(versions.latest_status.is_some());
1516    /// ```
1517    #[must_use]
1518    pub fn with_latest_status(mut self, latest_status: &'a LatestStatusMap) -> Self {
1519        self.latest_status = Some(latest_status);
1520        self
1521    }
1522
1523    /// Attaches phase B's per-candidate-version check result to this `VersionData`. See
1524    /// [`Self::candidate_status`].
1525    ///
1526    /// # Examples
1527    ///
1528    /// ```
1529    /// use deps_core::VersionData;
1530    /// use deps_core::osv::CandidateStatusMap;
1531    /// use std::collections::HashMap;
1532    ///
1533    /// let cached = HashMap::new();
1534    /// let resolved = HashMap::new();
1535    /// let candidate_status = CandidateStatusMap::new();
1536    /// let versions =
1537    ///     VersionData::new(&cached, &resolved).with_candidate_status(&candidate_status);
1538    /// assert!(versions.candidate_status.is_some());
1539    /// ```
1540    #[must_use]
1541    pub fn with_candidate_status(mut self, candidate_status: &'a CandidateStatusMap) -> Self {
1542        self.candidate_status = Some(candidate_status);
1543        self
1544    }
1545
1546    /// Attaches yanked, deprecation, and fetch-failure findings to this `VersionData`. See
1547    /// [`Self::outcomes`].
1548    ///
1549    /// # Examples
1550    ///
1551    /// ```
1552    /// use deps_core::VersionData;
1553    /// use deps_core::lsp_helpers::DependencyOutcomes;
1554    /// use std::collections::HashMap;
1555    ///
1556    /// let cached = HashMap::new();
1557    /// let resolved = HashMap::new();
1558    /// let outcomes = DependencyOutcomes::new();
1559    /// let versions = VersionData::new(&cached, &resolved).with_outcomes(&outcomes);
1560    /// assert!(versions.outcomes.is_some());
1561    /// ```
1562    #[must_use]
1563    pub fn with_outcomes(mut self, outcomes: &'a DependencyOutcomes) -> Self {
1564        self.outcomes = Some(outcomes);
1565        self
1566    }
1567
1568    /// Attaches this document's ecosystem, enabling the occurrence-aware
1569    /// refinements described on [`Self::ecosystem`].
1570    ///
1571    /// # Examples
1572    ///
1573    /// ```
1574    /// use deps_core::{EcosystemId, VersionData};
1575    /// use std::collections::HashMap;
1576    ///
1577    /// let cached = HashMap::new();
1578    /// let resolved = HashMap::new();
1579    /// let versions = VersionData::new(&cached, &resolved).with_ecosystem(EcosystemId::Cargo);
1580    /// assert_eq!(versions.ecosystem, Some(EcosystemId::Cargo));
1581    /// ```
1582    #[must_use]
1583    pub const fn with_ecosystem(mut self, ecosystem: EcosystemId) -> Self {
1584        self.ecosystem = Some(ecosystem);
1585        self
1586    }
1587
1588    /// Marks this `VersionData` as built while `network.offline` was set, so
1589    /// [`generate_hover`] appends its offline footer.
1590    ///
1591    /// # Examples
1592    ///
1593    /// ```
1594    /// use deps_core::VersionData;
1595    /// use std::collections::HashMap;
1596    ///
1597    /// let cached = HashMap::new();
1598    /// let resolved = HashMap::new();
1599    /// let versions = VersionData::new(&cached, &resolved).with_network(deps_core::NetworkMode::Offline);
1600    /// assert_eq!(versions.network, deps_core::NetworkMode::Offline);
1601    /// ```
1602    #[must_use]
1603    pub const fn with_network(mut self, network: crate::NetworkMode) -> Self {
1604        self.network = network;
1605        self
1606    }
1607
1608    /// Attaches a deps.dev client, enabling [`generate_hover`] to attempt a
1609    /// supply-chain trust signal for the hovered dependency. See
1610    /// [`Self::trust`].
1611    ///
1612    /// # Examples
1613    ///
1614    /// ```
1615    /// use deps_core::{DepsDevClient, HttpCache, VersionData};
1616    /// use std::collections::HashMap;
1617    /// use std::sync::Arc;
1618    ///
1619    /// let cached = HashMap::new();
1620    /// let resolved = HashMap::new();
1621    /// let client = Arc::new(DepsDevClient::new(Arc::new(HttpCache::new())));
1622    /// let versions = VersionData::new(&cached, &resolved).with_trust(&client);
1623    /// assert!(versions.trust.is_some());
1624    /// ```
1625    #[must_use]
1626    pub const fn with_trust(mut self, client: &'a Arc<DepsDevClient>) -> Self {
1627        self.trust = Some(client);
1628        self
1629    }
1630
1631    /// Attaches tier-3 pre-fetched license data. See [`Self::license_prefetch`].
1632    ///
1633    /// # Examples
1634    ///
1635    /// ```
1636    /// use deps_core::VersionData;
1637    /// use std::collections::HashMap;
1638    ///
1639    /// let cached = HashMap::new();
1640    /// let resolved = HashMap::new();
1641    /// let licenses = HashMap::new();
1642    /// let versions = VersionData::new(&cached, &resolved).with_license_prefetch(&licenses);
1643    /// assert!(versions.license_prefetch.is_some());
1644    /// ```
1645    #[must_use]
1646    pub const fn with_license_prefetch(
1647        mut self,
1648        licenses: &'a HashMap<PackageName, Vec<String>>,
1649    ) -> Self {
1650        self.license_prefetch = Some(licenses);
1651        self
1652    }
1653
1654    /// Attaches this ecosystem's license source. See [`Self::license_source`].
1655    ///
1656    /// # Examples
1657    ///
1658    /// ```
1659    /// use deps_core::{LicenseSource, VersionData};
1660    /// use std::collections::HashMap;
1661    ///
1662    /// let cached = HashMap::new();
1663    /// let resolved = HashMap::new();
1664    /// let versions =
1665    ///     VersionData::new(&cached, &resolved).with_license_source(LicenseSource::PomFreeText);
1666    /// assert_eq!(versions.license_source, Some(LicenseSource::PomFreeText));
1667    /// ```
1668    #[must_use]
1669    pub const fn with_license_source(mut self, source: LicenseSource) -> Self {
1670        self.license_source = Some(source);
1671        self
1672    }
1673
1674    /// Attaches a license policy for diagnostics evaluation. See [`Self::license_policy`].
1675    ///
1676    /// # Examples
1677    ///
1678    /// ```
1679    /// use deps_core::{LicensePolicy, VersionData};
1680    /// use std::collections::HashMap;
1681    ///
1682    /// let cached = HashMap::new();
1683    /// let resolved = HashMap::new();
1684    /// let policy = LicensePolicy::new(vec!["MIT".to_string()], vec![]);
1685    /// let versions = VersionData::new(&cached, &resolved).with_license_policy(&policy);
1686    /// assert!(versions.license_policy.is_some());
1687    /// ```
1688    #[must_use]
1689    pub const fn with_license_policy(mut self, policy: &'a LicensePolicy) -> Self {
1690        self.license_policy = Some(policy);
1691        self
1692    }
1693
1694    /// Attaches background-pre-fetched typosquat signals. See [`Self::typosquat_prefetch`].
1695    ///
1696    /// # Examples
1697    ///
1698    /// ```
1699    /// use deps_core::VersionData;
1700    /// use std::collections::HashMap;
1701    ///
1702    /// let cached = HashMap::new();
1703    /// let resolved = HashMap::new();
1704    /// let typosquat = HashMap::new();
1705    /// let versions = VersionData::new(&cached, &resolved).with_typosquat_prefetch(&typosquat);
1706    /// assert!(versions.typosquat_prefetch.is_some());
1707    /// ```
1708    #[must_use]
1709    pub const fn with_typosquat_prefetch(
1710        mut self,
1711        typosquat_prefetch: &'a HashMap<PackageName, TyposquatSignal>,
1712    ) -> Self {
1713        self.typosquat_prefetch = Some(typosquat_prefetch);
1714        self
1715    }
1716
1717    /// Attaches background-pre-fetched GOSSIP findings. See [`Self::gossip_prefetch`].
1718    ///
1719    /// # Examples
1720    ///
1721    /// ```
1722    /// use deps_core::VersionData;
1723    /// use std::collections::HashMap;
1724    ///
1725    /// let cached = HashMap::new();
1726    /// let resolved = HashMap::new();
1727    /// let gossip = HashMap::new();
1728    /// let versions = VersionData::new(&cached, &resolved).with_gossip_prefetch(&gossip);
1729    /// assert!(versions.gossip_prefetch.is_some());
1730    /// ```
1731    #[must_use]
1732    pub const fn with_gossip_prefetch(
1733        mut self,
1734        gossip_prefetch: &'a HashMap<PackageName, GossipFindings>,
1735    ) -> Self {
1736        self.gossip_prefetch = Some(gossip_prefetch);
1737        self
1738    }
1739
1740    /// Attaches a deps.dev client, enabling hover to attempt a live GOSSIP low-usage fetch.
1741    /// See [`Self::gossip_client`].
1742    ///
1743    /// # Examples
1744    ///
1745    /// ```
1746    /// use deps_core::{DepsDevClient, HttpCache, VersionData};
1747    /// use std::collections::HashMap;
1748    /// use std::sync::Arc;
1749    ///
1750    /// let cached = HashMap::new();
1751    /// let resolved = HashMap::new();
1752    /// let client = Arc::new(DepsDevClient::new(Arc::new(HttpCache::new())));
1753    /// let versions = VersionData::new(&cached, &resolved).with_gossip_client(&client);
1754    /// assert!(versions.gossip_client.is_some());
1755    /// ```
1756    #[must_use]
1757    pub const fn with_gossip_client(mut self, client: &'a Arc<DepsDevClient>) -> Self {
1758        self.gossip_client = Some(client);
1759        self
1760    }
1761}
1762
1763/// The three states [`gossip_cooldown_for`] can resolve to — deliberately distinct from a
1764/// plain `Option<&GossipCooldown>` (issue #1456 security/impl-critic review, S2).
1765///
1766/// A bare `Option` conflates "no GOSSIP data available for this version at all" with "GOSSIP
1767/// has data and it authoritatively says this version is not in cooldown", and both used to
1768/// fall through to the unattributed local heuristic — which can then show a cooldown callout
1769/// that directly contradicts what GOSSIP already knows, violating FR-002's "authoritative when
1770/// available" requirement.
1771///
1772/// Spec 075 FR-005 (R-S3) redefined [`Self::NotActive`] to require a parsed, past `end` —
1773/// a missing COOLDOWN finding is [`Self::Unavailable`], not [`Self::NotActive`]. The earlier
1774/// definition conflated "no data" with "confirmed clear", which was fail-open for a write
1775/// path (`deps-cli update`'s cooldown-fallback candidate, spec 075).
1776#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1777pub enum GossipCooldownLookup {
1778    /// No GOSSIP data for this exact version — an absent entry, an FR-008 version mismatch,
1779    /// or a COOLDOWN finding with no cooldown at all (`findings.cooldown == None`, including
1780    /// a missing/unparseable `end`, spec 075 FR-005) — the caller falls back to
1781    /// [`local_cooldown_precedence`], unattributed.
1782    Unavailable,
1783    /// GOSSIP data is present for this exact version and confirms an active cooldown as of
1784    /// `now` (spec 072 FR-011's read-time `end > now` check) — the caller renders a
1785    /// GOSSIP-attributed callout. Carries no payload: no caller renders the cooldown's own
1786    /// `end`/`risk` today, only whether one is active — see [`gossip_cooldown_for`] for
1787    /// where that data is still available (`GossipFindings.cooldown`) if a future caller
1788    /// needs it.
1789    Active,
1790    /// GOSSIP data is present for this exact version and authoritatively reports a cooldown
1791    /// that has already ended (a parsed, past `end`) — the caller must render nothing here,
1792    /// never falling back to the local heuristic, which could contradict this answer.
1793    NotActive,
1794}
1795
1796/// Looks up the GOSSIP cooldown state for `name` in `gossip_prefetch`, applying spec 072
1797/// FR-008's version-equality gate against `comparand` and FR-011's read-time `end > now` check.
1798///
1799/// `comparand` is the version actually being displayed at this call site — hover's
1800/// `latest_line`, diagnostics' `package_versions.latest` — shared by
1801/// `hover::push_latest_hover_section`, `diagnostics::apply_outdated_rule`,
1802/// [`crate::lsp_helpers::cooldown_disposition`], and `deps-engine`'s fallback-candidate
1803/// computation (spec 075) so no call site can diverge on this gate.
1804///
1805/// See [`GossipCooldownLookup`]'s doc for why this returns a three-state enum rather than
1806/// `Option<&GossipCooldown>`.
1807///
1808/// # Examples
1809///
1810/// ```
1811/// use deps_core::PackageName;
1812/// use deps_core::deps_dev::{GossipCooldown, GossipRiskLevel};
1813/// use deps_core::lsp_helpers::{GossipCooldownLookup, gossip_cooldown_for};
1814/// use deps_core::test_util::stub_gossip_findings;
1815/// use deps_core::PublishTime;
1816/// use std::collections::HashMap;
1817///
1818/// let name = PackageName::new("left-pad");
1819/// let mut prefetch = HashMap::new();
1820/// prefetch.insert(
1821///     name.clone(),
1822///     stub_gossip_findings(
1823///         "1.3.0",
1824///         Some(GossipCooldown::new(
1825///             PublishTime::from_unix_secs(2_000),
1826///             GossipRiskLevel::High,
1827///         )),
1828///     ),
1829/// );
1830///
1831/// assert_eq!(
1832///     gossip_cooldown_for(Some(&prefetch), &name, "1.3.0", PublishTime::from_unix_secs(1_000)),
1833///     GossipCooldownLookup::Active
1834/// );
1835/// assert_eq!(
1836///     gossip_cooldown_for(Some(&prefetch), &name, "1.3.0", PublishTime::from_unix_secs(3_000)),
1837///     GossipCooldownLookup::NotActive
1838/// );
1839/// assert_eq!(
1840///     gossip_cooldown_for(None, &name, "1.3.0", PublishTime::now()),
1841///     GossipCooldownLookup::Unavailable
1842/// );
1843/// ```
1844#[must_use]
1845pub fn gossip_cooldown_for(
1846    gossip_prefetch: Option<&HashMap<PackageName, GossipFindings>>,
1847    name: &PackageName,
1848    comparand: &str,
1849    now: PublishTime,
1850) -> GossipCooldownLookup {
1851    let Some(findings) = gossip_prefetch.and_then(|m| m.get(name)) else {
1852        return GossipCooldownLookup::Unavailable;
1853    };
1854    if findings.version != comparand {
1855        return GossipCooldownLookup::Unavailable;
1856    }
1857    match findings.cooldown.as_ref() {
1858        Some(cooldown) if cooldown.is_active(now) => GossipCooldownLookup::Active,
1859        Some(_) => GossipCooldownLookup::NotActive,
1860        None => GossipCooldownLookup::Unavailable,
1861    }
1862}
1863
1864/// Wall-clock budget `deps-lsp`'s completion handler gives an ecosystem's
1865/// `generate_completions` before treating it as a timeout.
1866///
1867/// Past this, the handler skips the fallback search rather than treating a
1868/// fast-but-empty result as "genuinely no results"
1869/// (`crates/deps-lsp/src/handlers/completion.rs`).
1870///
1871/// A registry-backed completion path that retries internally on failure (e.g.
1872/// `deps-maven`'s `search`, #274) must size its own total retry budget to
1873/// exceed this constant: finishing sooner with an empty/error result is
1874/// indistinguishable, at the call site, from a query that legitimately has no
1875/// matches, and triggers a wasted (and, for a struggling registry, likely to also
1876/// fail) fallback search rather than the handler's existing skip-on-timeout path.
1877///
1878/// Lives here (ungated), not in [`crate::completion`] (`#[cfg(feature =
1879/// "lsp-responses")]`), and is re-exported from there for that feature's consumers:
1880/// a registry-backed completion path's own retry-budget constant (e.g. `deps-maven`'s
1881/// `RECENT_FAILURE_TTL`) is genuine `Registry::search` behavior, not LSP-response-shaped,
1882/// and must stay available under `--no-default-features` too (issue #1083 critic M1) —
1883/// one definition, not a hand-copied duplicate per feature state.
1884pub const COMPLETION_SEARCH_TIMEOUT: Duration = Duration::from_secs(2);
1885
1886/// Wall-clock budget hover and code-action generation give the primary
1887/// `Registry::get_versions_from` fetch for the dependency under the cursor.
1888///
1889/// Unlike completion, code lens, inlay hints, and the background fetch task, hover
1890/// and code actions previously awaited this call with no deadline at all — an
1891/// ecosystem whose registry client retries across several candidate URLs
1892/// sequentially (e.g. `deps-maven`'s Gradle Plugin Portal fallback) could multiply
1893/// `HttpCache`'s own per-request timeout into tens of seconds of blocked, user-facing
1894/// latency on a single hover or lightbulb request (issue #1204). This is the *only*
1895/// deadline needed to fix that: it wraps the whole `get_versions_from` future,
1896/// cancelling it — candidate-URL loop included — at the ecosystem-registry level, so
1897/// no per-ecosystem inner budget is required on top of it. On elapse, hover falls back
1898/// to its existing `.ok()`-based basic-card degradation (the same path a genuine fetch
1899/// error takes); code actions instead drop the speculative fix/unsat actions outright
1900/// (see `await_versions_fetch`'s `timed_out` flag) rather than reusing that same
1901/// fail-open path, since an unverified yank check must not be treated as "not yanked".
1902pub const REGISTRY_FETCH_BUDGET: Duration = Duration::from_secs(10);
1903
1904/// Awaits `fetch` bounded by [`REGISTRY_FETCH_BUDGET`], logging a `tracing::warn!` naming
1905/// `package` and `context` if the budget elapses first.
1906///
1907/// Shared by hover and code-action generation to avoid duplicating this
1908/// timeout/log/degrade shape at both call sites. Returns `(None, false)` for a
1909/// fetch-level error exactly like the pre-#1204 `.ok()` degrade, and `(None, true)` on
1910/// timeout — the `bool` lets a caller (code actions) tell the two apart when a fetch
1911/// failure and a fetch timeout must not be treated the same way (impl-critic S1: a
1912/// timeout is not a verified "not yanked" answer, so it must not fail open the way a
1913/// genuine registry outage does).
1914#[cfg(feature = "lsp-responses")]
1915async fn await_versions_fetch<T, E>(
1916    fetch: impl std::future::Future<Output = Result<T, E>>,
1917    package: &PackageName,
1918    context: &'static str,
1919) -> (Option<T>, bool) {
1920    match tokio::time::timeout(REGISTRY_FETCH_BUDGET, fetch).await {
1921        Ok(result) => (result.ok(), false),
1922        Err(_) => {
1923            tracing::warn!(
1924                package = %package.for_tracing(),
1925                context,
1926                timeout_secs = REGISTRY_FETCH_BUDGET.as_secs(),
1927                "primary registry version fetch timed out"
1928            );
1929            (None, true)
1930        }
1931    }
1932}
1933
1934/// Converts UTF-16 offset to byte offset in a string.
1935///
1936/// LSP uses UTF-16 code units for character positions (for compatibility with
1937/// JavaScript and other languages). This function converts from UTF-16 offset
1938/// to byte offset for Rust string indexing.
1939///
1940/// # Arguments
1941///
1942/// * `s` - The string to index into
1943/// * `utf16_offset` - UTF-16 code unit offset (from LSP Position.character)
1944///
1945/// # Returns
1946///
1947/// Byte offset if valid, `None` if the UTF-16 offset is out of bounds.
1948///
1949/// # Examples
1950///
1951/// ```
1952/// # use deps_core::lsp_helpers::utf16_to_byte_offset;
1953/// // ASCII: UTF-16 offset equals byte offset
1954/// assert_eq!(utf16_to_byte_offset("hello", 2), Some(2));
1955///
1956/// // Unicode: "日本語" - each char is 3 bytes but 1 UTF-16 code unit
1957/// assert_eq!(utf16_to_byte_offset("日本語", 0), Some(0));
1958/// assert_eq!(utf16_to_byte_offset("日本語", 1), Some(3));
1959/// assert_eq!(utf16_to_byte_offset("日本語", 2), Some(6));
1960///
1961/// // Emoji: "😀" is 4 bytes but 2 UTF-16 code units (surrogate pair)
1962/// assert_eq!(utf16_to_byte_offset("😀test", 2), Some(4));
1963/// ```
1964pub fn utf16_to_byte_offset(s: &str, utf16_offset: u32) -> Option<usize> {
1965    let mut utf16_count = 0u32;
1966    for (byte_idx, ch) in s.char_indices() {
1967        if utf16_count >= utf16_offset {
1968            return Some(byte_idx);
1969        }
1970        #[expect(
1971            clippy::cast_possible_truncation,
1972            reason = "char::len_utf16 always returns 1 or 2, so this cast never truncates"
1973        )]
1974        {
1975            utf16_count += ch.len_utf16() as u32;
1976        }
1977    }
1978    if utf16_count == utf16_offset {
1979        return Some(s.len());
1980    }
1981    None
1982}
1983
1984/// Converts a byte offset within `s` to a UTF-16 code unit offset (LSP `Position.character`).
1985///
1986/// `byte_offset` may be an arbitrary caller-supplied value: it is clamped to `s.len()` and
1987/// floored down to the nearest UTF-8 char boundary before use, so this never panics.
1988///
1989/// # Examples
1990///
1991/// ```
1992/// # use deps_core::lsp_helpers::byte_to_utf16_offset;
1993/// // ASCII: byte offset equals UTF-16 offset
1994/// assert_eq!(byte_to_utf16_offset("hello", 2), 2);
1995///
1996/// // Unicode: "日本語" - each char is 3 bytes but 1 UTF-16 code unit
1997/// assert_eq!(byte_to_utf16_offset("日本語", 0), 0);
1998/// assert_eq!(byte_to_utf16_offset("日本語", 3), 1);
1999/// assert_eq!(byte_to_utf16_offset("日本語", 6), 2);
2000///
2001/// // Emoji: "😀" is 4 bytes but 2 UTF-16 code units (surrogate pair)
2002/// assert_eq!(byte_to_utf16_offset("😀test", 4), 2);
2003///
2004/// // Never panics: an offset landing mid-character floors down to the start of that
2005/// // character, and an offset past the end saturates to the string's length.
2006/// assert_eq!(byte_to_utf16_offset("日本語", 1), 0); // inside the first character
2007/// assert_eq!(byte_to_utf16_offset("日本語", 999), 3); // past the end
2008/// ```
2009#[expect(
2010    clippy::string_slice,
2011    reason = "end is floor_char_boundary-clamped just above, mirroring the already-hardened \
2012              LineOffsetTable::byte_offset_to_position (lsp_helpers/mod.rs)"
2013)]
2014pub fn byte_to_utf16_offset(s: &str, byte_offset: usize) -> u32 {
2015    // Saturate rather than silently wrap: an LSP `Position.character` past `u32::MAX` UTF-16
2016    // units is already meaningless, but a wrapped value would be a wrong-but-plausible one
2017    // (#673 — the exact offset-math bug class #244 shipped).
2018    //
2019    // `byte_offset` is not guaranteed to be in bounds or on a char boundary (a caller-supplied
2020    // offset can be past `s.len()` or land mid-character); `floor_char_boundary` clamps both
2021    // — past-the-end saturates to `s.len()` and any other in-bounds index floors to the
2022    // nearest char boundary — mirroring `LineOffsetTable::byte_offset_to_position`
2023    // (lsp_helpers/mod.rs).
2024    let end = s.floor_char_boundary(byte_offset);
2025    u32::try_from(s[..end].encode_utf16().count()).unwrap_or(u32::MAX)
2026}
2027
2028/// Checks whether a cursor position falls within an LSP range (inclusive on both ends).
2029pub fn position_in_range(pos: Position, range: Range) -> bool {
2030    if pos.line < range.start.line || pos.line > range.end.line {
2031        return false;
2032    }
2033    if pos.line == range.start.line && pos.character < range.start.character {
2034        return false;
2035    }
2036    if pos.line == range.end.line && pos.character > range.end.character {
2037        return false;
2038    }
2039    true
2040}
2041
2042/// Whether `dep`'s `version_range()` is a degenerate, completion-only position with no
2043/// requirement behind it (e.g. Maven's `<version></version>`/self-closing `<version/>`) rather
2044/// than a real, non-empty range that happens to have no requirement (e.g. Gradle's
2045/// `version.ref` pointing at a dangling or rich-version alias, which spans the real
2046/// alias-reference text). Zero-width (`start == end`) is the discriminating signal —
2047/// `version_requirement().is_none()` alone is not, since both shapes share it.
2048pub(crate) fn version_range_is_synthetic_empty(dep: &dyn Dependency) -> bool {
2049    dep.version_requirement().is_none() && dep.version_range().is_some_and(|r| r.start == r.end)
2050}
2051
2052/// Resolves the [`ScanOutcome`] for one dependency occurrence.
2053///
2054/// Tries `keys`' version-qualified lookup key for `dep` first (from
2055/// `crate::osv::vulnerability_keys`, #394 S2 — distinguishes two occurrences of one name
2056/// pinned to different versions), then the ecosystem-normalized name, then `dep`'s declared
2057/// name.
2058///
2059/// The single shared fallback chain for every OSV-outcome consumer
2060/// (`diagnostics::apply_vulnerability_rule`, `diagnostics::skip_reason_notice`,
2061/// `hover::generate_hover`'s vulnerability section) — previously written out four times
2062/// independently (issue #1400), risking a future change to the fallback priority landing in
2063/// only some of them. `keys` is `Option` because a caller with no [`EcosystemId`] to give
2064/// `crate::osv::vulnerability_keys` (most test
2065/// fixtures) has none to pass. `normalized_name` stays a caller-supplied parameter rather than
2066/// being derived here: `diagnostics` precomputes it once per name range and shares it across
2067/// both this lookup and its own per-dependency loop, so `normalize_package_name` (an
2068/// allocating call) never runs twice for the same dependency.
2069///
2070/// # Examples
2071///
2072/// ```
2073/// use deps_core::lsp_helpers::{
2074///     DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
2075///     RequirementResolution, SourcePolicy, resolve_scan_outcome,
2076/// };
2077/// use deps_core::osv::{ScanOutcome, VulnerabilityMap};
2078/// use deps_core::position::{Position, Range};
2079/// use deps_core::{ConcreteVersion, Dependency, PackageName, VersionReq};
2080/// use std::any::Any;
2081///
2082/// struct SimpleDep {
2083///     name: PackageName,
2084///     name_range: Range,
2085/// }
2086///
2087/// impl Dependency for SimpleDep {
2088///     fn name(&self) -> &PackageName {
2089///         &self.name
2090///     }
2091///     fn name_range(&self) -> Range {
2092///         self.name_range
2093///     }
2094///     fn version_requirement(&self) -> Option<&VersionReq> {
2095///         None
2096///     }
2097///     fn version_range(&self) -> Option<Range> {
2098///         None
2099///     }
2100///     fn source(&self) -> deps_core::parser::DependencySource {
2101///         deps_core::parser::DependencySource::Registry
2102///     }
2103///     fn as_any(&self) -> &dyn Any {
2104///         self
2105///     }
2106/// }
2107///
2108/// let dep = SimpleDep {
2109///     name: PackageName::new("time"),
2110///     name_range: Range::new(Position::new(0, 0), Position::new(0, 4)).into(),
2111/// };
2112///
2113/// let mut vulnerabilities = VulnerabilityMap::new();
2114/// vulnerabilities.insert(deps_core::test_util::vuln_key("time"), ScanOutcome::Clean);
2115///
2116/// // No `VulnKeys` map: falls back straight to the normalized name.
2117/// let outcome = resolve_scan_outcome(&vulnerabilities, &dep, None, "time");
2118/// assert!(matches!(outcome, Some(ScanOutcome::Clean)));
2119/// ```
2120pub fn resolve_scan_outcome<'a>(
2121    vulnerabilities: &'a VulnerabilityMap,
2122    dep: &dyn Dependency,
2123    keys: Option<&crate::osv::VulnKeys>,
2124    normalized_name: &str,
2125) -> Option<&'a ScanOutcome> {
2126    resolve_by_vuln_key(vulnerabilities, dep, keys, normalized_name)
2127}
2128
2129/// Shared per-occurrence [`VulnKey`] lookup chain (version-qualified key, then normalized name,
2130/// then declared name) underlying both [`resolve_scan_outcome`] and [`resolve_latest_status`] —
2131/// the two maps [`VulnerabilityMap`] and [`crate::osv::LatestStatusMap`] share this exact key
2132/// space (#1517), so the fallback chain only needs writing once.
2133fn resolve_by_vuln_key<'a, V>(
2134    map: &'a HashMap<VulnKey, V>,
2135    dep: &dyn Dependency,
2136    keys: Option<&crate::osv::VulnKeys>,
2137    normalized_name: &str,
2138) -> Option<&'a V> {
2139    keys.and_then(|k| k.get(&dep.name_range()))
2140        .and_then(|key| map.get(key))
2141        .or_else(|| map.get(&VulnKey::from_name(normalized_name.to_string())))
2142        .or_else(|| map.get(&VulnKey::from_name(dep.name().as_str().to_string())))
2143}
2144
2145/// Looks up `dep`'s entry in phase B's per-key "latest" status map, using the same
2146/// version-qualified-key-then-normalized-name-then-declared-name fallback chain as
2147/// [`resolve_scan_outcome`] (#1517).
2148///
2149/// # Examples
2150///
2151/// ```
2152/// use deps_core::lsp_helpers::resolve_latest_status;
2153/// use deps_core::osv::{LatestStatusMap, UpgradeStatus};
2154/// use deps_core::position::{Position, Range};
2155/// use deps_core::{Dependency, PackageName, VersionReq};
2156/// use std::any::Any;
2157///
2158/// struct SimpleDep {
2159///     name: PackageName,
2160///     name_range: Range,
2161/// }
2162///
2163/// impl Dependency for SimpleDep {
2164///     fn name(&self) -> &PackageName {
2165///         &self.name
2166///     }
2167///     fn name_range(&self) -> Range {
2168///         self.name_range
2169///     }
2170///     fn version_requirement(&self) -> Option<&VersionReq> {
2171///         None
2172///     }
2173///     fn version_range(&self) -> Option<Range> {
2174///         None
2175///     }
2176///     fn source(&self) -> deps_core::parser::DependencySource {
2177///         deps_core::parser::DependencySource::Registry
2178///     }
2179///     fn as_any(&self) -> &dyn Any {
2180///         self
2181///     }
2182/// }
2183///
2184/// let dep = SimpleDep {
2185///     name: PackageName::new("time"),
2186///     name_range: Range::new(Position::new(0, 0), Position::new(0, 4)).into(),
2187/// };
2188///
2189/// let mut latest_status = LatestStatusMap::new();
2190/// latest_status.insert(
2191///     deps_core::test_util::vuln_key("time"),
2192///     UpgradeStatus::CandidateClean {
2193///         version: deps_core::ConcreteVersion::new("1.0.0"),
2194///     },
2195/// );
2196///
2197/// let status = resolve_latest_status(&latest_status, &dep, None, "time");
2198/// assert!(matches!(status, Some(UpgradeStatus::CandidateClean { .. })));
2199/// ```
2200pub fn resolve_latest_status<'a>(
2201    latest_status: &'a LatestStatusMap,
2202    dep: &dyn Dependency,
2203    keys: Option<&crate::osv::VulnKeys>,
2204    normalized_name: &str,
2205) -> Option<&'a UpgradeStatus> {
2206    resolve_by_vuln_key(latest_status, dep, keys, normalized_name)
2207}
2208
2209/// Whether a dependency's currently-displayed "latest" upgrade recommendation is safe to
2210/// surface as such (issue #1517).
2211///
2212/// The single gate every renderer (hover, diagnostics, code actions, code lens, inlay hints,
2213/// completion) and `deps-cli update`/`check` must consult before treating `latest` as an
2214/// endorsed upgrade target.
2215// Exhaustive: a new "verdict" is a deliberate design decision about how a fifth case should
2216// render everywhere at once, never a silent default — mirrors `EcosystemId`'s exhaustive-match
2217// convention (project rule, see #118).
2218#[derive(Debug, Clone, PartialEq, Eq)]
2219pub enum LatestVerdict {
2220    /// OSV's latest-check does not apply here: OSV is disabled/offline for this scan entirely
2221    /// (no [`crate::osv::LatestStatusMap`] was ever attached), or this dependency's skip reason
2222    /// is structural ([`crate::osv::SkipReason::is_structural`] — its source/ecosystem is never
2223    /// checked against OSV, e.g. a git dependency or an OSV-unsupported ecosystem).
2224    NotApplicable,
2225    /// The exact version currently displayed as "latest" was checked and found clean (or
2226    /// affected only by [`VulnSeverity::Informational`]-only advisories, e.g. an "unmaintained"
2227    /// notice with no genuine security content).
2228    Verified,
2229    /// The exact version currently displayed as "latest" was checked and found affected by at
2230    /// least one non-informational advisory (or a record whose severity could not be
2231    /// determined, which is treated as blocking rather than silently passed through).
2232    Flagged {
2233        /// Advisory ids affecting the checked version, for display.
2234        advisory_ids: Vec<String>,
2235        /// Whether the worst affecting advisory is a confirmed-malicious-package record
2236        /// ([`VulnSeverity::Malicious`]) — renderers use this to escalate wording/severity.
2237        malicious: bool,
2238    },
2239    /// The version was never definitively checked against OSV: no entry for this dependency
2240    /// (including the pre-phase-B window, where an empty map is attached deliberately so this
2241    /// case fires instead of [`Self::NotApplicable`]), a transient skip (timeout, query
2242    /// failure, truncation), or the checked version has since diverged from what's now
2243    /// displayed as `latest`. Renderers must treat this the same as [`Self::Flagged`] for the
2244    /// purpose of *not* recommending the upgrade — the two are kept distinct only so wording can
2245    /// differ ("not yet verified" vs. "flagged unsafe").
2246    Unverified,
2247}
2248
2249/// Computes `dep`'s [`LatestVerdict`] against `displayed_latest` (the exact version string a
2250/// renderer is about to show as "latest") from `latest_status` (typically
2251/// [`VersionData::latest_status`]).
2252///
2253/// # Examples
2254///
2255/// ```
2256/// use deps_core::lsp_helpers::{LatestVerdict, latest_verdict};
2257/// use deps_core::osv::{LatestStatusMap, UpgradeStatus};
2258/// use deps_core::position::{Position, Range};
2259/// use deps_core::{Dependency, PackageName, VersionReq};
2260/// use std::any::Any;
2261///
2262/// struct SimpleDep {
2263///     name: PackageName,
2264///     name_range: Range,
2265/// }
2266///
2267/// impl Dependency for SimpleDep {
2268///     fn name(&self) -> &PackageName {
2269///         &self.name
2270///     }
2271///     fn name_range(&self) -> Range {
2272///         self.name_range
2273///     }
2274///     fn version_requirement(&self) -> Option<&VersionReq> {
2275///         None
2276///     }
2277///     fn version_range(&self) -> Option<Range> {
2278///         None
2279///     }
2280///     fn source(&self) -> deps_core::parser::DependencySource {
2281///         deps_core::parser::DependencySource::Registry
2282///     }
2283///     fn as_any(&self) -> &dyn Any {
2284///         self
2285///     }
2286/// }
2287///
2288/// let dep = SimpleDep {
2289///     name: PackageName::new("left-pad"),
2290///     name_range: Range::new(Position::new(0, 0), Position::new(0, 8)).into(),
2291/// };
2292/// let formatter = deps_core::test_util::StubFormatter::new();
2293///
2294/// // No map attached at all (OSV disabled/offline) — not applicable, today's behavior.
2295/// assert_eq!(
2296///     latest_verdict(None, &dep, None, "left-pad", "1.0.8", &formatter),
2297///     LatestVerdict::NotApplicable
2298/// );
2299///
2300/// // A map with no entry for this dependency (e.g. before phase B first completes) must fail
2301/// // closed, never pass through as safe.
2302/// let empty = LatestStatusMap::new();
2303/// assert_eq!(
2304///     latest_verdict(Some(&empty), &dep, None, "left-pad", "1.0.8", &formatter),
2305///     LatestVerdict::Unverified
2306/// );
2307/// ```
2308#[must_use]
2309pub fn latest_verdict(
2310    latest_status: Option<&LatestStatusMap>,
2311    dep: &dyn Dependency,
2312    keys: Option<&crate::osv::VulnKeys>,
2313    normalized_name: &str,
2314    displayed_latest: &str,
2315    formatter: &dyn EcosystemFormatter,
2316) -> LatestVerdict {
2317    let Some(map) = latest_status else {
2318        return LatestVerdict::NotApplicable;
2319    };
2320    upgrade_status_to_verdict(
2321        resolve_latest_status(map, dep, keys, normalized_name),
2322        displayed_latest,
2323        dep,
2324        formatter,
2325    )
2326}
2327
2328/// Shared `UpgradeStatus -> LatestVerdict` mapping behind both [`latest_verdict`] and
2329/// [`candidate_verdict`] (#1524) — factored out so the two never drift on what "clean" vs.
2330/// "vulnerable" vs. "unverified"/"not applicable" means for a checked candidate.
2331///
2332/// A [`SkipReason::NonRegistrySource`] structural entry is cached under `dep`'s plain
2333/// [`crate::osv::VulnKey`] (name, not source), so it survives untouched if `dep`'s source later
2334/// changes from e.g. path/git to registry — until the next phase B run overwrites it. Since that
2335/// window would otherwise read as the fail-open [`LatestVerdict::NotApplicable`] for a dependency
2336/// that is now registry-resolvable and simply hasn't been checked yet, this re-checks `dep`'s
2337/// *current* source before trusting a cached `NonRegistrySource` skip (issue #1531). The check
2338/// goes through `formatter.source_is_public_registry_content` — the same predicate phase B itself
2339/// uses to classify `NonRegistrySource` in the first place (`deps-engine`'s `classify::osv`) —
2340/// rather than a bare `DependencySource::Registry` match, so an ecosystem that widens what counts
2341/// as registry content (e.g. `deps-cargo`'s `AlternateRegistry { mirrors_crates_io: true }` for a
2342/// configured crates.io mirror) is not itself misread as still-non-registry and wrongly kept
2343/// stale. A mismatch means the entry is stale, so it fails closed to
2344/// [`LatestVerdict::Unverified`] instead.
2345fn upgrade_status_to_verdict(
2346    status: Option<&UpgradeStatus>,
2347    expected_version: &str,
2348    dep: &dyn Dependency,
2349    formatter: &dyn EcosystemFormatter,
2350) -> LatestVerdict {
2351    match status {
2352        None | Some(UpgradeStatus::NotChecked) => LatestVerdict::Unverified,
2353        Some(UpgradeStatus::StructurallyUnchecked(reason)) => {
2354            let stale_non_registry_skip = *reason == StructuralSkipReason::NonRegistrySource
2355                && formatter.source_is_public_registry_content(&dep.source());
2356            if stale_non_registry_skip {
2357                LatestVerdict::Unverified
2358            } else {
2359                LatestVerdict::NotApplicable
2360            }
2361        }
2362        Some(UpgradeStatus::CandidateUnverified { reason, .. }) => {
2363            if reason.is_structural() {
2364                LatestVerdict::NotApplicable
2365            } else {
2366                LatestVerdict::Unverified
2367            }
2368        }
2369        Some(UpgradeStatus::CandidateClean { version }) => {
2370            if version == expected_version {
2371                LatestVerdict::Verified
2372            } else {
2373                LatestVerdict::Unverified
2374            }
2375        }
2376        Some(UpgradeStatus::CandidateVulnerable {
2377            version,
2378            advisory_ids,
2379            worst_severity,
2380        }) => {
2381            if version != expected_version {
2382                return LatestVerdict::Unverified;
2383            }
2384            if *worst_severity == Some(VulnSeverity::Informational) {
2385                LatestVerdict::Verified
2386            } else {
2387                LatestVerdict::Flagged {
2388                    advisory_ids: advisory_ids.items().to_vec(),
2389                    malicious: *worst_severity == Some(VulnSeverity::Malicious),
2390                }
2391            }
2392        }
2393    }
2394}
2395
2396/// Resolves `dep`'s per-version candidate status from `candidate_status` (typically
2397/// [`VersionData::candidate_status`]), using the same three-tier `VulnKey` resolution
2398/// [`resolve_latest_status`] uses.
2399///
2400/// # Examples
2401///
2402/// ```
2403/// use deps_core::lsp_helpers::resolve_candidate_status;
2404/// use deps_core::osv::{CandidateStatusMap, UpgradeStatus};
2405/// use deps_core::position::{Position, Range};
2406/// use deps_core::{Dependency, PackageName, VersionReq};
2407/// use std::any::Any;
2408///
2409/// struct SimpleDep {
2410///     name: PackageName,
2411///     name_range: Range,
2412/// }
2413///
2414/// impl Dependency for SimpleDep {
2415///     fn name(&self) -> &PackageName {
2416///         &self.name
2417///     }
2418///     fn name_range(&self) -> Range {
2419///         self.name_range
2420///     }
2421///     fn version_requirement(&self) -> Option<&VersionReq> {
2422///         None
2423///     }
2424///     fn version_range(&self) -> Option<Range> {
2425///         None
2426///     }
2427///     fn source(&self) -> deps_core::parser::DependencySource {
2428///         deps_core::parser::DependencySource::Registry
2429///     }
2430///     fn as_any(&self) -> &dyn Any {
2431///         self
2432///     }
2433/// }
2434///
2435/// let dep = SimpleDep {
2436///     name: PackageName::new("time"),
2437///     name_range: Range::new(Position::new(0, 0), Position::new(0, 4)).into(),
2438/// };
2439///
2440/// let mut candidate_status = CandidateStatusMap::new();
2441/// candidate_status.insert(
2442///     deps_core::test_util::vuln_key("time"),
2443///     deps_core::osv::CandidateStatuses::PerVersion(
2444///         std::iter::once((
2445///             deps_core::ConcreteVersion::new("0.1.43"),
2446///             UpgradeStatus::CandidateClean {
2447///                 version: deps_core::ConcreteVersion::new("0.1.43"),
2448///             },
2449///         ))
2450///         .collect(),
2451///     ),
2452/// );
2453///
2454/// let statuses = resolve_candidate_status(&candidate_status, &dep, None, "time").unwrap();
2455/// let deps_core::osv::CandidateStatuses::PerVersion(per_version) = statuses else {
2456///     unreachable!()
2457/// };
2458/// assert!(matches!(
2459///     per_version.get(&deps_core::ConcreteVersion::new("0.1.43")),
2460///     Some(UpgradeStatus::CandidateClean { .. })
2461/// ));
2462/// ```
2463#[must_use]
2464pub fn resolve_candidate_status<'a>(
2465    candidate_status: &'a CandidateStatusMap,
2466    dep: &dyn Dependency,
2467    keys: Option<&crate::osv::VulnKeys>,
2468    normalized_name: &str,
2469) -> Option<&'a CandidateStatuses> {
2470    resolve_by_vuln_key(candidate_status, dep, keys, normalized_name)
2471}
2472
2473/// Computes `dep`'s [`LatestVerdict`] for one specific candidate version (#1524).
2474///
2475/// The sibling of [`latest_verdict`] for a candidate-offering surface (code actions' "update
2476/// to X" list, completion's version items) that needs a verdict for more than just the
2477/// registry's single "latest" pick.
2478///
2479/// A dependency with no entry at all in `candidate_status` (phase B's candidate-check round
2480/// never covered it) resolves to [`LatestVerdict::Unverified`], matching [`latest_verdict`]'s
2481/// identical fail-closed default. A structural skip is recorded once per dependency as
2482/// [`crate::osv::CandidateStatuses::Structural`] (see that type's doc) and applies uniformly to
2483/// every candidate version, never mixed with real per-version data for the same dependency.
2484///
2485/// # Examples
2486///
2487/// ```
2488/// use deps_core::lsp_helpers::{LatestVerdict, candidate_verdict};
2489/// use deps_core::osv::{CandidateStatusMap, UpgradeStatus};
2490/// use deps_core::position::{Position, Range};
2491/// use deps_core::{Dependency, PackageName, VersionReq};
2492/// use std::any::Any;
2493///
2494/// struct SimpleDep {
2495///     name: PackageName,
2496///     name_range: Range,
2497/// }
2498///
2499/// impl Dependency for SimpleDep {
2500///     fn name(&self) -> &PackageName {
2501///         &self.name
2502///     }
2503///     fn name_range(&self) -> Range {
2504///         self.name_range
2505///     }
2506///     fn version_requirement(&self) -> Option<&VersionReq> {
2507///         None
2508///     }
2509///     fn version_range(&self) -> Option<Range> {
2510///         None
2511///     }
2512///     fn source(&self) -> deps_core::parser::DependencySource {
2513///         deps_core::parser::DependencySource::Registry
2514///     }
2515///     fn as_any(&self) -> &dyn Any {
2516///         self
2517///     }
2518/// }
2519///
2520/// let dep = SimpleDep {
2521///     name: PackageName::new("left-pad"),
2522///     name_range: Range::new(Position::new(0, 0), Position::new(0, 8)).into(),
2523/// };
2524/// let formatter = deps_core::test_util::StubFormatter::new();
2525///
2526/// // No map attached at all (OSV disabled/offline) — not applicable, mirrors `latest_verdict`.
2527/// assert_eq!(
2528///     candidate_verdict(None, &dep, None, "left-pad", "1.0.6", &formatter),
2529///     LatestVerdict::NotApplicable
2530/// );
2531///
2532/// // A map with an entry for this dependency, but not for this exact version — fails closed
2533/// // to `Unverified`, never silently treated as safe.
2534/// let mut candidate_status = CandidateStatusMap::new();
2535/// candidate_status.insert(
2536///     deps_core::test_util::vuln_key("left-pad"),
2537///     deps_core::osv::CandidateStatuses::PerVersion(std::collections::HashMap::new()),
2538/// );
2539/// assert_eq!(
2540///     candidate_verdict(Some(&candidate_status), &dep, None, "left-pad", "1.0.6", &formatter),
2541///     LatestVerdict::Unverified
2542/// );
2543/// ```
2544#[must_use]
2545pub fn candidate_verdict(
2546    candidate_status: Option<&CandidateStatusMap>,
2547    dep: &dyn Dependency,
2548    keys: Option<&crate::osv::VulnKeys>,
2549    normalized_name: &str,
2550    version: &str,
2551    formatter: &dyn EcosystemFormatter,
2552) -> LatestVerdict {
2553    let Some(map) = candidate_status else {
2554        return LatestVerdict::NotApplicable;
2555    };
2556    match resolve_candidate_status(map, dep, keys, normalized_name) {
2557        None => LatestVerdict::Unverified,
2558        Some(CandidateStatuses::Structural(reason)) => upgrade_status_to_verdict(
2559            Some(&UpgradeStatus::StructurallyUnchecked(*reason)),
2560            version,
2561            dep,
2562            formatter,
2563        ),
2564        Some(CandidateStatuses::PerVersion(per_version)) => {
2565            let status = per_version.get(&ConcreteVersion::from(version));
2566            upgrade_status_to_verdict(status, version, dep, formatter)
2567        }
2568    }
2569}
2570
2571/// Converts byte offsets in source text to LSP `Position` values.
2572///
2573/// Precomputes line-start byte offsets once, then maps any byte offset to a
2574/// `(line, character)` position. Characters are counted as UTF-16 code units
2575/// as required by the LSP specification.
2576///
2577/// Also precomputes, per line, whether that line is pure ASCII (`line_is_ascii`): for an
2578/// ASCII-only line, byte offset and UTF-16 code-unit
2579/// offset are always equal, so [`byte_offset_to_position`](Self::byte_offset_to_position)
2580/// can skip the `chars().map(char::len_utf16).sum()` scan entirely and compute the
2581/// character offset in O(1). This matters because a single minified manifest line (e.g. a
2582/// `package.json` with hundreds of dependencies on one line) turns per-offset lookups into
2583/// an O(n) scan each, and O(n) lookups across the line's length make the whole document
2584/// O(n^2) (#742) — the common case of an ASCII-only line now stays O(1) per lookup; a
2585/// non-ASCII line still takes an O(1) lookup too, via a lazily-built per-line index (#882) —
2586/// see `with_non_ascii_line_index`.
2587///
2588/// Interior mutability: this type is `Send` but not `Sync` (the non-ASCII line index cache
2589/// uses a `RefCell`). Build one per document parse and never share it across threads.
2590pub struct LineOffsetTable {
2591    line_starts: Vec<usize>,
2592    line_is_ascii: Vec<bool>,
2593    // Lazily populated, keyed by line number. A `HashMap` rather than a `Vec` sized to the
2594    // line count so cost is proportional to lines actually queried non-ASCII (#882).
2595    non_ascii_line_index_cache:
2596        std::cell::RefCell<std::collections::HashMap<usize, NonAsciiLineIndex>>,
2597}
2598
2599/// A non-ASCII line's char-boundary index, lazily built and cached by
2600/// `LineOffsetTable::with_non_ascii_line_index`.
2601///
2602/// `entries[i]` is `(byte_offset, utf16_units_before)` for the line's `i`-th char boundary
2603/// (`byte_offset` relative to the line's own start, `utf16_units_before` the cumulative UTF-16
2604/// code-unit count of every char *before* it). A final sentinel entry for the end of the line
2605/// (`byte_offset == line.len()`, `utf16_units_before` = the line's total UTF-16 length) makes
2606/// end-of-line byte offsets resolvable the same way as any other char boundary.
2607///
2608/// Serves both directions #882 needs on a non-ASCII line, from one build pass: char index ->
2609/// byte offset (`entries.get(col)`, what [`marker_byte_offset`] needs) and byte offset ->
2610/// UTF-16 units (`entries.binary_search_by_key`, what
2611/// [`byte_offset_to_position`](LineOffsetTable::byte_offset_to_position) needs) — a second,
2612/// independent cache would duplicate the exact `char_indices()` walk this one pass already
2613/// performs.
2614struct NonAsciiLineIndex {
2615    entries: Vec<(u32, u32)>,
2616}
2617
2618impl NonAsciiLineIndex {
2619    /// Builds the index for one line's text via a single `char_indices()` walk.
2620    fn build(line_text: &str) -> Self {
2621        let mut entries = Vec::with_capacity(line_text.len() + 1);
2622        let mut utf16_units = 0u32;
2623        for (byte_offset, c) in line_text.char_indices() {
2624            entries.push((u32::try_from(byte_offset).unwrap_or(u32::MAX), utf16_units));
2625            utf16_units = utf16_units.saturating_add(u32::try_from(c.len_utf16()).unwrap_or(2));
2626        }
2627        entries.push((
2628            u32::try_from(line_text.len()).unwrap_or(u32::MAX),
2629            utf16_units,
2630        ));
2631        // The initial `line_text.len() + 1` capacity over-reserves 3-4x on a multi-byte-heavy
2632        // line (CJK, Devanagari, ...): it reserves per byte but pushes one entry per char.
2633        entries.shrink_to_fit();
2634        Self { entries }
2635    }
2636}
2637
2638impl LineOffsetTable {
2639    /// Builds the table for `content`.
2640    pub fn new(content: &str) -> Self {
2641        let mut line_starts = vec![0];
2642        let mut line_is_ascii = Vec::new();
2643        let mut current_ascii = true;
2644        for (i, c) in content.char_indices() {
2645            if !c.is_ascii() {
2646                current_ascii = false;
2647            }
2648            if c == '\n' {
2649                line_starts.push(i + 1);
2650                line_is_ascii.push(current_ascii);
2651                current_ascii = true;
2652            }
2653        }
2654        line_is_ascii.push(current_ascii);
2655        debug_assert_eq!(
2656            line_starts.len(),
2657            line_is_ascii.len(),
2658            "line_starts and line_is_ascii must stay in lockstep — one entry per line"
2659        );
2660        Self {
2661            line_starts,
2662            line_is_ascii,
2663            non_ascii_line_index_cache: std::cell::RefCell::new(std::collections::HashMap::new()),
2664        }
2665    }
2666
2667    /// Runs `f` against the cached (building it on first call) [`NonAsciiLineIndex`] for the
2668    /// non-ASCII line starting at `line0` (0-indexed), given that line's own text.
2669    ///
2670    /// Callers must pass the exact text of line `line0` as sliced from the same `content` this
2671    /// table was built from — the cache trusts its first-seen `line_text` for every later
2672    /// lookup of the same `line0` and never re-validates it. Building a line's index the first
2673    /// time it is looked up is still O(line length) (one `char_indices()` walk); only repeat
2674    /// lookups on an already-cached line become O(1) — a huge non-ASCII line's *first* lookup
2675    /// is not free, it is the same one-time cost `LineOffsetTable::new` always paid.
2676    ///
2677    /// Takes a closure rather than returning a `Ref` so the `RefCell` borrow never outlives one
2678    /// call — a caller cannot accidentally hold it open across an unrelated later borrow.
2679    #[expect(
2680        clippy::expect_used,
2681        reason = "borrow_mut() is dropped before borrow() runs, and the inserted entry is \
2682                  never removed, so the lookup below always succeeds"
2683    )]
2684    fn with_non_ascii_line_index<R>(
2685        &self,
2686        line0: usize,
2687        line_text: &str,
2688        f: impl FnOnce(&NonAsciiLineIndex) -> R,
2689    ) -> R {
2690        self.non_ascii_line_index_cache
2691            .borrow_mut()
2692            .entry(line0)
2693            .or_insert_with(|| NonAsciiLineIndex::build(line_text));
2694        let cache = self.non_ascii_line_index_cache.borrow();
2695        let index = cache
2696            .get(&line0)
2697            .expect("just inserted above if it was missing");
2698        f(index)
2699    }
2700
2701    /// Byte offset (relative to the line's own start) of the `col`-th char boundary within the
2702    /// non-ASCII line starting at `line0`, given that line's text. Clamps to `line_text.len()`
2703    /// if `col` is past the line's char count, matching a plain `char_indices().nth(col)` walk.
2704    fn non_ascii_char_byte_offset(&self, line0: usize, line_text: &str, col: usize) -> usize {
2705        self.with_non_ascii_line_index(line0, line_text, |index| {
2706            index
2707                .entries
2708                .get(col)
2709                .map_or(line_text.len(), |&(byte_offset, _)| byte_offset as usize)
2710        })
2711    }
2712
2713    /// UTF-16 code-unit count preceding `byte_offset` (relative to the line's own start) within
2714    /// the non-ASCII line starting at `line0`, given that line's text.
2715    ///
2716    /// `byte_offset` must be a char boundary within the line (guaranteed by
2717    /// [`byte_offset_to_position`](Self::byte_offset_to_position)'s `floor_char_boundary` clamp
2718    /// before calling this) — every char boundary, including end-of-line, has an exact entry in
2719    /// the index, so this never needs to interpolate between two boundaries.
2720    fn non_ascii_utf16_units_before(
2721        &self,
2722        line0: usize,
2723        line_text: &str,
2724        byte_offset: usize,
2725    ) -> u32 {
2726        self.with_non_ascii_line_index(line0, line_text, |index| {
2727            let byte_offset = u32::try_from(byte_offset).unwrap_or(u32::MAX);
2728            let found = index
2729                .entries
2730                .binary_search_by_key(&byte_offset, |&(b, _)| b);
2731            // `Err` shouldn't happen given the caller's `floor_char_boundary` guarantee, but
2732            // falls back to the nearest preceding entry rather than panicking.
2733            let i = found.unwrap_or_else(|i| i.saturating_sub(1));
2734            index.entries.get(i).map_or(0, |&(_, units)| units)
2735        })
2736    }
2737
2738    /// Absolute byte offset where `line` (0-indexed) starts, or `None` if
2739    /// `line` is out of range.
2740    ///
2741    /// Prefer this over re-deriving a line's start via cursor arithmetic
2742    /// (`cursor += line.len() + 1`): `str::lines()` strips a trailing `\r`,
2743    /// so that approach under-counts by one byte per CRLF line and corrupts
2744    /// every subsequent offset in the file. This table is built by scanning
2745    /// `char_indices()` for `\n` (see [`new`](Self::new)), which counts the
2746    /// `\r`, so it stays correct for LF, CRLF and mixed line endings alike.
2747    ///
2748    /// # Examples
2749    ///
2750    /// ```
2751    /// use deps_core::lsp_helpers::LineOffsetTable;
2752    ///
2753    /// let table = LineOffsetTable::new("a\r\nb\r\nc");
2754    /// assert_eq!(table.line_start(0), Some(0));
2755    /// assert_eq!(table.line_start(1), Some(3));
2756    /// assert_eq!(table.line_start(2), Some(6));
2757    /// assert_eq!(table.line_start(3), None);
2758    /// ```
2759    pub fn line_start(&self, line: usize) -> Option<usize> {
2760        self.line_starts.get(line).copied()
2761    }
2762
2763    /// Converts a byte offset into an LSP `Position`.
2764    pub fn byte_offset_to_position(&self, content: &str, offset: usize) -> Position {
2765        let offset = offset.min(content.len());
2766        // The requirements.txt line parser derives offsets via hand-rolled byte arithmetic,
2767        // which can land inside a multi-byte character — clamp to the nearest char boundary
2768        // rather than panicking on the slice below.
2769        let offset = content.floor_char_boundary(offset);
2770        let line = self
2771            .line_starts
2772            .partition_point(|&start| start <= offset)
2773            .saturating_sub(1);
2774        #[expect(
2775            clippy::indexing_slicing,
2776            reason = "line_starts always has at least one element (vec![0] at construction), \
2777                      so partition_point().saturating_sub(1) is always a valid index into it"
2778        )]
2779        let line_start = self.line_starts[line];
2780        // #673 M1: `line`/`character` aren't bounded by any size cap for editor-sent
2781        // full-document-sync text, so this saturates rather than assuming an upstream cap
2782        // (mirrors `completion::byte_to_utf16_offset`'s identical fix).
2783        //
2784        // #742: an ASCII-only line has 1 UTF-16 unit per byte, so no need to walk `chars()`.
2785        let character = if self.line_is_ascii.get(line).copied().unwrap_or(false) {
2786            u32::try_from(offset - line_start).unwrap_or(u32::MAX)
2787        } else {
2788            // #882: a naive `chars().map(len_utf16).sum()` per call is O(line length); N
2789            // lookups on the same wide line made this O(N x line length). The cached
2790            // per-line index (`with_non_ascii_line_index`) makes repeat lookups O(1).
2791            let line_end = self
2792                .line_starts
2793                .get(line + 1)
2794                .copied()
2795                .unwrap_or(content.len());
2796            let line_text = content.get(line_start..line_end).unwrap_or_default();
2797            self.non_ascii_utf16_units_before(line, line_text, offset - line_start)
2798        };
2799        Position::new(u32::try_from(line).unwrap_or(u32::MAX), character)
2800    }
2801
2802    /// Converts an LSP `Position` back into a byte offset — the inverse of
2803    /// [`byte_offset_to_position`](Self::byte_offset_to_position). Out-of-range lines or
2804    /// UTF-16 characters clamp to `content.len()` rather than panicking, matching the
2805    /// forward conversion's `.min(content.len())` guard.
2806    ///
2807    /// A `character` landing inside a surrogate pair rounds up to the next char boundary, and
2808    /// one past the line's end (including its `\n`) resolves to the line's end — the same
2809    /// results as [`utf16_to_byte_offset`] on the line, but O(1) on an ASCII line and
2810    /// O(log line length) on a non-ASCII one via the cached per-line index (#1711).
2811    #[expect(
2812        clippy::string_slice,
2813        reason = "line_start/line_end come from line_starts (post-newline offsets, always \
2814                  char boundaries) or content.len()"
2815    )]
2816    pub fn position_to_byte_offset(&self, content: &str, position: Position) -> usize {
2817        let line0 = position.line as usize;
2818        let Some(&line_start) = self.line_starts.get(line0) else {
2819            return content.len();
2820        };
2821        let line_end = self
2822            .line_starts
2823            .get(line0 + 1)
2824            .copied()
2825            .unwrap_or(content.len());
2826        let line_len = line_end - line_start;
2827        let relative = if self.line_is_ascii.get(line0).copied().unwrap_or(false) {
2828            let character = position.character as usize;
2829            if character <= line_len {
2830                character
2831            } else {
2832                line_len
2833            }
2834        } else {
2835            let line_text = &content[line_start..line_end];
2836            self.with_non_ascii_line_index(line0, line_text, |index| {
2837                let i = index
2838                    .entries
2839                    .partition_point(|&(_, units)| units < position.character);
2840                index
2841                    .entries
2842                    .get(i)
2843                    .map_or(line_len, |&(byte_offset, _)| byte_offset as usize)
2844            })
2845        };
2846        (line_start + relative).min(content.len())
2847    }
2848}
2849
2850/// Escapes Markdown syntax characters so untrusted text cannot break out of the
2851/// Markdown structure it is embedded in.
2852///
2853/// Applied to manifest-controlled text (dependency names) before it is written into
2854/// hover markdown link labels, and to registry-controlled completion metadata
2855/// (package name/version, description, repository/documentation URLs) before it is
2856/// written into completion-item link labels and link destinations. Every ASCII
2857/// punctuation character is backslash-escaped (CommonMark's full escapable set — not
2858/// just brackets/parens, which would still leave e.g. `<https://evil.example>`
2859/// autolinks live), and control characters (including newlines) plus a narrow set of
2860/// invisible/bidi-override characters (see `is_markdown_unsafe`, #1248) are replaced
2861/// with a space so the text cannot terminate the single-line block it is embedded in,
2862/// splice in new content, or visually spoof the rendered name via a Trojan Source
2863/// (CVE-2021-42574) bidi override.
2864///
2865/// Backslash-escaping is valid in link destinations as well as regular text, so this
2866/// also neutralizes `)`/`]` breakout attempts in a `[label](destination)` URL. It does
2867/// *not* block dangerous URI schemes (e.g. `javascript:`) in a destination — that is a
2868/// separate concern from breaking out of the surrounding Markdown structure.
2869///
2870/// Backslash-escaping does *not* work inside inline code spans (CommonMark §6.1) —
2871/// use [`markdown_code_span`] for text embedded in `` `...` `` instead.
2872///
2873/// # Examples
2874///
2875/// ```
2876/// use deps_core::lsp_helpers::escape_markdown;
2877///
2878/// assert_eq!(escape_markdown("pkg](evil)[pkg"), r"pkg\]\(evil\)\[pkg");
2879/// assert_eq!(escape_markdown("a\nb"), "a b");
2880/// ```
2881pub fn escape_markdown(s: &str) -> String {
2882    let mut escaped = String::with_capacity(s.len());
2883    for c in s.chars() {
2884        if is_markdown_unsafe(c) {
2885            escaped.push(' ');
2886            continue;
2887        }
2888        if c.is_ascii_punctuation() {
2889            escaped.push('\\');
2890        }
2891        escaped.push(c);
2892    }
2893    escaped
2894}
2895
2896/// Predicate for a character that must not survive [`escape_markdown`]/
2897/// [`markdown_code_span`] unescaped (#1248, widened #1323): every Unicode `Cc`/`Cf`/`Zl`/`Zp`
2898/// character except a small, named exempt set with genuine mid-string use in real-world free
2899/// text — RTL/ZWNJ/ZWJ marks and Arabic/Syriac/Kaithi prefixed-format signs (see individual
2900/// `matches!` arms below, and `EXEMPT_CHARS` in
2901/// `test_is_markdown_unsafe_exempt_set_matches_sanitize_invisible_drift_guard` for the exact
2902/// list). Deliberately narrower than [`crate::redact::sanitize_invisible`]'s full sweep for
2903/// that reason; a name/version-shaped caller with no such legitimate-mark concern should
2904/// layer `sanitize_invisible` on top for full parity, as
2905/// `deps_npm::catalog::CatalogOrigin::hover_detail` does (#1266) — this crate's own
2906/// `deprecation.replacement` hover field does not yet (#1311).
2907///
2908/// The drift-guard test above scans the full Unicode code space and enforces that the
2909/// exempt set is exact. Do not widen or narrow this function without updating that test's
2910/// `EXEMPT_CHARS`, and do not fold in `sanitize_invisible`'s RTL/ZWNJ/ZWJ exemption without
2911/// also revisiting #1248.
2912fn is_markdown_unsafe(c: char) -> bool {
2913    c.is_control()
2914        || matches!(c,
2915            '\u{202A}'..='\u{202E}'    // LRE RLE PDF LRO RLO
2916          | '\u{2060}'..='\u{206F}'    // WORD JOINER, invisible math ops, bidi isolates,
2917                                       // deprecated format-control block (U+2065 unassigned,
2918                                       // deliberately included)
2919          | '\u{200B}'                // ZWSP
2920          | '\u{2028}' | '\u{2029}'   // LS, PS
2921          | '\u{FEFF}'                // ZWNBSP / BOM
2922          | '\u{FFF9}'..='\u{FFFB}'   // interlinear annotation anchor/separator/terminator
2923          | '\u{E0000}'..='\u{E007F}' // Unicode tag characters
2924          | '\u{00AD}'                // SOFT HYPHEN
2925          | '\u{180E}'                // MONGOLIAN VOWEL SEPARATOR
2926          | '\u{13430}'..='\u{1343F}' // Egyptian Hieroglyph format controls
2927          | '\u{1BCA0}'..='\u{1BCA3}' // Shorthand format controls
2928          | '\u{1D173}'..='\u{1D17A}' // Musical symbol format controls
2929        )
2930}
2931
2932/// Replaces every [`is_markdown_unsafe`] character in `s` with a single space.
2933///
2934/// Shared by [`markdown_code_span`] and `diagnostics::sanitize_advisory_text_for_diagnostic`
2935/// (#1262 code-review follow-up) so the narrow bidi/invisible-character filter has exactly
2936/// one loop to keep in sync with [`is_markdown_unsafe`]'s policy, instead of two copies that
2937/// could silently drift. Also called from `crate::diagnostic::Diagnostic::new` and
2938/// `crate::diagnostic::RelatedInformation::new` as a defense-in-depth backstop on their
2939/// constructor path (#1276) — `pub(crate)` rather than private for that, but still not
2940/// reachable from outside `deps-core`.
2941///
2942/// Idempotent: `' '` is not itself [`is_markdown_unsafe`], so re-applying this to
2943/// already-sanitized input is a no-op — safe to double-apply through both a producer-side
2944/// call and the constructor-path backstop.
2945///
2946/// Only correct for a text/label sink or an inline code span, where a stray space is
2947/// harmless — **not** for a Markdown link *destination*: per CommonMark, an unbracketed
2948/// `[label](destination)` destination cannot contain a literal space at all, so
2949/// substituting one would turn a fired hazard into a broken (non-)link instead of a
2950/// sanitized one. [`strip_markdown_unsafe_chars`] is the destination-safe sibling.
2951pub(crate) fn replace_markdown_unsafe_chars(s: &str) -> String {
2952    s.chars()
2953        .map(|c| if is_markdown_unsafe(c) { ' ' } else { c })
2954        .collect()
2955}
2956
2957/// Same filter as [`replace_markdown_unsafe_chars`], except `\n` is never replaced.
2958///
2959/// [`is_markdown_unsafe`] starts with `c.is_control()`, which classifies `\n` as unsafe —
2960/// correct for a single-line diagnostic message, but wrong for hover content: a hover card
2961/// is a multi-section Markdown document that relies on structural newlines between
2962/// sections, so running it through [`replace_markdown_unsafe_chars`] verbatim would
2963/// collapse the whole card onto one line. Used as `crate::hover::Hover`'s constructor-path
2964/// sanitization backstop (#1277), the hover equivalent of `Diagnostic::new`'s use of
2965/// [`replace_markdown_unsafe_chars`] (#1276).
2966///
2967/// Idempotent for the same reason [`replace_markdown_unsafe_chars`] is: neither `' '` nor
2968/// `'\n'` is itself [`is_markdown_unsafe`], so re-applying this to already-sanitized input
2969/// (or to input already sanitized by [`replace_markdown_unsafe_chars`], which is strictly
2970/// more aggressive) is a no-op.
2971///
2972/// Same **not destination-safe** caveat as [`replace_markdown_unsafe_chars`], but more load-
2973/// bearing here: this filter runs over the *entire* hover document on every construction and
2974/// mutation, so unlike the sibling's narrow, hand-picked call sites, it always spans any
2975/// Markdown link destination the document happens to contain — a substituted space there
2976/// would break the link (CommonMark forbids a literal space in an unbracketed destination)
2977/// rather than neutralize a hazard. This is not a gap in practice: every hover-rendered URL
2978/// is already stripped (not space-substituted) per-site via
2979/// [`crate::lsp_helpers::strip_markdown_unsafe_chars`] before it reaches this filter (see
2980/// `push_header_hover_section` in `lsp_helpers/hover.rs`), so this document-wide pass finds
2981/// nothing left to fire on a correctly-sanitized destination — but a future call site that
2982/// skips the per-site strip would silently break instead of just risking a hazard.
2983#[cfg(feature = "lsp-responses")]
2984pub(crate) fn replace_markdown_unsafe_chars_keep_newlines(s: &str) -> String {
2985    s.chars()
2986        .map(|c| {
2987            if c != '\n' && is_markdown_unsafe(c) {
2988                ' '
2989            } else {
2990                c
2991            }
2992        })
2993        .collect()
2994}
2995
2996/// Removes every `is_markdown_unsafe` character from `s` outright, rather than
2997/// substituting a space (`replace_markdown_unsafe_chars`'s behavior).
2998///
2999/// Used for a Markdown link *destination* (#1259 critic S3): CommonMark forbids a
3000/// literal, unescaped space inside an unbracketed `[label](destination)` destination,
3001/// so replacing a stripped character with a space there would break the link (render it
3002/// as non-link literal text) instead of sanitizing it in place. Dropping the character
3003/// keeps the surrounding URL syntactically intact — the removed character carried no
3004/// meaningful display information to begin with (it is invisible/bidi-control by
3005/// definition), so there is nothing worth preserving a placeholder for.
3006///
3007/// # Examples
3008///
3009/// ```
3010/// use deps_core::lsp_helpers::strip_markdown_unsafe_chars;
3011///
3012/// assert_eq!(strip_markdown_unsafe_chars("https://example.com/pkg"), "https://example.com/pkg");
3013/// assert_eq!(
3014///     strip_markdown_unsafe_chars("https://example.com/real\u{202E}gnp.sj"),
3015///     "https://example.com/realgnp.sj"
3016/// );
3017/// ```
3018pub fn strip_markdown_unsafe_chars(s: &str) -> String {
3019    s.chars().filter(|c| !is_markdown_unsafe(*c)).collect()
3020}
3021
3022/// Wraps `content` in a Markdown inline code span (backticks included) that safely
3023/// contains arbitrary untrusted text, regardless of embedded backticks.
3024///
3025/// Backslash-escaping does not work inside code spans (CommonMark §6.1), so instead
3026/// this fences with one more backtick than the longest run found in `content`, and
3027/// pads with a single space on each side when `content` starts or ends with a
3028/// backtick or space (required by CommonMark to keep the fence unambiguous). Control
3029/// characters (including newlines) plus the same narrow invisible/bidi-override subset
3030/// `escape_markdown` neutralizes (see `is_markdown_unsafe`, #1248) are replaced with a
3031/// space first, since the raw hover string is otherwise free to merge into an adjacent
3032/// Markdown block or carry a Trojan Source bidi override into the rendered code span.
3033///
3034/// # Examples
3035///
3036/// ```
3037/// use deps_core::lsp_helpers::markdown_code_span;
3038///
3039/// assert_eq!(markdown_code_span("1.0.0"), "`1.0.0`");
3040/// assert_eq!(markdown_code_span("a`b"), "``a`b``");
3041/// ```
3042pub fn markdown_code_span(content: &str) -> String {
3043    let sanitized = replace_markdown_unsafe_chars(content);
3044
3045    let max_backtick_run = sanitized
3046        .split(|c| c != '`')
3047        .map(str::len)
3048        .max()
3049        .unwrap_or(0);
3050    let fence = "`".repeat(max_backtick_run + 1);
3051
3052    if sanitized.is_empty() {
3053        format!("{fence} {fence}")
3054    } else if sanitized.starts_with(['`', ' ']) || sanitized.ends_with(['`', ' ']) {
3055        format!("{fence} {sanitized} {fence}")
3056    } else {
3057        format!("{fence}{sanitized}{fence}")
3058    }
3059}
3060
3061/// Checks if two version strings have the same major and minor version.
3062pub fn is_same_major_minor(v1: &str, v2: &str) -> bool {
3063    if v1.is_empty() || v2.is_empty() {
3064        return false;
3065    }
3066
3067    let mut parts1 = v1.split('.');
3068    let mut parts2 = v2.split('.');
3069
3070    if parts1.next() != parts2.next() {
3071        return false;
3072    }
3073
3074    match (parts1.next(), parts2.next()) {
3075        (Some(m1), Some(m2)) => m1 == m2,
3076        _ => true,
3077    }
3078}
3079
3080/// The length of the maximal `[a-zA-Z_][a-zA-Z0-9_]*`-shaped identifier starting at `start` in
3081/// `bytes`, or `None` if `bytes[start]` does not start one — the identifier grammar shared by
3082/// shell/envsubst-style variable expansion (`$VAR`, `${VAR}`) and the `@VAR@`/`%VAR%` forms
3083/// below.
3084fn template_placeholder_identifier_end(bytes: &[u8], start: usize) -> Option<usize> {
3085    let first = *bytes.get(start)?;
3086    if !(first.is_ascii_alphabetic() || first == b'_') {
3087        return None;
3088    }
3089    let mut end = start + 1;
3090    while bytes
3091        .get(end)
3092        .is_some_and(|&c| c.is_ascii_alphanumeric() || c == b'_')
3093    {
3094        end += 1;
3095    }
3096    Some(end)
3097}
3098
3099/// Like [`template_placeholder_identifier_end`], but also allows `-` (never `.`) in the
3100/// identifier's tail — the `$(...)` Makefile/MSBuild form's own grammar (spec 070, issue
3101/// #1421): MSBuild property names may contain a hyphen in subsequent positions
3102/// (`$(MOD-VERSION)`) but never a period, unlike the `@VAR@` form's
3103/// [`dotted_identifier_end`], which permits both. Deliberately not reused verbatim for
3104/// `$(...)` — doing so would silently admit the dotted `$(A.VERSION)` form this project's
3105/// prevalence research rejected as unsupported by real tooling.
3106fn hyphenated_identifier_end(bytes: &[u8], start: usize) -> Option<usize> {
3107    let first = *bytes.get(start)?;
3108    if !(first.is_ascii_alphabetic() || first == b'_') {
3109        return None;
3110    }
3111    let mut end = start + 1;
3112    while bytes
3113        .get(end)
3114        .is_some_and(|&c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-'))
3115    {
3116        end += 1;
3117    }
3118    Some(end)
3119}
3120
3121/// Like [`template_placeholder_identifier_end`], but also allows `.`/`-` in the identifier's
3122/// tail (never as the leading character) — the shape a CMake/Autotools `configure_file`
3123/// placeholder needs (`@project.version@`, `@some-flag@`), which is the actual common
3124/// real-world `@..@` form (impl-critic M1, #1379 follow-up): the stricter
3125/// `[a-zA-Z_][a-zA-Z0-9_]*` grammar the `$VAR`/`%VAR%` forms use never matches it, since those
3126/// forms' own real-world identifiers (shell/environment variable names, Windows env vars)
3127/// never contain `.`/`-` to begin with.
3128fn dotted_identifier_end(bytes: &[u8], start: usize) -> Option<usize> {
3129    let first = *bytes.get(start)?;
3130    if !(first.is_ascii_alphabetic() || first == b'_') {
3131        return None;
3132    }
3133    let mut end = start + 1;
3134    while bytes
3135        .get(end)
3136        .is_some_and(|&c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'.' | b'-'))
3137    {
3138        end += 1;
3139    }
3140    Some(end)
3141}
3142
3143/// Whether `text` contains a `delim IDENT delim`-shaped placeholder (`@VAR@`/`@project.version@`
3144/// autoconf/CMake `configure_file`, `%VAR%` Windows batch/NSIS), anywhere in `text`. Unlike the
3145/// `$`-prefixed forms, both delimiters are required — `@`/`%` are common enough in ordinary text
3146/// (email addresses, percentages) that a bare opening delimiter alone would be too permissive.
3147fn contains_delimited_identifier_placeholder(
3148    text: &str,
3149    delim: u8,
3150    identifier_end: impl Fn(&[u8], usize) -> Option<usize>,
3151) -> bool {
3152    let bytes = text.as_bytes();
3153    bytes.iter().enumerate().any(|(i, &b)| {
3154        b == delim && identifier_end(bytes, i + 1).is_some_and(|end| bytes.get(end) == Some(&delim))
3155    })
3156}
3157
3158/// Whether `text` contains an `open ... close`-shaped placeholder with non-blank content
3159/// between the delimiters (`{{ VAR }}` Liquid/Jinja2/Mustache/Go `text/template`, `<%= VAR %>`
3160/// ERB/lodash/Yeoman) — content is not restricted to a single identifier, since these grammars
3161/// commonly carry dotted field access (`{{ .NetVersion }}`), filters, or an `=`/`-` modifier
3162/// right after `open`.
3163///
3164/// The closing delimiter is not required (failing safe on an unclosed `{{VAR` — still treating
3165/// it as a placeholder — mirrors the `${VAR` precedent below): only `open` followed by
3166/// whitespace-then-non-blank content is checked, so a stray, immediately-closed `open` (`{{}}`,
3167/// `{{ }}`) is deliberately NOT flagged, matching `${}`'s empty-placeholder carve-out.
3168fn contains_bracketed_placeholder(text: &str, open: &str, close_or_skip: &[u8]) -> bool {
3169    let bytes = text.as_bytes();
3170    text.match_indices(open).any(|(pos, _)| {
3171        let mut i = pos + open.len();
3172        if close_or_skip.contains(bytes.get(i).unwrap_or(&0)) {
3173            i += 1;
3174        }
3175        while bytes.get(i).is_some_and(|b| b.is_ascii_whitespace()) {
3176            i += 1;
3177        }
3178        bytes.get(i).is_some_and(|&b| b != b'}' && b != b'%')
3179    })
3180}
3181
3182/// Whether `requirement` contains an unresolved external-templating placeholder.
3183///
3184/// Recognizes several common generator syntaxes: `$VAR`/`${VAR}` (shell/envsubst), `$(VAR)`
3185/// (Makefile/MSBuild variable reference), `{{ VAR }}` (Liquid/Jinja2/Mustache/Go `text/template`
3186/// expression tags), `{% ... %}` (Jinja2/Liquid statement tags), `@VAR@`/`@project.version@`
3187/// (autoconf/CMake `configure_file`), `%VAR%` (Windows batch/NSIS), or `<%= VAR %>`
3188/// (ERB/lodash/Yeoman templates).
3189///
3190/// Detected anywhere in the text, not just as the whole value: `"${REACT_VERSION}"`, `"$VUE"`,
3191/// `"$(LODASH_VERSION)"`, `"{{ .NetVersion }}"`, `"{% if x %}"`, `"@PACKAGE_VERSION@"`,
3192/// `"%VERSION%"`, `"<%= version %>"`, and an embedded form like `"1.0.0-$BUILD"` are all
3193/// detected.
3194///
3195/// Issues #1374/#1379/#1417: manifests pre-processed by external templating (`envsubst`, CI
3196/// templating, cookiecutter/Yeoman-style generators, `configure_file`, `Makefile`-orchestrated
3197/// codegen) commonly carry one of these shapes in a version-requirement slot. npm, Cargo, Dart,
3198/// Poetry (`[tool.poetry.dependencies]`), Deno and Go have no expansion syntax of their own for
3199/// any of them — unlike Maven's `${property}` or Gradle's `$var`/`${var}`, which their own build
3200/// tools resolve — so this crate can never expand one either, and a requirement containing it
3201/// must never be classified as outdated/unsatisfiable or rewritten to a literal version.
3202///
3203/// The `$`/`{{`/`{%`/`<%` forms do not require a closing delimiter (failing safe on an unclosed
3204/// `${VAR`/`{{VAR`/`{%VAR`/`<%VAR` — still treating it as a placeholder — mirrors the GitLab
3205/// precedent this predicate was originally extracted alongside); `@VAR@`/`%VAR%` require both
3206/// delimiters, since a bare `@`/`%` is too common in ordinary text to treat as an opening
3207/// delimiter alone. `$(VAR)` also requires both delimiters (unlike `${VAR`'s fail-open
3208/// precedent): an unclosed `$(` is far more likely to be a real, unrelated `$` immediately
3209/// followed by literal `(text...` (parenthetical prose, a pasted shell command-substitution
3210/// snippet) than an unterminated Makefile reference (#1417).
3211///
3212/// NuGet's `.csproj`/`.fsproj`/`.vbproj`/`Directory.Packages.props` MSBuild Central Package
3213/// Management references (also `$(VAR)`-shaped, plus `%(VAR)`/`@(VAR)`) are natively recognized
3214/// by that crate's own, independent `is_msbuild_reference` guard — `NuGetFormatter`'s
3215/// `requirement_is_placeholder` ORs this function with that guard (`shared || native`), so this
3216/// function is still reachable and consulted for NuGet, but its own `$(VAR)` extension is not
3217/// load-bearing there: `is_msbuild_reference` already covered NuGet's `$(VAR)` case before this
3218/// function did.
3219///
3220/// The `$(VAR)` identifier grammar additionally accepts `-` in subsequent (never leading)
3221/// positions (`$(MOD-VERSION)`), matching real MSBuild property-name syntax (spec 070, issue
3222/// #1421). A `.` anywhere before the closing `)` (`$(A.VERSION)`) is a deliberate, researched
3223/// exclusion — not valid MSBuild syntax and not a documented real-world convention — and is
3224/// never matched, even in combination with a hyphen (`$(MOD.SUB-VERSION)`).
3225///
3226/// # Examples
3227///
3228/// ```
3229/// use deps_core::lsp_helpers::requirement_contains_template_placeholder;
3230///
3231/// assert!(requirement_contains_template_placeholder("${REACT_VERSION}"));
3232/// assert!(requirement_contains_template_placeholder("$VUE"));
3233/// assert!(requirement_contains_template_placeholder("1.0.0-$BUILD"));
3234/// assert!(requirement_contains_template_placeholder("$(LODASH_VERSION)"));
3235/// assert!(requirement_contains_template_placeholder("1.0.0-$(BUILD_SUFFIX)"));
3236/// assert!(requirement_contains_template_placeholder("$(MOD-VERSION)"));
3237/// assert!(requirement_contains_template_placeholder("1.0.0-$(BUILD-SUFFIX)"));
3238/// assert!(requirement_contains_template_placeholder("{{ .NetVersion }}"));
3239/// assert!(requirement_contains_template_placeholder("{% if x %}"));
3240/// assert!(requirement_contains_template_placeholder("@PACKAGE_VERSION@"));
3241/// assert!(requirement_contains_template_placeholder("@project.version@"));
3242/// assert!(requirement_contains_template_placeholder("%VERSION%"));
3243/// assert!(requirement_contains_template_placeholder("<%= version %>"));
3244/// assert!(!requirement_contains_template_placeholder("1.2.3"));
3245/// assert!(!requirement_contains_template_placeholder("price-is-$5"));
3246/// assert!(!requirement_contains_template_placeholder("price-is-$(five"));
3247/// assert!(!requirement_contains_template_placeholder("me@example.com"));
3248/// assert!(!requirement_contains_template_placeholder("100%"));
3249/// assert!(!requirement_contains_template_placeholder("$(A.VERSION)"));
3250/// assert!(!requirement_contains_template_placeholder("$(MOD.SUB-VERSION)"));
3251/// assert!(!requirement_contains_template_placeholder("$(-VERSION)"));
3252/// ```
3253pub fn requirement_contains_template_placeholder(requirement: &str) -> bool {
3254    let bytes = requirement.as_bytes();
3255    let has_dollar_form = bytes.iter().enumerate().any(|(i, &b)| {
3256        b == b'$'
3257            && match bytes.get(i + 1) {
3258                Some(b'{') => template_placeholder_identifier_end(bytes, i + 2).is_some(),
3259                // #1421: `[a-zA-Z_][a-zA-Z0-9_-]*` — a hyphen is accepted in subsequent
3260                // positions (real MSBuild property-name syntax, `$(MOD-VERSION)`); a dot
3261                // (`$(A.VERSION)`) remains a deliberate, researched exclusion (spec 070).
3262                Some(b'(') => hyphenated_identifier_end(bytes, i + 2)
3263                    .is_some_and(|end| bytes.get(end) == Some(&b')')),
3264                _ => template_placeholder_identifier_end(bytes, i + 1).is_some(),
3265            }
3266    });
3267    has_dollar_form
3268        || contains_delimited_identifier_placeholder(requirement, b'@', dotted_identifier_end)
3269        || contains_delimited_identifier_placeholder(
3270            requirement,
3271            b'%',
3272            template_placeholder_identifier_end,
3273        )
3274        || contains_bracketed_placeholder(requirement, "{{", b"")
3275        || contains_bracketed_placeholder(requirement, "{%", b"-")
3276        || contains_bracketed_placeholder(requirement, "<%", b"=-")
3277}
3278
3279/// Result of checking whether a dependency's declared requirement is already satisfied by
3280/// the latest known version.
3281///
3282/// Diagnostics and inlay hints read this result differently: diagnostics only need to know
3283/// whether it is safe to skip the "Newer version available" warning, so both `UpToDate` and
3284/// `Unresolved` suppress it. Inlay hints additionally need to distinguish `Unresolved` from
3285/// `UpToDate`, since an unresolved requirement (e.g. a dangling Gradle version-catalog
3286/// `version.ref` alias, or an unexpanded Maven `${property}`) must not render an "up to
3287/// date" badge that was never actually verified.
3288// Exhaustive: a wildcard arm at any consuming match site would silently render the wrong
3289// label for a new variant instead of failing to compile (#769).
3290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3291pub enum RequirementStatus {
3292    /// The latest version satisfies the declared requirement.
3293    UpToDate,
3294    /// The latest version does not satisfy the declared requirement — a newer version is
3295    /// available.
3296    Outdated,
3297    /// The requirement could not be resolved to a concrete constraint, so no comparison
3298    /// could be made.
3299    Unresolved,
3300}
3301
3302/// A `requirement` compiled by one ecosystem, ready to test candidate versions against.
3303///
3304/// Produced by [`formatter::RequirementResolution::compile_bounded_requirement`]. Kept as a separate object
3305/// (rather than a single "does any version match" function) so the requirement is parsed
3306/// once per dependency, and so the scanning loop — including the empty-list guard, the
3307/// early-exit on first match, and the "skip an unparseable candidate" rule — lives once in
3308/// [`requirement_is_unsatisfiable`] instead of being reimplemented by all eleven ecosystems.
3309///
3310/// # Examples
3311///
3312/// ```
3313/// use deps_core::lsp_helpers::RequirementMatcher;
3314/// use deps_core::ConcreteVersion;
3315///
3316/// struct ExactMatch(String);
3317///
3318/// impl RequirementMatcher for ExactMatch {
3319///     fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
3320///         Some(version.as_str() == self.0)
3321///     }
3322///
3323///     fn strict_prerelease_exclusion(&self) -> bool {
3324///         false
3325///     }
3326/// }
3327///
3328/// let matcher = ExactMatch("1.0.0".to_string());
3329/// assert_eq!(matcher.matches(&ConcreteVersion::new("1.0.0")), Some(true));
3330/// assert_eq!(matcher.matches(&ConcreteVersion::new("2.0.0")), Some(false));
3331/// ```
3332pub trait RequirementMatcher: Send + Sync {
3333    /// Tests one candidate version string against the compiled requirement.
3334    ///
3335    /// `Some(true)` / `Some(false)`: this candidate provably does / does not satisfy the
3336    /// requirement. `None`: this candidate *string* could not be parsed by this ecosystem's
3337    /// version format (e.g. a PyPI legacy release identifier, a Maven timestamped snapshot
3338    /// qualifier) — the caller skips it and keeps scanning the rest of the list. Never
3339    /// return `None` to mean "the requirement itself is unusable"; that is
3340    /// [`formatter::RequirementResolution::compile_bounded_requirement`]'s job, via returning `None` from that
3341    /// method instead of constructing a matcher at all.
3342    fn matches(&self, version: &ConcreteVersion) -> Option<bool>;
3343
3344    /// Whether this compiled matcher follows strict SemVer 2.0.0 pre-release semantics: a
3345    /// pre-release version (`X.Y.Z-pre`) is excluded from matching unless the compiled
3346    /// requirement itself pins to the same `X.Y.Z` tuple with a pre-release tag — the rule
3347    /// `semver::VersionReq` and `node_semver::Range` both implement.
3348    ///
3349    /// `matching_prerelease_would_satisfy`'s "requirement is unsatisfiable only because of
3350    /// default pre-release exclusion" enrichment (#299) is gated on this, read from the
3351    /// already-compiled matcher instance rather than from a separate per-formatter flag
3352    /// (#1478) — so a formatter that reuses an existing matcher implementation (e.g.
3353    /// `deps-npm`'s shared `compile_node_semver_range`, also used by `deps-deno`) inherits the
3354    /// correct answer for free; only a formatter introducing a genuinely new matcher type
3355    /// needs to decide this at all, and only in the one place that type is defined.
3356    ///
3357    /// No default: a new matcher type must decide this explicitly rather than silently
3358    /// inheriting `false` by omission (#1478) — the whole point of moving this property off a
3359    /// per-formatter flag and onto the matcher type is that the answer can no longer go
3360    /// unconsidered. Return `true` only for a matcher whose underlying comparator itself
3361    /// implements this exclusion ([`compile_semver_requirement`]'s shared `semver::VersionReq`
3362    /// wrapper, used by `deps-cargo` and `deps-swift`; `deps-npm`'s shared
3363    /// `compile_node_semver_range`/`node_semver::Range` wrapper, used by `deps-npm` and
3364    /// `deps-deno`). Maven/NuGet/Composer/Gradle/PyPI/Go/Bundler/Dart's own matchers use
3365    /// non-strict, ecosystem-specific range models where this premise does not hold — they must
3366    /// return `false`.
3367    ///
3368    /// # Examples
3369    ///
3370    /// ```
3371    /// use deps_core::ConcreteVersion;
3372    /// use deps_core::lsp_helpers::RequirementMatcher;
3373    ///
3374    /// struct NonStrictMatcher;
3375    /// impl RequirementMatcher for NonStrictMatcher {
3376    ///     fn matches(&self, _version: &ConcreteVersion) -> Option<bool> {
3377    ///         Some(true)
3378    ///     }
3379    ///
3380    ///     fn strict_prerelease_exclusion(&self) -> bool {
3381    ///         false
3382    ///     }
3383    /// }
3384    ///
3385    /// assert!(!NonStrictMatcher.strict_prerelease_exclusion());
3386    /// ```
3387    fn strict_prerelease_exclusion(&self) -> bool;
3388
3389    /// Whether this requirement's own grammar punches a hole that individually bans exactly
3390    /// `version`, independent of whether `version` would otherwise fall inside the
3391    /// requirement's nominal range — whether via a literal exclusion term (PyPI's `!=`/`!=X.*`,
3392    /// Composer's `!=`, Bundler's `!=`) or, for a grammar with no such operator, a gap between
3393    /// two segments of a disjoint range union (Maven's `[1.0,1.5),(1.5,2.0)`, which bans exactly
3394    /// `1.5.0`).
3395    ///
3396    /// Fix-cycle (#1571, generalized by #1590): [`crate::lsp_helpers::fallback_edit_excludes_newer`]'s
3397    /// `OriginalExcludesFallback` check needs this as an intensional signal — scanning
3398    /// `available` for "does some newer entry also match" cannot distinguish a punched-out
3399    /// hole from a fallback that legitimately exceeds the requirement's ceiling, since both
3400    /// produce the same `matches(fallback) == Some(false)` result and, when no newer entry
3401    /// happens to be listed in `available` either, the identical "nothing newer matches" scan
3402    /// outcome (critic-reproduced: `>=1.0,!=1.5.0,<2.0` and `>=1.0,<1.5` give the same verdict
3403    /// over `available = [2.0.0, 1.5.0, 1.0.0]` unless the matcher is asked directly).
3404    ///
3405    /// Default `false` — most ecosystems have no such shape (range/caret/tilde bounds only ever
3406    /// exclude by falling outside an interval, never by naming or gapping out one banned value
3407    /// inside it). A matcher overrides this exactly when its grammar CAN express such a hole —
3408    /// via a literal `!=`-style term, or, lacking one, via a gap between disjoint range/union
3409    /// segments — something `matches()` alone cannot surface as "explicitly banned" rather than
3410    /// "merely out of range".
3411    ///
3412    /// # Examples
3413    ///
3414    /// ```
3415    /// use deps_core::ConcreteVersion;
3416    /// use deps_core::lsp_helpers::RequirementMatcher;
3417    ///
3418    /// struct RangeOnlyMatcher;
3419    /// impl RequirementMatcher for RangeOnlyMatcher {
3420    ///     fn matches(&self, _version: &ConcreteVersion) -> Option<bool> {
3421    ///         Some(true)
3422    ///     }
3423    ///     fn strict_prerelease_exclusion(&self) -> bool {
3424    ///         false
3425    ///     }
3426    /// }
3427    ///
3428    /// assert!(!RangeOnlyMatcher.explicitly_excludes(&ConcreteVersion::new("1.5.0")));
3429    /// ```
3430    fn explicitly_excludes(&self, _version: &ConcreteVersion) -> bool {
3431        false
3432    }
3433}
3434
3435/// Parses `version` as [`semver::Version`] and tests it against `req`, `None` on parse failure.
3436/// Shared by [`SemverReqMatcher::matches`] and the test-only `NonStrictSemverMatcher` in
3437/// `diagnostics.rs` (identical logic, differing only in `strict_prerelease_exclusion()`).
3438pub(crate) fn semver_req_matches(
3439    req: &semver::VersionReq,
3440    version: &ConcreteVersion,
3441) -> Option<bool> {
3442    version
3443        .as_str()
3444        .parse::<semver::Version>()
3445        .ok()
3446        .map(|v| req.matches(&v))
3447}
3448
3449/// Precise [`semver::VersionReq`] matcher, constructed only through [`compile_semver_requirement`].
3450/// Named to avoid shadowing the public `crate::version_matcher::SemverMatcher`.
3451struct SemverReqMatcher(semver::VersionReq);
3452
3453impl RequirementMatcher for SemverReqMatcher {
3454    fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
3455        semver_req_matches(&self.0, version)
3456    }
3457
3458    /// `semver::VersionReq::matches` excludes pre-releases unless `requirement` itself pins to
3459    /// the same `X.Y.Z` tuple with a pre-release tag — strict SemVer 2.0.0 semantics (#299).
3460    /// Declared once, here, on the matcher type itself: any formatter that reuses
3461    /// [`compile_semver_requirement`] (`deps-cargo`'s `CargoFormatter`, `deps-swift`'s
3462    /// `SwiftFormatter`) inherits this answer for free, with no separate per-formatter flag to
3463    /// keep in sync (#1478, #1495).
3464    fn strict_prerelease_exclusion(&self) -> bool {
3465        true
3466    }
3467}
3468
3469/// Compiles `requirement` as a plain [`semver::VersionReq`], the range grammar Cargo's registry
3470/// and Swift Package Manager's `from:`/closed-range/`upToNextMajor` translations both use for
3471/// matching.
3472///
3473/// The single source of truth for `deps-cargo`'s `CargoFormatter::compile_bounded_requirement` and
3474/// `deps-swift`'s `SwiftFormatter::compile_bounded_requirement` (#1495) — mirroring how #1478 unified
3475/// the analogous `node_semver::Range` case into `deps_npm::compile_node_semver_range`.
3476///
3477/// Unlike that npm/JSR case, this function has no built-in unresolved-placeholder guard: Cargo
3478/// and Swift's [`formatter::RequirementResolution::bounded_requirement_is_unresolved`] overrides
3479/// diverge (Swift additionally rejects its own native `\(...)` string-interpolation syntax), so
3480/// each formatter must run its own `self.bounded_requirement_is_unresolved(requirement)` guard before
3481/// calling this function, rather than the guard being hardcoded here.
3482///
3483/// # Examples
3484///
3485/// ```
3486/// use deps_core::lsp_helpers::{RequirementMatcher, compile_semver_requirement};
3487/// use deps_core::{ConcreteVersion, VersionReq};
3488///
3489/// let matcher = compile_semver_requirement(&VersionReq::new("^1.0.0")).unwrap();
3490/// assert_eq!(matcher.matches(&ConcreteVersion::new("1.5.0")), Some(true));
3491/// assert_eq!(matcher.matches(&ConcreteVersion::new("2.0.0")), Some(false));
3492/// assert!(matcher.strict_prerelease_exclusion());
3493///
3494/// assert!(compile_semver_requirement(&VersionReq::new("not a semver req")).is_none());
3495/// ```
3496pub fn compile_semver_requirement(requirement: &VersionReq) -> Option<Box<dyn RequirementMatcher>> {
3497    requirement
3498        .as_str()
3499        .parse::<semver::VersionReq>()
3500        .ok()
3501        .map(|req| Box::new(SemverReqMatcher(req)) as Box<dyn RequirementMatcher>)
3502}
3503
3504#[cfg(test)]
3505mod semver_req_matcher_tests {
3506    use super::compile_semver_requirement;
3507    use crate::{ConcreteVersion, VersionReq};
3508
3509    /// #299/#1495: `semver::VersionReq::matches` itself excludes a pre-release candidate unless
3510    /// the requirement pins to that exact `X.Y.Z` tuple with a pre-release tag — this is the
3511    /// actual behavior `strict_prerelease_exclusion() == true` promises callers, not just a flag
3512    /// value. `1.5.0-alpha.1` falls inside `>=1.0.0, <2.0.0`'s stable range but must still be
3513    /// rejected, since the requirement never pins to `1.5.0` specifically.
3514    #[test]
3515    fn matches_rejects_prerelease_candidate_not_pinned_by_requirement() {
3516        let matcher = compile_semver_requirement(&VersionReq::new(">=1.0.0, <2.0.0"))
3517            .expect("valid semver requirement must compile");
3518        assert_eq!(
3519            matcher.matches(&ConcreteVersion::new("1.5.0")),
3520            Some(true),
3521            "a stable candidate in range must match"
3522        );
3523        assert_eq!(
3524            matcher.matches(&ConcreteVersion::new("1.5.0-alpha.1")),
3525            Some(false),
3526            "a pre-release candidate must be rejected unless the requirement pins to its exact \
3527             X.Y.Z tuple with a pre-release tag"
3528        );
3529    }
3530
3531    /// The one documented exception: a requirement that itself pins to the same `X.Y.Z` tuple
3532    /// with a pre-release tag does admit that exact pre-release.
3533    #[test]
3534    fn matches_accepts_prerelease_candidate_pinned_by_requirement() {
3535        let matcher = compile_semver_requirement(&VersionReq::new("=1.5.0-alpha.1"))
3536            .expect("valid semver requirement must compile");
3537        assert_eq!(
3538            matcher.matches(&ConcreteVersion::new("1.5.0-alpha.1")),
3539            Some(true)
3540        );
3541    }
3542}
3543
3544/// Whether `segment` is exactly `.` or `..`.
3545///
3546/// Unlike ordinary path characters, a literal `.`/`..` segment is not neutralized by
3547/// percent-encoding: `.` is an unreserved character (RFC 3986), so `urlencoding::encode`
3548/// leaves it untouched, and the URL parser's dot-segment removal (RFC 3986 §5.2.4) still
3549/// collapses it after encoding — `%2E` decodes back to `.` before that normalization runs.
3550/// A registry-fetch URL built as `{base}/{prefix}/{name}` (no fixed suffix after `name`)
3551/// must reject a `name`/path segment satisfying this predicate rather than encode it,
3552/// since encoding alone does not stop the collapse (#341, #349).
3553///
3554/// Shared by `deps-npm`'s scope/package segment guard and `deps-dart`'s package-name
3555/// guard — both ecosystems' registry APIs key a fetch on a bare, suffix-less path segment.
3556///
3557/// **Scope**: this predicate (and the `#365` regression sweep built around it) guards
3558/// registry-*fetch* URL builders — the sink is a request this process actually
3559/// dereferences, so a retargeted URL can make it fetch attacker-chosen data. It
3560/// deliberately does *not* extend to a "docs link"/`package_url`-style builder (the
3561/// per-ecosystem hover/display link, e.g. `deps_cargo::crate_url`, `deps_go::package_url`):
3562/// those interpolate the name into a link rendered in hover text and never fetched by
3563/// this process, so an unrejected `.`/`..` name there produces at worst a misleading
3564/// same-host link (the registry's package-listing root), not a traversal off-host (#379).
3565///
3566/// # Examples
3567///
3568/// ```
3569/// use deps_core::lsp_helpers::is_dot_segment;
3570///
3571/// assert!(is_dot_segment(".."));
3572/// assert!(is_dot_segment("."));
3573/// assert!(!is_dot_segment("left-pad"));
3574/// ```
3575pub fn is_dot_segment(segment: &str) -> bool {
3576    segment == "." || segment == ".."
3577}
3578
3579/// Whether `version` is safe to embed in a manifest `TextEdit` or completion item.
3580///
3581/// Guards every call into
3582/// [`formatter::PackageRendering::format_version_replacing`]/[`formatter::PackageRendering::format_version_for_text_edit`]
3583/// and every completion item's `insert_text`/`text_edit`.
3584///
3585/// Must be applied to the raw version string *before* formatting, never to a
3586/// formatter's output: some formatters legitimately produce structural
3587/// characters in their output from an already-validated version plus fixed,
3588/// trusted operators (e.g. PyPI's `>=1.2.3,<2`), so validating the output
3589/// would wrongly reject those.
3590///
3591/// An allowlist, not a denylist: `version` must be non-empty, at most 64
3592/// bytes, and contain only `[A-Za-z0-9.+_~:*^!-]` — the character set real
3593/// version strings use across every ecosystem this workspace supports
3594/// (SemVer, PEP 440 including epochs like `1!2.0`, Maven qualifiers, npm's
3595/// `^`/`~`/`*` range tokens, Go's `+incompatible` suffix). A denylist here
3596/// would need to anticipate every dangerous token a target manifest format
3597/// (or a build tool evaluating it, e.g. Gradle's Kotlin/Groovy DSL
3598/// interpolating `${...}` inside a version literal) could ever act on;
3599/// failing closed on an unrecognized character is cheaper and safer.
3600///
3601/// This is the single validation chokepoint shared by every producer of a
3602/// version-derived `TextEdit`/completion item in this workspace, including
3603/// OSV advisory data (an `Advisory.fixed_versions` entry is exactly as
3604/// untrusted as a registry-reported version).
3605///
3606/// # Examples
3607///
3608/// ```
3609/// use deps_core::is_safe_version_string;
3610///
3611/// assert!(is_safe_version_string("1.2.3-alpha.1+build"));
3612/// assert!(!is_safe_version_string("1.2.3\", git = \"https://evil"));
3613/// ```
3614pub fn is_safe_version_string(version: &str) -> bool {
3615    !version.is_empty()
3616        && version.len() <= 64
3617        && version.chars().all(|c| {
3618            c.is_ascii_alphanumeric()
3619                || matches!(c, '.' | '+' | '_' | '~' | ':' | '*' | '^' | '!' | '-')
3620        })
3621}
3622
3623/// Whether `segment` is safe to embed as a Maven `groupId`/`artifactId` value in a
3624/// pom.xml `TextEdit` or completion item.
3625///
3626/// Guards Maven's group/artifact completion producer, which builds a completion item's
3627/// `insert_text`/`text_edit` from one field of a Maven Central search result — a value
3628/// type distinct from a version string (see [`is_safe_version_string`]'s doc comment for
3629/// why version-derived and non-version-derived sinks each get their own allowlist).
3630///
3631/// An allowlist, not a denylist: `segment` must be non-empty, at most 128 bytes, not
3632/// exactly `.`/`..` (see [`is_dot_segment`]), and contain only `[A-Za-z0-9._-]` — the
3633/// character set real Maven Central group ids (reverse-DNS style, e.g.
3634/// `org.apache.commons`) and artifact ids (hyphen/underscore separated, e.g.
3635/// `commons-lang3`) use. Deliberately excludes `:` — the `groupId:artifactId` separator —
3636/// because this validates one already-split coordinate field at a time, never the joined
3637/// pair. Failing closed on an unrecognized character (e.g. `<`, `"`, a newline) keeps a
3638/// malicious/compromised search result from restructuring the pom.xml it's inserted into;
3639/// the dedicated `.`/`..` rejection closes the same dot-segment URL-normalization gap
3640/// [`is_dot_segment`] guards elsewhere (`artifactId` reaches a registry-fetch URL as a bare
3641/// path segment in `deps-maven::registry::metadata_urls`, unlike `groupId`, whose `.`→`/`
3642/// expansion can never itself produce a literal `..` component).
3643///
3644/// # Examples
3645///
3646/// ```
3647/// use deps_core::is_safe_maven_coordinate_segment;
3648///
3649/// assert!(is_safe_maven_coordinate_segment("org.apache.commons"));
3650/// assert!(is_safe_maven_coordinate_segment("commons-lang3"));
3651/// assert!(!is_safe_maven_coordinate_segment("commons</artifactId><parent>"));
3652/// assert!(!is_safe_maven_coordinate_segment(".."));
3653/// ```
3654pub fn is_safe_maven_coordinate_segment(segment: &str) -> bool {
3655    !segment.is_empty()
3656        && segment.len() <= 128
3657        && !is_dot_segment(segment)
3658        && segment
3659            .chars()
3660            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
3661}
3662
3663/// Builds the Maven repository URL path (`"group/path/segments/artifact"`) for a
3664/// `groupId`/`artifactId` coordinate pair, or `None` if either fails validation.
3665///
3666/// The single shared home for `groupId:artifactId` -> URL-path construction (#702):
3667/// validates `group_id` and `artifact_id` as whole strings via
3668/// [`is_safe_maven_coordinate_segment`] (preserving its 128-byte total-length cap per
3669/// segment) *and* validates every `.`-separated component of `group_id` individually,
3670/// rejecting an empty component outright. The whole-string check alone is not enough —
3671/// it permits internal `.` characters, so a naive `group_id.replace('.', "/")` on a
3672/// coordinate like `"com..evil"` would produce the empty path segment `"com//evil"`; the
3673/// per-component check closes that gap without dropping the whole-string length bound a
3674/// pure per-segment `split('.')` validation would lose (a 10-segment, 128-byte-each group
3675/// would otherwise pass at 1280 bytes total).
3676///
3677/// # Examples
3678///
3679/// ```
3680/// use deps_core::maven_coordinate_path;
3681///
3682/// assert_eq!(
3683///     maven_coordinate_path("com.example", "artifact").as_deref(),
3684///     Some("com/example/artifact")
3685/// );
3686/// assert_eq!(
3687///     maven_coordinate_path("junit", "junit").as_deref(),
3688///     Some("junit/junit")
3689/// );
3690/// assert_eq!(maven_coordinate_path("com..evil", "artifact"), None);
3691/// ```
3692pub fn maven_coordinate_path(group_id: &str, artifact_id: &str) -> Option<String> {
3693    if !is_safe_maven_coordinate_segment(group_id) || !is_safe_maven_coordinate_segment(artifact_id)
3694    {
3695        return None;
3696    }
3697    let group_segments: Vec<&str> = group_id.split('.').collect();
3698    if group_segments
3699        .iter()
3700        .any(|segment| !is_safe_maven_coordinate_segment(segment))
3701    {
3702        return None;
3703    }
3704    Some(format!("{}/{artifact_id}", group_segments.join("/")))
3705}
3706
3707/// Whether `url` is safe to embed as a Swift Package Manager repository URL in a
3708/// Package.swift `TextEdit` or completion item.
3709///
3710/// Guards Swift's URL-completion producer, which builds a `.package(url: "...")`
3711/// string-literal replacement from a package registry search result's URL — a value
3712/// type distinct from a version string (see [`is_safe_version_string`]'s doc comment for
3713/// why version-derived and non-version-derived sinks each get their own allowlist).
3714///
3715/// An allowlist, not a denylist: `url` must be non-empty, at most 2048 bytes, start with
3716/// `https://` — every real Swift package registry response is HTTPS (GitHub's `html_url`
3717/// never downgrades), so accepting plain `http://` would only hand a
3718/// compromised/malicious registry a transport-downgrade lever for zero legitimate
3719/// benefit — and otherwise contain only RFC 3986 URL characters (`A-Za-z0-9` plus
3720/// `` -._~:/?#[]@!$&'()*+,;=% ``). Deliberately excludes `"`, `\`, control characters,
3721/// and whitespace — none of those are valid unencoded URL characters, and any of them
3722/// could close the surrounding Swift string literal or otherwise corrupt the manifest.
3723/// Failing closed on an unrecognized character keeps a malicious/compromised search
3724/// result from breaking out of the string it's inserted into.
3725///
3726/// # Examples
3727///
3728/// ```
3729/// use deps_core::is_safe_registry_url;
3730///
3731/// assert!(is_safe_registry_url("https://github.com/apple/swift-nio"));
3732/// assert!(!is_safe_registry_url("https://evil.example\", .exact(\"1\")) // "));
3733/// ```
3734pub fn is_safe_registry_url(url: &str) -> bool {
3735    !url.is_empty()
3736        && url.len() <= 2048
3737        && url.starts_with("https://")
3738        && url.chars().all(|c| {
3739            c.is_ascii_alphanumeric()
3740                || matches!(
3741                    c,
3742                    '-' | '.'
3743                        | '_'
3744                        | '~'
3745                        | ':'
3746                        | '/'
3747                        | '?'
3748                        | '#'
3749                        | '['
3750                        | ']'
3751                        | '@'
3752                        | '!'
3753                        | '$'
3754                        | '&'
3755                        | '\''
3756                        | '('
3757                        | ')'
3758                        | '*'
3759                        | '+'
3760                        | ','
3761                        | ';'
3762                        | '='
3763                        | '%'
3764                )
3765        })
3766}
3767
3768/// Whether `name` is safe to embed as a package name in a manifest `TextEdit` or
3769/// completion item.
3770///
3771/// Guards every arm of `create_package_completion_item`
3772/// (`crates/deps-lsp/src/handlers/completion.rs`) as a single upfront check, applied
3773/// before the raw `name` reaches any ecosystem-specific snippet — including Maven and
3774/// Swift, which additionally validate a *derived* value on top of this gate
3775/// ([`is_safe_maven_coordinate_segment`] on each split coordinate segment,
3776/// [`is_safe_registry_url`] on the constructed URL) because a value type distinct from
3777/// the raw name needs its own allowlist — see [`is_safe_version_string`]'s doc comment
3778/// for why version-derived and non-version-derived sinks each get their own allowlist.
3779/// [`PackageName::new`](crate::PackageName::new) is documented as never validating or
3780/// modifying its input, so this predicate is the first gate a registry-reported name
3781/// passes through before reaching a manifest. Two sinks that key a bare TOML/YAML
3782/// entry by `name` (Cargo/PyPI, Dart) additionally quote that key in the snippet, since
3783/// `.` and `@` are legal here but would otherwise be read as TOML's dotted-key
3784/// separator or break a YAML plain scalar.
3785///
3786/// An allowlist, not a denylist: `name` must be non-empty, at most 256 bytes, and
3787/// contain only `[A-Za-z0-9._@:/~-]` — the character set real package names use across
3788/// every ecosystem this predicate guards: Cargo/PyPI/Dart/NuGet/Bundler
3789/// (alphanumeric, `-`, `_`, `.`), npm/Deno scoped names (`@scope/name`, adding `@` and
3790/// `/`), Composer (`vendor/package`, `/`), Go module paths (domain-qualified paths like
3791/// `github.com/org/repo`, `/`, `.`, and `~` — legal in a Go path element and already
3792/// allowed by [`is_safe_version_string`]/[`is_safe_registry_url`]), and Gradle's
3793/// colon-delimited `group:artifact` short form (`:`). A denylist here would need to
3794/// anticipate every dangerous token a target manifest format (TOML/JSON/YAML/XML
3795/// string literals, a live Kotlin/Groovy build-script DSL) could ever act on; failing
3796/// closed on an unrecognized character — notably `"`, `'`, `<`, `>`, `` ` ``, and all
3797/// control characters/newlines — is cheaper and safer.
3798///
3799/// # Examples
3800///
3801/// ```
3802/// use deps_core::is_safe_package_name;
3803///
3804/// assert!(is_safe_package_name("serde"));
3805/// assert!(is_safe_package_name("@scope/name"));
3806/// assert!(is_safe_package_name("org.apache.commons:commons-lang3"));
3807/// assert!(!is_safe_package_name("evil\"\nbackdoor = \"9.9.9"));
3808/// ```
3809pub fn is_safe_package_name(name: &str) -> bool {
3810    !name.is_empty()
3811        && name.len() <= 256
3812        && name.chars().all(|c| {
3813            c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '@' | ':' | '/' | '-' | '~')
3814        })
3815}
3816
3817/// Whether `name` is safe to embed as a Cargo feature name in a manifest `TextEdit` or
3818/// completion item.
3819///
3820/// Guards [`crate::completion::build_feature_completion`] the same way
3821/// [`is_safe_package_name`] guards [`crate::completion::build_package_completion`]:
3822/// `feature_name` there is registry-supplied (a key of `Version::features()`, read from
3823/// the index JSON's `features`/`features2` maps), not user-typed, so it must pass this
3824/// allowlist before reaching `label`/`insert_text`/`text_edit`/`sort_text`.
3825///
3826/// An allowlist, narrower than [`is_safe_package_name`]: Cargo's own feature-name grammar
3827/// (<https://doc.rust-lang.org/cargo/reference/features.html#the-features-section>) permits
3828/// Unicode `XID_Start`/`XID_Continue` characters, but crates.io's publish-time check — the
3829/// registry `deps-cargo` actually talks to — is stricter and only accepts ASCII
3830/// alphanumerics, `_`, `-`, and `+`; this predicate matches that stricter, ASCII-only set
3831/// (plus `.`, this predicate's own addition, not part of crates.io's check, allowed because
3832/// it's already permitted in [`is_safe_package_name`] and carries the same low risk here).
3833/// `dep:`, `?`, and `/` are never part of a feature *name* (an index JSON map key) either
3834/// way — they only ever appear inside a feature's *value* list (e.g.
3835/// `"avif" = ["dep:ravif", "rgb?/serde"]`), which this predicate never sees. A
3836/// registry-supplied string containing any of those characters is therefore not a
3837/// plausible feature name regardless of intent, so it is rejected the same as any other
3838/// out-of-allowlist character.
3839///
3840/// Tradeoff, accepted rather than incidental: a legitimate feature name published with a
3841/// Unicode `XID` character to an *alternate* (non-crates.io) registry — which Cargo's own
3842/// grammar permits but crates.io's check does not — would fail this allowlist and be
3843/// silently dropped rather than rendered. Failing closed on a character class this
3844/// predicate cannot cheaply distinguish from a homograph/bidi-spoofing attempt is judged
3845/// safer than accepting the full Cargo grammar, consistent with [`is_safe_package_name`]'s
3846/// own ASCII-only rationale.
3847///
3848/// # Examples
3849///
3850/// ```
3851/// use deps_core::is_safe_feature_name;
3852///
3853/// assert!(is_safe_feature_name("derive"));
3854/// assert!(is_safe_feature_name("std_alloc-v2+extra"));
3855/// assert!(!is_safe_feature_name("dep:ravif"));
3856/// assert!(!is_safe_feature_name("rgb?/serde"));
3857/// assert!(!is_safe_feature_name("evil\"\nbackdoor = \"9.9.9"));
3858/// ```
3859pub fn is_safe_feature_name(name: &str) -> bool {
3860    !name.is_empty()
3861        && name.len() <= 256
3862        && name
3863            .chars()
3864            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '.'))
3865}
3866
3867/// Logs a `tracing::warn!` for a value rejected by an `is_safe_*` predicate (or an
3868/// equivalent value-rejecting gate) before it reaches a manifest edit or registry URL.
3869///
3870/// Deliberately logs only `value`'s byte length, never its content: `value` is
3871/// registry-controlled and, by construction, already failed an allowlist — logging it
3872/// verbatim at `warn` would let a malicious/compromised registry response inject arbitrary
3873/// content into this project's own log stream (a second-order log-injection concern),
3874/// mirroring why `deps-pypi`'s `truncate_for_log` bounds a logged excerpt instead of
3875/// logging a value verbatim. This is this helper's own contract, not a claim that every
3876/// `tracing` call site in the workspace avoids logging a raw value — e.g. an OSV
3877/// malformed-`fixed`-version warning predates this helper and logs its rejected value
3878/// directly; that is an unrelated call site, not a place this helper is used.
3879///
3880/// `gate` names the predicate/guard that rejected `value` (e.g.
3881/// `"is_safe_maven_coordinate_segment"`); `context` is a short description of the call site
3882/// (e.g. `"maven groupId completion"`).
3883///
3884/// # Examples
3885///
3886/// ```
3887/// use deps_core::lsp_helpers::warn_rejected_value;
3888///
3889/// warn_rejected_value("is_safe_version_string", "code lens latest version", "1.0.0\"; evil");
3890/// ```
3891pub fn warn_rejected_value(gate: &str, context: &str, value: &str) {
3892    tracing::warn!(
3893        gate,
3894        context,
3895        len = value.len(),
3896        "rejected unsafe value before manifest/registry sink"
3897    );
3898}
3899
3900/// Logs a [`warn_rejected_value`] warning and builds the [`DepsError::PackageNotFound`] a
3901/// caller returns after its own `is_dot_segment`/`has_dot_segment` predicate rejects `name`.
3902///
3903/// Extracted from four equivalent `warn_rejected_value` + `PackageNotFound`
3904/// constructions in `deps-dart`, `deps-composer`, `deps-nuget`, and `deps-npm`
3905/// (deps-lsp#929 item 2) — `deps-npm`'s `gate` label was `"npm_dot_segment_guard"` rather
3906/// than the other three's `"is_dot_segment"` before this extraction normalized it. Each
3907/// crate keeps its own dot-segment *predicate* and the call to it exactly where it is
3908/// today; only this trailing boilerplate is shared. Deliberately does **not** fold in
3909/// `deps-go`'s guard, which returns `DepsError::InvalidPackageName`, not `PackageNotFound`.
3910///
3911/// # Examples
3912///
3913/// ```
3914/// use deps_core::lsp_helpers::dot_segment_rejection_error;
3915/// use deps_core::DepsError;
3916///
3917/// let err = dot_segment_rejection_error(
3918///     "is_dot_segment",
3919///     "example package metadata request URL",
3920///     "..",
3921///     "example",
3922/// );
3923/// assert!(matches!(err, DepsError::PackageNotFound { .. }));
3924/// ```
3925#[must_use]
3926pub fn dot_segment_rejection_error(
3927    gate: &str,
3928    context: &str,
3929    name: &str,
3930    registry: &'static str,
3931) -> DepsError {
3932    warn_rejected_value(gate, context, name);
3933    DepsError::PackageNotFound {
3934        package: name.into(),
3935        registry,
3936    }
3937}
3938
3939/// Converts a domain [`url::Url`] back into a `tower_lsp_server::ls_types::Uri` for
3940/// embedding into an actual LSP response object (`WorkspaceEdit`, `Location`, a code-lens
3941/// command argument, ...).
3942///
3943/// This is the one blessed conversion path from `deps-core`'s internal `url::Url` domain
3944/// type to `tower_lsp_server`'s `Uri` — ecosystem crates and `deps-core` itself must route
3945/// through it rather than re-deriving the conversion (e.g. `uri.as_str().parse()`) locally,
3946/// so a future change to how URLs round-trip through the LSP wire format only needs fixing
3947/// in one place. It mirrors `deps-lsp`'s own `lsp_types_interop::to_lsp_uri` (issue #1071):
3948/// `deps-core` cannot reuse that function (the dependency direction points the other way)
3949/// or express this as a `From` impl (neither `url::Url` nor `ls_types::Uri` is local to
3950/// `deps-core` — the same orphan-rule constraint that makes the `deps-lsp` boundary a pair
3951/// of free functions instead of a trait impl), so this is `deps-core`'s independent
3952/// counterpart, needed because `lsp_helpers` still builds real `ls_types` response objects
3953/// directly even though `Dependency`/`ParseResult` no longer carry an `ls_types::Uri` (see
3954/// `lib.rs`'s "LSP type stability" doc section).
3955///
3956/// # Panics
3957///
3958/// Panics if `url`'s string form does not round-trip into an `Uri`. In practice this
3959/// never happens: every `url::Url` reaching `lsp_helpers` originates from a real
3960/// editor-opened document (an `ls_types::Uri` converted to `Url` at the `deps-lsp`
3961/// boundary), so failing to convert it back indicates upstream corruption, not
3962/// attacker-controlled manifest content.
3963///
3964/// # Examples
3965///
3966/// ```
3967/// use deps_core::to_ls_uri;
3968/// use url::Url;
3969///
3970/// let url = Url::parse("file:///tmp/Cargo.toml").unwrap();
3971/// let uri = to_ls_uri(&url);
3972/// assert_eq!(uri.as_str(), "file:///tmp/Cargo.toml");
3973/// ```
3974#[cfg(feature = "lsp-responses")]
3975#[must_use]
3976pub fn to_ls_uri(url: &url::Url) -> tower_lsp_server::ls_types::Uri {
3977    url.as_str()
3978        .parse()
3979        .unwrap_or_else(|e| panic!("document URL {url} did not round-trip to an LSP Uri: {e}"))
3980}
3981
3982/// Builds a single-entry [`tower_lsp_server::ls_types::WorkspaceEdit::changes`] map replacing `range` in `uri`
3983/// with `new_text`.
3984///
3985/// Shared by every quickfix/refactor code action in `code_actions` that edits exactly
3986/// one span in the current document (`build_vulnerability_fix_action`,
3987/// `build_unsatisfiable_fix_action`, and the plain "update to `<version>`" loop in
3988/// [`generate_code_actions`]) — and by any ecosystem crate hand-building a
3989/// `WorkspaceEdit` for a single-span fix instead of reimplementing this map shape
3990/// locally.
3991///
3992/// # Panics
3993///
3994/// Panics if `uri` does not round-trip through [`to_ls_uri`] — see that function's own
3995/// `# Panics` section.
3996///
3997/// # Examples
3998///
3999/// ```
4000/// use deps_core::single_file_edit;
4001/// use deps_core::position::{Position, Range};
4002/// use url::Url;
4003///
4004/// let url = Url::parse("file:///tmp/Cargo.toml").unwrap();
4005/// let range = Range::new(Position::new(0, 0), Position::new(0, 5));
4006/// let edits = single_file_edit(&url, range, "1.2.3".to_string());
4007///
4008/// assert_eq!(edits.len(), 1);
4009/// ```
4010#[cfg(feature = "lsp-responses")]
4011#[must_use]
4012pub fn single_file_edit(
4013    uri: &url::Url,
4014    range: Range,
4015    new_text: String,
4016) -> HashMap<tower_lsp_server::ls_types::Uri, Vec<tower_lsp_server::ls_types::TextEdit>> {
4017    let mut edits = HashMap::new();
4018    edits.insert(
4019        to_ls_uri(uri),
4020        vec![tower_lsp_server::ls_types::TextEdit {
4021            range: range.into(),
4022            new_text,
4023        }],
4024    );
4025    edits
4026}
4027
4028/// Strips every whitespace character from `s`, so two textually-equivalent strings that
4029/// differ only in spacing compare equal.
4030///
4031/// Shared by every no-op/literal-match guard across `code_actions` and `code_lenses`
4032/// (`build_vulnerability_fix_action`'s N1 guard, `generate_code_actions`'s REFACTOR-loop
4033/// guard, `literal_span_matches`, and `collect_update_all_edits`'s no-op guard), all of
4034/// which compare a declared requirement
4035/// string against a differently-normalized counterpart — e.g. pep508's `>=1.7, <2.0` vs. a
4036/// formatter's `>=1.7,<2.0`.
4037pub(crate) fn strip_whitespace(s: &str) -> String {
4038    s.chars().filter(|c| !c.is_whitespace()).collect()
4039}
4040
4041/// Slices `content` over an LSP `Range` using a pre-built `LineOffsetTable`, returning
4042/// `""` for an inverted or out-of-bounds range instead of panicking.
4043///
4044/// `table` is document-invariant — callers iterating over multiple dependencies in the
4045/// same document must build it once and reuse it, rather than rebuilding it (an O(n)
4046/// scan of `content`) per dependency.
4047pub(crate) fn slice_for_range<'a>(
4048    content: &'a str,
4049    table: &LineOffsetTable,
4050    range: Range,
4051) -> &'a str {
4052    let start = table.position_to_byte_offset(content, range.start);
4053    let end = table.position_to_byte_offset(content, range.end);
4054    if start > end {
4055        return "";
4056    }
4057    content.get(start..end).unwrap_or("")
4058}
4059
4060/// Checks whether `slice` — `content` sliced over a dependency's `version_range` — still
4061/// holds the literal version text declared by `requirement`.
4062///
4063/// Whitespace is stripped from both sides before comparison, since pep508's normalized
4064/// requirement string can diverge from the original source spacing (PyPI's `>=1.7,<2.0`
4065/// renders as `>=1.7, <2.0`) while `version_range` still spans the un-normalized source.
4066///
4067/// The second branch accepts `slice` wrapped in brackets matching `requirement` — the
4068/// exact inverse of NuGet's parser wrapping a bare source version as `format!("[{v}]")`
4069/// (`crates/deps-nuget/src/parser.rs`). This is deliberately **not** a symmetric bracket
4070/// strip: NuGet's `Version="1.0.0"` produces requirement `[1.0.0]` over a bare-literal
4071/// span, so a *symmetric* strip (stripping one bracket pair from both operands) would
4072/// compare `1.0.0` against `1.0.0` — coincidentally correct there, but the same strip
4073/// applied to `Version="[1.0.0]"` (requirement `[[1.0.0]]`, a spelling
4074/// `crates/deps-nuget/src/formatter.rs` explicitly supports) leaves `[1.0.0]` vs
4075/// `1.0.0` and **falsely rejects** an editable dependency. Wrapping only the slice side
4076/// handles both spellings without that false reject.
4077pub(crate) fn literal_span_matches(slice: &str, requirement: &str) -> bool {
4078    let norm_slice = strip_whitespace(slice);
4079    let norm_req = strip_whitespace(requirement);
4080    norm_slice == norm_req || format!("[{norm_slice}]") == norm_req
4081}
4082
4083/// Whether `version_range`'s slice of `content` still holds `dep`'s own declared literal
4084/// version text.
4085///
4086/// The same literal-span discipline [`generate_code_actions`] and
4087/// `collect_update_all_edits` already apply before writing a `TextEdit` there, generalized
4088/// (issue #919) for any caller about to treat `version_range` as an editable literal: a
4089/// completion context, or a raw-text-scanning ecosystem's own completion dispatch (Maven,
4090/// Gradle) that never goes through [`crate::completion::detect_completion_context`] at all.
4091///
4092/// Returns `false` — not editable — when the slice is syntactically reference-shaped (see
4093/// below), or the slice doesn't textually match the declared literal. When `dep` has no
4094/// [`Dependency::version_requirement`] at all, an empty (or whitespace-only) slice is admitted
4095/// — e.g. Maven's `<version></version>`, which has no text for its parser to capture and thus
4096/// nothing that could be misread as a reference/wildcard (#1161) — but a *non-empty* slice is
4097/// still rejected, since there is then no requirement to validate it against.
4098///
4099/// The reference-shape check runs **independently** of the text comparison (#919 C1): an
4100/// *unresolved* Maven `${property}` or Gradle `$var`/`${var}` interpolation is left by its
4101/// parser exactly as-is in both `version_range`'s slice and `version_requirement` (there is
4102/// nothing else to put there), so the two would otherwise textually agree and wrongly pass —
4103/// this is precisely the corruption #919 was filed over, and the dominant real-world Maven
4104/// case (a version inherited from a parent/BOM POM this crate cannot resolve) always lands
4105/// here. A slice containing `$` anywhere is rejected up front, before ever comparing text —
4106/// this single check subsumes both a whole-value `$var`/`${var}` interpolation and a Gradle
4107/// GString with the reference embedded mid-string (`1.0.$patch`), which `resolve_variable_ref`
4108/// only ever resolves in its whole-value form, leaving the mid-string case byte-identical
4109/// between slice and requirement and otherwise invisible to a `starts_with`-only check. A
4110/// slice that starts with `*` is rejected the same way *unless* [`crate::is_existence_wildcard_str`]
4111/// recognizes it as the project-wide existence-wildcard spelling (synthesized by `deps-npm`,
4112/// `deps-composer`, ...) — that case must keep offering a full version list at the exact
4113/// moment a user wants to replace the wildcard with a pin, while a `*anchor` YAML alias (any
4114/// other leading-`*` shape) still gets rejected.
4115///
4116/// An empty `version_requirement` is deliberately **not** special-cased here (unlike
4117/// `generate_code_actions`'s "nothing to update" early return, which does not apply to
4118/// completion): `serde = ""` / `"lodash": ""` — what an editor's auto-closing quotes produce
4119/// the instant the opening quote is typed — must still offer the full version list.
4120/// `literal_span_matches` alone already handles this correctly: `("", "")` matches (empty
4121/// slice, empty requirement — admitted), while a non-empty non-literal slice against an empty
4122/// requirement still fails to match (rejected).
4123///
4124/// Compares against [`Dependency::version_literal`] when the ecosystem provides one, falling
4125/// back to `version_requirement` otherwise (see that method's doc for why).
4126///
4127/// Builds its own [`LineOffsetTable`] for `content` on every call rather than accepting a
4128/// caller-built one — unlike `slice_for_range`'s multi-dependency callers (`generate_code_actions`,
4129/// `code_lenses`), every current call site invokes this at most once per request (a single
4130/// matched dependency), so a table-accepting overload would add API surface with no caller to
4131/// exercise it.
4132///
4133/// # Examples
4134///
4135/// ```
4136/// use deps_core::lsp_helpers::dependency_version_range_is_literal;
4137/// use deps_core::{Dependency, PackageName, VersionReq};
4138/// use std::any::Any;
4139/// use tower_lsp_server::ls_types::{Position, Range};
4140///
4141/// struct MockDep {
4142///     name: PackageName,
4143///     version_req: VersionReq,
4144///     version_range: deps_core::position::Range,
4145/// }
4146/// impl Dependency for MockDep {
4147///     fn name(&self) -> &PackageName {
4148///         &self.name
4149///     }
4150///     fn name_range(&self) -> deps_core::position::Range {
4151///         deps_core::position::Range::default()
4152///     }
4153///     fn version_requirement(&self) -> Option<&VersionReq> {
4154///         Some(&self.version_req)
4155///     }
4156///     fn version_range(&self) -> Option<deps_core::position::Range> {
4157///         Some(self.version_range)
4158///     }
4159///     fn source(&self) -> deps_core::parser::DependencySource {
4160///         deps_core::parser::DependencySource::Registry
4161///     }
4162///     fn as_any(&self) -> &dyn Any {
4163///         self
4164///     }
4165/// }
4166///
4167/// // A plain literal version is admitted.
4168/// let content = r#"serde = "1.0.0""#;
4169/// let dep = MockDep {
4170///     name: PackageName::new("serde"),
4171///     version_req: VersionReq::new("1.0.0"),
4172///     version_range: Range::new(Position::new(0, 9), Position::new(0, 14)).into(),
4173/// };
4174/// assert!(dependency_version_range_is_literal(&dep, content, dep.version_range.into()));
4175///
4176/// // A Maven `${property}` interpolation is rejected even when unresolved — slice and
4177/// // requirement are byte-identical raw reference text, which the shape check catches
4178/// // independently of the (otherwise-matching) text comparison.
4179/// let content = r"<version>${slf4j.version}</version>";
4180/// let dep = MockDep {
4181///     name: PackageName::new("slf4j-api"),
4182///     version_req: VersionReq::new("${slf4j.version}"),
4183///     version_range: Range::new(Position::new(0, 9), Position::new(0, 25)).into(),
4184/// };
4185/// assert!(!dependency_version_range_is_literal(&dep, content, dep.version_range.into()));
4186/// ```
4187#[must_use]
4188pub fn dependency_version_range_is_literal(
4189    dep: &dyn Dependency,
4190    content: &str,
4191    version_range: Range,
4192) -> bool {
4193    if version_range == Range::default() {
4194        // `Range::default()` ((0,0)-(0,0)) is a purely syntactic "not a real position"
4195        // sentinel, never a genuine in-document location — `deps-gradle`'s catalog context
4196        // deliberately returns it for a `version.ref = "alias"` reference (#931), including a
4197        // dangling or still-being-typed one, so this function must always reject it
4198        // unconditionally, before the `version_requirement()` check below ever runs.
4199        return false;
4200    }
4201
4202    let table = LineOffsetTable::new(content);
4203    let slice = slice_for_range(content, &table, version_range);
4204    let trimmed = slice.trim();
4205
4206    let Some(version_req) = dep.version_requirement() else {
4207        // No parsed requirement at all — e.g. Maven's `<version></version>`, which has no
4208        // text for its parser to capture. An empty span here has no existing text that could
4209        // be misread as a reference/wildcard, so it is always safe to offer completion
4210        // (#1161) — now that the `Range::default()` sentinel above is rejected first, this can
4211        // only be a genuine in-document position.
4212        return trimmed.is_empty();
4213    };
4214    if trimmed.contains('$')
4215        || (trimmed.starts_with('*') && !crate::is_existence_wildcard_str(trimmed))
4216    {
4217        return false;
4218    }
4219    let literal_target = dep
4220        .version_literal()
4221        .unwrap_or_else(|| version_req.as_str());
4222    literal_span_matches(slice, literal_target)
4223}
4224
4225#[cfg(test)]
4226#[expect(
4227    clippy::string_slice,
4228    reason = "fixtures are single-line ASCII literals with hand-computed byte offsets"
4229)]
4230mod tests {
4231    use super::*;
4232    use crate::lsp_helpers::test_support::*;
4233    use crate::osv::SkipReason;
4234    use crate::{DependencySource, PackageName, VersionReq};
4235
4236    // --- gossip_cooldown_for (issue #1456, spec 072 FR-008/FR-011, S2 tri-state) ---
4237
4238    fn gossip_findings_fixture(version: &str, end_unix_secs: i64) -> GossipFindings {
4239        GossipFindings {
4240            version: version.to_string(),
4241            cooldown: Some(crate::GossipCooldown {
4242                end: PublishTime::from_unix_secs(end_unix_secs),
4243                risk: crate::GossipRiskLevel::High,
4244            }),
4245            low_usage: None,
4246        }
4247    }
4248
4249    fn gossip_findings_fixture_not_in_cooldown(version: &str) -> GossipFindings {
4250        GossipFindings {
4251            version: version.to_string(),
4252            cooldown: None,
4253            low_usage: None,
4254        }
4255    }
4256
4257    #[test]
4258    fn gossip_cooldown_for_none_prefetch_is_unavailable() {
4259        let name = PackageName::new("vite");
4260        assert!(matches!(
4261            gossip_cooldown_for(None, &name, "8.3.1", PublishTime::now()),
4262            GossipCooldownLookup::Unavailable
4263        ));
4264    }
4265
4266    #[test]
4267    fn gossip_cooldown_for_matching_active_version_is_active() {
4268        let name = PackageName::new("vite");
4269        let mut prefetch = HashMap::new();
4270        prefetch.insert(name.clone(), gossip_findings_fixture("8.3.1", 2_000));
4271
4272        let lookup = gossip_cooldown_for(
4273            Some(&prefetch),
4274            &name,
4275            "8.3.1",
4276            PublishTime::from_unix_secs(1_000),
4277        );
4278        assert_eq!(lookup, GossipCooldownLookup::Active);
4279    }
4280
4281    #[test]
4282    fn gossip_cooldown_for_version_mismatch_is_unavailable() {
4283        let name = PackageName::new("vite");
4284        let mut prefetch = HashMap::new();
4285        prefetch.insert(name.clone(), gossip_findings_fixture("8.3.1", 2_000));
4286
4287        // FR-008: the prefetch entry is for 8.3.1, but the caller is displaying 8.4.0 (a
4288        // newer release the prefetch hasn't caught up with yet) — must be `Unavailable`,
4289        // not the stale 8.3.1 answer.
4290        let lookup = gossip_cooldown_for(
4291            Some(&prefetch),
4292            &name,
4293            "8.4.0",
4294            PublishTime::from_unix_secs(1_000),
4295        );
4296        assert!(matches!(lookup, GossipCooldownLookup::Unavailable));
4297    }
4298
4299    #[test]
4300    fn gossip_cooldown_for_ended_cooldown_self_clears_to_not_active_at_read_time() {
4301        let name = PackageName::new("vite");
4302        let mut prefetch = HashMap::new();
4303        prefetch.insert(name.clone(), gossip_findings_fixture("8.3.1", 1_000));
4304
4305        // FR-011: `end` compared against `now` at read time, never a stored bool — a `now`
4306        // past `end` must self-clear to `NotActive` (GOSSIP data is present and version-
4307        // matched, it just no longer reports an active cooldown), never `Unavailable`
4308        // (which would wrongly let the caller fall back to the local heuristic).
4309        let lookup = gossip_cooldown_for(
4310            Some(&prefetch),
4311            &name,
4312            "8.3.1",
4313            PublishTime::from_unix_secs(2_000),
4314        );
4315        assert!(matches!(lookup, GossipCooldownLookup::NotActive));
4316    }
4317
4318    /// Spec 075 FR-005 (R-S3), inverting the earlier `..._is_not_active` expectation: GOSSIP
4319    /// data present, version-matched, but explicitly reporting no cooldown at all
4320    /// (`cooldown: None` — no `COOLDOWN` finding and no `cooldownEnd` fallback) must be
4321    /// `Unavailable`, not `NotActive` — `NotActive` now means only "a parsed, past `end` was
4322    /// found", so the caller falls back to the local heuristic instead of reading this as an
4323    /// authoritative "not in cooldown" answer (the old mapping was fail-open for a write path).
4324    #[test]
4325    fn gossip_cooldown_for_present_but_no_cooldown_data_is_unavailable() {
4326        let name = PackageName::new("vite");
4327        let mut prefetch = HashMap::new();
4328        prefetch.insert(
4329            name.clone(),
4330            gossip_findings_fixture_not_in_cooldown("8.3.1"),
4331        );
4332
4333        let lookup = gossip_cooldown_for(Some(&prefetch), &name, "8.3.1", PublishTime::now());
4334        assert!(matches!(lookup, GossipCooldownLookup::Unavailable));
4335    }
4336
4337    #[test]
4338    fn gossip_cooldown_for_absent_name_is_unavailable() {
4339        let name = PackageName::new("vite");
4340        let other = PackageName::new("left-pad");
4341        let mut prefetch = HashMap::new();
4342        prefetch.insert(other, gossip_findings_fixture("1.3.0", 2_000));
4343
4344        assert!(matches!(
4345            gossip_cooldown_for(Some(&prefetch), &name, "8.3.1", PublishTime::now()),
4346            GossipCooldownLookup::Unavailable
4347        ));
4348    }
4349
4350    // --- cooldown_disposition (spec 075 FR-004/NFR-002) ---
4351
4352    /// Fix-cycle item 8 (M3/NFR-002): a stored fallback candidate is re-evaluated against
4353    /// read-time `now`/cooldown window, not trusted as a fetch-time snapshot. A
4354    /// fallback whose own `published_at` no longer clears a (narrower, read-time)
4355    /// cooldown window must read as absent, not as a stale "still usable" answer.
4356    #[test]
4357    fn cooldown_disposition_read_time_reevaluates_the_stored_fallback() {
4358        let now = PublishTime::from_unix_secs(10_000);
4359        let versions = PackageVersions::new("2.0.0".into(), Arc::from(vec!["2.0.0".into()]))
4360            .with_published_at(PublishTime::from_unix_secs(9_900)) // 100s old
4361            .with_cooldown_fallback(CooldownFallback::new(
4362                "1.9.0".into(),
4363                PublishTime::from_unix_secs(9_800), // 200s old at read time
4364            ));
4365        let name = PackageName::new("pkg");
4366
4367        // At fetch time, a 150s cooldown window: latest (100s old) is blocked, the fallback
4368        // (200s old) already clears it.
4369        let disposition = cooldown_disposition(
4370            &versions,
4371            &name,
4372            crate::freshness::FreshnessSettings::Enabled {
4373                cooldown: CooldownWindow::from_secs(150),
4374            },
4375            None,
4376            now,
4377        );
4378        assert!(
4379            matches!(
4380                disposition,
4381                CooldownDisposition::Blocked {
4382                    fallback: Some(_),
4383                    ..
4384                }
4385            ),
4386            "got: {disposition:?}"
4387        );
4388
4389        // Read time later widens the configured cooldown to 10_000s (e.g. a stricter
4390        // `--cooldown` override) — the SAME stored fallback (still only 200s old) no longer
4391        // clears it and must read as absent, never as the earlier fetch-time answer.
4392        let disposition = cooldown_disposition(
4393            &versions,
4394            &name,
4395            crate::freshness::FreshnessSettings::Enabled {
4396                cooldown: CooldownWindow::from_secs(10_000),
4397            },
4398            None,
4399            now,
4400        );
4401        assert!(
4402            matches!(
4403                disposition,
4404                CooldownDisposition::Blocked { fallback: None, .. }
4405            ),
4406            "a fallback that no longer clears cooldown at read time must not be surfaced: {disposition:?}"
4407        );
4408    }
4409
4410    /// #919: a plain literal version — `version_range`'s slice equals the declared
4411    /// requirement exactly — must be admitted as editable.
4412    #[test]
4413    fn test_dependency_version_range_is_literal_plain_literal_admitted() {
4414        let content = r#"serde = "1.0.0""#;
4415        let dep = MockDep {
4416            name: PackageName::new("serde"),
4417            version_req: VersionReq::new("1.0.0"),
4418            version_range: Range::new(Position::new(0, 9), Position::new(0, 14)),
4419            name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
4420        };
4421
4422        assert!(dependency_version_range_is_literal(
4423            &dep,
4424            content,
4425            dep.version_range,
4426        ));
4427    }
4428
4429    /// #919: a Maven-style `${property}` interpolation — `version_range` slices to the raw
4430    /// placeholder text while the parser resolves `version_requirement` to the property's
4431    /// value (a different string) — must be rejected, since a completion/edit there would
4432    /// splice text into the interpolation instead of updating a version.
4433    #[test]
4434    fn test_dependency_version_range_is_literal_rejects_property_interpolation() {
4435        let content = r"<version>${slf4j.version}</version>";
4436        let dep = MockDep {
4437            name: PackageName::new("slf4j-api"),
4438            version_req: VersionReq::new("2.0.16"), // resolved property value
4439            version_range: Range::new(Position::new(0, 9), Position::new(0, 25)), // "${slf4j.version}"
4440            name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
4441        };
4442
4443        assert!(!dependency_version_range_is_literal(
4444            &dep,
4445            content,
4446            dep.version_range,
4447        ));
4448    }
4449
4450    /// #919: a YAML alias-shaped value (`*anchor`, e.g. GitLab CI/GitHub Actions `ref: *pin`
4451    /// job reuse) never textually equals a declared literal requirement — rejected the same
4452    /// way as the property-interpolation case.
4453    #[test]
4454    fn test_dependency_version_range_is_literal_rejects_yaml_alias() {
4455        let content = "ref: *pin";
4456        let dep = MockDep {
4457            name: PackageName::new("job"),
4458            version_req: VersionReq::new("1.0.0"),
4459            version_range: Range::new(Position::new(0, 5), Position::new(0, 9)), // "*pin"
4460            name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
4461        };
4462
4463        assert!(!dependency_version_range_is_literal(
4464            &dep,
4465            content,
4466            dep.version_range,
4467        ));
4468    }
4469
4470    /// S1 (critic follow-up): an empty `version_requirement` over an equally-empty slice —
4471    /// exactly what an editor's auto-closing quotes produce the instant `serde = "` is typed
4472    /// — must be **admitted**, not rejected. `generate_code_actions`'s "nothing to update"
4473    /// semantics for an empty requirement do not apply to completion, where an empty value is
4474    /// the single most common moment to offer the full version list.
4475    #[test]
4476    fn test_dependency_version_range_is_literal_admits_empty_requirement_with_empty_slice() {
4477        let content = r#"serde = """#;
4478        let dep = MockDep {
4479            name: PackageName::new("serde"),
4480            version_req: VersionReq::new(""),
4481            version_range: Range::new(Position::new(0, 9), Position::new(0, 9)),
4482            name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
4483        };
4484
4485        assert!(dependency_version_range_is_literal(
4486            &dep,
4487            content,
4488            dep.version_range,
4489        ));
4490    }
4491
4492    /// S1 (critic follow-up): an empty `version_requirement` paired with a *non-empty*
4493    /// non-literal slice must still be rejected — dropping the old `is_empty()` early return
4494    /// must not also drop this case, which the plain text comparison already handles on its
4495    /// own (`"1.0" != ""`).
4496    #[test]
4497    fn test_dependency_version_range_is_literal_rejects_empty_requirement_with_mismatched_slice() {
4498        let content = r#"x = "1.0""#;
4499        let dep = MockDep {
4500            name: PackageName::new("x"),
4501            version_req: VersionReq::new(""),
4502            version_range: Range::new(Position::new(0, 5), Position::new(0, 8)), // "1.0"
4503            name_range: Range::new(Position::new(0, 0), Position::new(0, 1)),
4504        };
4505
4506        assert!(!dependency_version_range_is_literal(
4507            &dep,
4508            content,
4509            dep.version_range,
4510        ));
4511    }
4512
4513    /// C1 (critic finding): the headline #919 case — an *unresolved* Maven `${property}`
4514    /// reference. The parser leaves an unresolved property as-is in both `version_range`'s
4515    /// slice and `version_requirement` (there is nothing else to put there), so the two
4516    /// textually agree — `literal_span_matches` alone would wrongly admit this. The
4517    /// reference-shape check must reject it independently of that text comparison.
4518    #[test]
4519    fn test_dependency_version_range_is_literal_rejects_unresolved_maven_property() {
4520        let content = r"<version>${slf4j.version}</version>";
4521        let dep = MockDep {
4522            name: PackageName::new("slf4j-api"),
4523            version_req: VersionReq::new("${slf4j.version}"), // left unresolved by the parser
4524            version_range: Range::new(Position::new(0, 9), Position::new(0, 25)), // "${slf4j.version}"
4525            name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
4526        };
4527
4528        assert!(!dependency_version_range_is_literal(
4529            &dep,
4530            content,
4531            dep.version_range,
4532        ));
4533    }
4534
4535    /// C1 (critic finding): the Gradle sibling of the unresolved-property case — a `$var`
4536    /// reference `resolve_variable_ref` could not resolve, left as raw text in both the slice
4537    /// and `version_requirement`.
4538    #[test]
4539    fn test_dependency_version_range_is_literal_rejects_unresolved_gradle_variable() {
4540        let content = "implementation 'com.example:lib:$libVersion'";
4541        let dep = MockDep {
4542            name: PackageName::new("com.example:lib"),
4543            version_req: VersionReq::new("$libVersion"), // left unresolved by the parser
4544            version_range: Range::new(Position::new(0, 32), Position::new(0, 43)), // "$libVersion"
4545            name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
4546        };
4547
4548        assert!(!dependency_version_range_is_literal(
4549            &dep,
4550            content,
4551            dep.version_range,
4552        ));
4553    }
4554
4555    /// C1 (critic finding): a partial interpolation embedded mid-string (`1.0-${suffix}`)
4556    /// does not start with `$`, but must still be rejected — the `contains("${")` arm of the
4557    /// reference-shape check, not just the `starts_with` arms.
4558    #[test]
4559    fn test_dependency_version_range_is_literal_rejects_embedded_interpolation() {
4560        let content = r#"x = "1.0-${suffix}""#;
4561        let dep = MockDep {
4562            name: PackageName::new("x"),
4563            version_req: VersionReq::new("1.0-${suffix}"),
4564            version_range: Range::new(Position::new(0, 5), Position::new(0, 18)), // "1.0-${suffix}"
4565            name_range: Range::new(Position::new(0, 0), Position::new(0, 1)),
4566        };
4567
4568        assert!(!dependency_version_range_is_literal(
4569            &dep,
4570            content,
4571            dep.version_range,
4572        ));
4573    }
4574
4575    /// M2 (critic follow-up): `"*"` — the project-wide existence-wildcard spelling
4576    /// (`deps_core::registry::is_existence_wildcard`, synthesized by `deps-npm`,
4577    /// `deps-composer`, ...) — must be **admitted**, not caught by the `*`-prefix arm meant
4578    /// for a `*anchor` YAML alias. Rejecting it would silently disable version completion at
4579    /// the exact moment a user wants to replace `"lodash": "*"` with a pinned version.
4580    #[test]
4581    fn test_dependency_version_range_is_literal_admits_existence_wildcard() {
4582        let content = r#"x = "*""#;
4583        let dep = MockDep {
4584            name: PackageName::new("x"),
4585            version_req: VersionReq::new("*"),
4586            version_range: Range::new(Position::new(0, 5), Position::new(0, 6)), // "*"
4587            name_range: Range::new(Position::new(0, 0), Position::new(0, 1)),
4588        };
4589
4590        assert!(dependency_version_range_is_literal(
4591            &dep,
4592            content,
4593            dep.version_range,
4594        ));
4595    }
4596
4597    /// M3 (critic follow-up): a `*anchor`-shaped slice that is *not exactly* `"*"` — e.g. a
4598    /// multi-char alias name — must still be rejected. Distinguishes the wildcard exemption
4599    /// above from a genuine leading-`*` reference.
4600    #[test]
4601    fn test_dependency_version_range_is_literal_rejects_multi_char_alias_despite_wildcard_exemption()
4602     {
4603        let content = "ref: *pinned-anchor";
4604        let dep = MockDep {
4605            name: PackageName::new("job"),
4606            version_req: VersionReq::new("1.0.0"),
4607            version_range: Range::new(Position::new(0, 5), Position::new(0, 19)), // "*pinned-anchor"
4608            name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
4609        };
4610
4611        assert!(!dependency_version_range_is_literal(
4612            &dep,
4613            content,
4614            dep.version_range,
4615        ));
4616    }
4617
4618    /// M3 (critic follow-up): a Gradle GString with a `$var` reference embedded mid-string
4619    /// (`1.0.$patch`, no braces, not at the start) is not resolved by
4620    /// `resolve_variable_ref` (whole-value only) — the slice stays byte-identical to the raw
4621    /// `version_requirement`, so only the shape check (not the text comparison) can reject
4622    /// it. This is #919 C1 in a narrower spelling than the whole-value `$var`/`${var}` case.
4623    #[test]
4624    fn test_dependency_version_range_is_literal_rejects_gradle_mid_string_variable() {
4625        let content = r#"implementation "com.example:lib:1.0.$patch""#;
4626        let dep = MockDep {
4627            name: PackageName::new("com.example:lib"),
4628            version_req: VersionReq::new("1.0.$patch"), // left unresolved by the parser
4629            version_range: Range::new(Position::new(0, 32), Position::new(0, 42)), // "1.0.$patch"
4630            name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
4631        };
4632
4633        assert!(!dependency_version_range_is_literal(
4634            &dep,
4635            content,
4636            dep.version_range,
4637        ));
4638    }
4639
4640    /// #1161: a dependency with no `version_requirement()` at all — Maven's parser never
4641    /// sets one for a genuinely empty `<version></version>` tag, since there is no text to
4642    /// capture — must still be admitted when `version_range`'s slice is empty. There is no
4643    /// existing text at that position that could be misread as a reference/wildcard, so
4644    /// offering completion is always safe regardless of whether a requirement was parsed.
4645    #[test]
4646    fn test_dependency_version_range_is_literal_admits_empty_slice_with_no_requirement_at_all() {
4647        let content = "<version></version>";
4648        let dep = MockSyntheticRangeDep {
4649            name: PackageName::new("com.example:foo"),
4650        };
4651        let empty_range = Range::new(Position::new(0, 9), Position::new(0, 9));
4652
4653        assert!(dep.version_requirement().is_none());
4654        assert!(dependency_version_range_is_literal(
4655            &dep,
4656            content,
4657            empty_range
4658        ));
4659    }
4660
4661    /// Critic follow-up (C1, second round) to #1161: a DANGLING or still-being-typed
4662    /// `version.ref` alias (e.g. `version.ref = "gu"` while typing "guava") has
4663    /// `version_requirement() == None` — the exact same shape as Maven's genuinely empty
4664    /// `<version></version>` tag — but its range is `deps-gradle`'s `Range::default()`
4665    /// sentinel, not a real in-document position. This is the *normal* interactive state on
4666    /// every keystroke while typing an alias, not an edge case: admitting it here would offer
4667    /// the full unfiltered version list and splice a version literal into the alias name.
4668    /// Must be rejected regardless of `version_requirement()` being absent.
4669    #[test]
4670    fn test_dependency_version_range_is_literal_rejects_default_range_with_no_requirement_at_all() {
4671        let content = r#"version.ref = "gu""#;
4672        let dep = MockSyntheticRangeDep {
4673            name: PackageName::new("com.example:guava"),
4674        };
4675
4676        assert!(dep.version_requirement().is_none());
4677        assert!(!dependency_version_range_is_literal(
4678            &dep,
4679            content,
4680            Range::default(),
4681        ));
4682    }
4683
4684    /// Critic follow-up (C1) to #1161: an empty slice must NOT be admitted merely because it
4685    /// is empty — only when `version_requirement()` is also absent. `deps-gradle`'s catalog
4686    /// context deliberately returns `Range::default()` (which slices to `""`) for a
4687    /// `version.ref = "alias"` reference while `version_requirement()` still holds the
4688    /// alias's real, non-empty resolved value (#931) — the "always reject" guarantee that
4689    /// sentinel relies on comes from comparing that non-empty value against the empty slice,
4690    /// not from any special-casing of the empty slice itself. A version that checks
4691    /// `trimmed.is_empty()` before/independent of `version_requirement()` would invert this
4692    /// into an always-accept and let a full completion list splice a version literal into
4693    /// the alias name.
4694    #[test]
4695    fn test_dependency_version_range_is_literal_rejects_default_range_with_nonempty_requirement() {
4696        let content = r#"version.ref = "guavaVersion""#;
4697        let dep = MockDep {
4698            name: PackageName::new("com.example:guava"),
4699            version_req: VersionReq::new("32.0.1"), // the alias's resolved value
4700            version_range: Range::default(), // deps-gradle's deliberate always-reject sentinel
4701            name_range: Range::default(),
4702        };
4703
4704        assert!(!dependency_version_range_is_literal(
4705            &dep,
4706            content,
4707            dep.version_range,
4708        ));
4709    }
4710
4711    /// The empty-entry pruning invariant: an entry is removed once its last set channel
4712    /// is cleared, one channel at a time, in every order — never left behind as a
4713    /// vacuous `Some(DependencyOutcome::default())` that would inflate `len()`.
4714    #[test]
4715    fn test_dependency_outcomes_prunes_entry_once_all_channels_cleared() {
4716        let mut outcomes = DependencyOutcomes::new()
4717            .with_yanked("pkg", ("1.0.0".into(), RemovalStatus::Yanked))
4718            .with_deprecation(
4719                "pkg",
4720                Deprecation {
4721                    reason: None,
4722                    replacement: None,
4723                },
4724            )
4725            .with_fetch_failure("pkg", FetchFailure::Transient);
4726        assert_eq!(outcomes.len(), 1);
4727
4728        outcomes.clear_yanked("pkg");
4729        assert!(
4730            outcomes.get("pkg").is_some(),
4731            "entry must survive while other channels are still set"
4732        );
4733
4734        outcomes.clear_deprecation("pkg");
4735        assert!(
4736            outcomes.get("pkg").is_some(),
4737            "entry must survive while the fetch-failure channel is still set"
4738        );
4739
4740        outcomes.clear_fetch_failure("pkg");
4741        assert!(
4742            outcomes.is_empty(),
4743            "entry must be pruned once its last channel is cleared, not left as a vacuous Some"
4744        );
4745        assert_eq!(outcomes.len(), 0);
4746    }
4747
4748    /// Clearing a channel that was never set on an existing entry must not spuriously
4749    /// prune channels that ARE still set (each `clear_*` only nulls its own field).
4750    #[test]
4751    fn test_dependency_outcomes_clear_on_unset_channel_is_a_no_op_for_others() {
4752        let mut outcomes = DependencyOutcomes::new()
4753            .with_yanked("pkg", ("1.0.0".into(), RemovalStatus::AdvisoryDeprecated));
4754
4755        outcomes.clear_deprecation("pkg");
4756        outcomes.clear_fetch_failure("pkg");
4757
4758        assert!(
4759            outcomes.yanked("pkg").is_some(),
4760            "clearing unset channels must not touch the still-set yanked channel"
4761        );
4762        assert_eq!(outcomes.len(), 1);
4763    }
4764
4765    /// `remove` drops the whole entry regardless of which channels are set, unlike the
4766    /// per-channel `clear_*` methods.
4767    #[test]
4768    fn test_dependency_outcomes_remove_drops_entry_with_multiple_channels_set() {
4769        let mut outcomes = DependencyOutcomes::new()
4770            .with_yanked("pkg", ("1.0.0".into(), RemovalStatus::Yanked))
4771            .with_fetch_failure("pkg", FetchFailure::Transient);
4772
4773        outcomes.remove("pkg");
4774
4775        assert!(outcomes.get("pkg").is_none());
4776        assert!(outcomes.is_empty());
4777    }
4778
4779    /// `clear_all_fetch_failures` (deps-lsp issue #592) drops the fetch-failure channel for
4780    /// every entry, pruning an entry that becomes empty, while leaving other channels
4781    /// (yanked/deprecation/no-comparable-versions) on a still-mixed entry untouched.
4782    #[test]
4783    fn test_dependency_outcomes_clear_all_fetch_failures() {
4784        let mut outcomes = DependencyOutcomes::new()
4785            .with_fetch_failure("only-failure", FetchFailure::Transient)
4786            .with_yanked("mixed", ("1.0.0".into(), RemovalStatus::Yanked))
4787            .with_fetch_failure("mixed", FetchFailure::Transient);
4788
4789        outcomes.clear_all_fetch_failures();
4790
4791        assert!(
4792            outcomes.get("only-failure").is_none(),
4793            "an entry whose only channel was fetch-failure must be pruned"
4794        );
4795        assert!(outcomes.fetch_failure("mixed").is_none());
4796        assert!(
4797            outcomes.yanked("mixed").is_some(),
4798            "clearing fetch-failure must not touch a mixed entry's other channels"
4799        );
4800    }
4801
4802    /// `clear_all_fetch_failures` on an empty map is a no-op, not a panic.
4803    #[test]
4804    fn test_dependency_outcomes_clear_all_fetch_failures_on_empty_map() {
4805        let mut outcomes = DependencyOutcomes::new();
4806        outcomes.clear_all_fetch_failures();
4807        assert!(outcomes.is_empty());
4808    }
4809
4810    /// `set_fetch_failure_if_absent` (impl-critic M2) must never clobber a genuine
4811    /// `Actionable`/`Transient` failure already recorded for the name — only `set_fetch_failure`
4812    /// unconditionally overwrites.
4813    #[test]
4814    fn test_dependency_outcomes_set_fetch_failure_if_absent_does_not_clobber_existing() {
4815        let mut outcomes = DependencyOutcomes::new().with_fetch_failure(
4816            "pkg",
4817            FetchFailure::Actionable("set GITHUB_TOKEN".to_string()),
4818        );
4819
4820        outcomes.set_fetch_failure_if_absent("pkg".to_string(), FetchFailure::NotAttempted);
4821
4822        assert_eq!(
4823            outcomes.fetch_failure("pkg"),
4824            Some(&FetchFailure::Actionable("set GITHUB_TOKEN".to_string())),
4825            "an existing genuine failure must survive a collided-name NotAttempted marker"
4826        );
4827    }
4828
4829    /// The mirror case: when no failure is recorded yet, `set_fetch_failure_if_absent` does
4830    /// populate the entry.
4831    #[test]
4832    fn test_dependency_outcomes_set_fetch_failure_if_absent_populates_when_unset() {
4833        let mut outcomes = DependencyOutcomes::new();
4834
4835        outcomes.set_fetch_failure_if_absent("pkg".to_string(), FetchFailure::NotAttempted);
4836
4837        assert_eq!(
4838            outcomes.fetch_failure("pkg"),
4839            Some(&FetchFailure::NotAttempted)
4840        );
4841    }
4842
4843    #[test]
4844    fn test_line_offset_table_line_start_crlf() {
4845        let table = LineOffsetTable::new("a\r\nbb\r\nc");
4846        assert_eq!(table.line_start(0), Some(0));
4847        assert_eq!(table.line_start(1), Some(3));
4848        assert_eq!(table.line_start(2), Some(7));
4849        assert_eq!(table.line_start(3), None);
4850    }
4851
4852    #[test]
4853    fn test_byte_offset_to_position_clamps_to_char_boundary_instead_of_panicking() {
4854        // "é" is a 2-byte UTF-8 sequence; offset 1 lands inside it.
4855        let content = "é";
4856        let table = LineOffsetTable::new(content);
4857        // Must not panic; clamps down to the nearest boundary (offset 0).
4858        let pos = table.byte_offset_to_position(content, 1);
4859        assert_eq!(pos, Position::new(0, 0));
4860    }
4861
4862    #[test]
4863    fn test_byte_offset_to_position_multi_byte_boundary_in_longer_line() {
4864        let content = "ab é cd";
4865        let table = LineOffsetTable::new(content);
4866        // Byte 3 is 'é's leading byte (boundary); byte 4 is its continuation
4867        // byte (not a boundary) and must clamp back to 3 rather than panic.
4868        assert!(content.is_char_boundary(3));
4869        assert!(!content.is_char_boundary(4));
4870        let pos = table.byte_offset_to_position(content, 4);
4871        assert_eq!(pos, table.byte_offset_to_position(content, 3));
4872    }
4873
4874    /// Covers the ASCII/non-ASCII fast-path split introduced for #742: an ASCII-only line
4875    /// (line 0), a non-ASCII line mixing a BMP accented character with a surrogate-pair
4876    /// emoji (line 1), and a mixed line (line 2) must all still resolve to the same
4877    /// `Position`s as the original `chars().map(char::len_utf16).sum()` scan would produce.
4878    #[test]
4879    fn test_byte_offset_to_position_ascii_and_non_ascii_lines_agree() {
4880        let content = "abcde\nhéllo 😀\nab café end";
4881        let table = LineOffsetTable::new(content);
4882
4883        // Line 0 ("abcde"): ASCII fast path, byte offset == UTF-16 character offset.
4884        assert_eq!(
4885            table.byte_offset_to_position(content, 3),
4886            Position::new(0, 3)
4887        );
4888        assert_eq!(
4889            table.byte_offset_to_position(content, 5),
4890            Position::new(0, 5)
4891        );
4892
4893        // Line 1 ("héllo 😀"): non-ASCII scan path.
4894        // Offset after "h\u{e9}" (1 + 2 bytes into the line): 'h' + 'é' = 2 UTF-16 units.
4895        assert_eq!(
4896            table.byte_offset_to_position(content, 6 + 3),
4897            Position::new(1, 2)
4898        );
4899        // Offset at the end of the line: "héllo 😀" = 8 UTF-16 units (emoji is a surrogate pair).
4900        assert_eq!(
4901            table.byte_offset_to_position(content, 6 + 11),
4902            Position::new(1, 8)
4903        );
4904
4905        // Line 2 ("ab café end"): mixed line, still takes the non-ASCII scan path.
4906        // Offset after "ab café" (8 bytes into the line): 7 UTF-16 units ('é' is 1 BMP unit).
4907        assert_eq!(
4908            table.byte_offset_to_position(content, 18 + 8),
4909            Position::new(2, 7)
4910        );
4911    }
4912
4913    fn reference_position_to_byte_offset(
4914        table: &LineOffsetTable,
4915        content: &str,
4916        position: Position,
4917    ) -> usize {
4918        let Some(line_start) = table.line_start(position.line as usize) else {
4919            return content.len();
4920        };
4921        let line_end = table
4922            .line_start(position.line as usize + 1)
4923            .unwrap_or(content.len());
4924        utf16_to_byte_offset(&content[line_start..line_end], position.character)
4925            .map_or(line_end, |offset| line_start + offset)
4926            .min(content.len())
4927    }
4928
4929    /// #1711: the indexed inverse conversion must match the old per-call line scan for every
4930    /// position, including out-of-range lines/characters and ones splitting a surrogate pair.
4931    #[test]
4932    fn test_position_to_byte_offset_matches_line_scan_reference() {
4933        for content in [
4934            "",
4935            "abc",
4936            "abc\n",
4937            "a\r\nbb\r\nc",
4938            "h\u{e9}llo \u{1f600}\nascii\n\u{65e5}\u{672c}\u{8a9e}x\n",
4939            "\u{1f600}\u{1f600}",
4940            "\n\n\u{e9}",
4941        ] {
4942            let table = LineOffsetTable::new(content);
4943            for line in 0..6 {
4944                for character in 0..24 {
4945                    let position = Position::new(line, character);
4946                    assert_eq!(
4947                        table.position_to_byte_offset(content, position),
4948                        reference_position_to_byte_offset(&table, content, position),
4949                        "content {content:?} position {position:?}"
4950                    );
4951                }
4952            }
4953        }
4954    }
4955
4956    #[test]
4957    fn test_position_to_byte_offset_surrogate_split_rounds_up_and_overshoot_clamps() {
4958        let content = "a\u{1f600}b\nzz";
4959        let table = LineOffsetTable::new(content);
4960        assert_eq!(
4961            table.position_to_byte_offset(content, Position::new(0, 2)),
4962            5
4963        );
4964        assert_eq!(
4965            table.position_to_byte_offset(content, Position::new(0, 4)),
4966            6
4967        );
4968        assert_eq!(
4969            table.position_to_byte_offset(content, Position::new(0, 99)),
4970            7
4971        );
4972        assert_eq!(
4973            table.position_to_byte_offset(content, Position::new(1, 99)),
4974            content.len()
4975        );
4976        assert_eq!(
4977            table.position_to_byte_offset(content, Position::new(9, 0)),
4978            content.len()
4979        );
4980    }
4981
4982    /// #1711: many edits on one long non-ASCII line must round-trip through the cached index.
4983    #[test]
4984    fn test_position_to_byte_offset_long_non_ascii_line_round_trips() {
4985        let content = "\u{e9}\u{1f600}dep = \"1.0.0\", ".repeat(5000);
4986        let table = LineOffsetTable::new(&content);
4987        for (offset, _) in content.char_indices().step_by(7) {
4988            let position = table.byte_offset_to_position(&content, offset);
4989            assert_eq!(table.position_to_byte_offset(&content, position), offset);
4990        }
4991    }
4992
4993    #[test]
4994    fn test_position_to_byte_offset_round_trips_across_mixed_ascii_and_non_ascii_lines() {
4995        let content = "plain = 1\r\nh\u{e9}llo \u{1f600}\nascii again\n\u{65e5}\u{672c}\n\nend";
4996        let table = LineOffsetTable::new(content);
4997        let offsets: Vec<usize> = content
4998            .char_indices()
4999            .map(|(i, _)| i)
5000            .chain([content.len()])
5001            .collect();
5002        for &offset in offsets.iter().rev().chain(offsets.iter()) {
5003            let position = table.byte_offset_to_position(content, offset);
5004            assert_eq!(
5005                table.position_to_byte_offset(content, position),
5006                offset,
5007                "offset {offset}"
5008            );
5009        }
5010    }
5011
5012    /// Regression guard for #742: `byte_offset_to_position` on a large single-line
5013    /// (minified) manifest must stay near-instant. The pre-fix implementation rescanned
5014    /// the line from its start on every call, making repeated lookups over such a line
5015    /// O(n^2) in the line's length (~350ms for 8000 calls on an ~180KB line on the
5016    /// reporter's machine); the ASCII fast path makes each call O(1), so this generous
5017    /// wall-clock bound leaves wide margin without being flaky.
5018    #[test]
5019    fn test_byte_offset_to_position_minified_line_stays_fast() {
5020        let mut content = String::from("{\"dependencies\":{");
5021        for i in 0..8000 {
5022            content.push_str(&format!("\"dep{i}\":\"1.0.{i}\","));
5023        }
5024        content.push_str("}}");
5025
5026        let table = LineOffsetTable::new(&content);
5027        let step = (content.len() / 8000).max(1);
5028        let start = std::time::Instant::now();
5029        for offset in (0..content.len()).step_by(step) {
5030            std::hint::black_box(table.byte_offset_to_position(&content, offset));
5031        }
5032        let elapsed = start.elapsed();
5033
5034        assert!(
5035            elapsed < std::time::Duration::from_millis(100),
5036            "8000 byte_offset_to_position calls on a minified line took {elapsed:?}, expected < 100ms"
5037        );
5038    }
5039
5040    /// Regression guard for #882 (widened scope): `byte_offset_to_position`'s non-ASCII branch
5041    /// used to walk `chars().map(len_utf16).sum()` from the line's start on every call, so N
5042    /// lookups on the same wide non-ASCII line cost O(N x line length) — the same shape #882
5043    /// reported for `marker_byte_offset`, and, per the #882 review, ~300x its cost since
5044    /// `make_range` calls this twice per dependency. The shared per-line index makes every
5045    /// lookup after the first on a given line O(1).
5046    #[test]
5047    fn test_byte_offset_to_position_non_ascii_line_cache_stays_fast_on_a_huge_single_line() {
5048        let filler = "x".repeat(200 * 1024);
5049        let content = format!("{filler}\u{2014}{filler}");
5050        let table = LineOffsetTable::new(&content);
5051        let step = (content.len() / 5000).max(1);
5052        let start = std::time::Instant::now();
5053        for offset in (0..content.len()).step_by(step) {
5054            std::hint::black_box(table.byte_offset_to_position(&content, offset));
5055        }
5056        assert!(
5057            start.elapsed() < std::time::Duration::from_secs(1),
5058            "byte_offset_to_position took {:?} for ~5000 lookups on a huge non-ASCII line, \
5059             expected well under 1s",
5060            start.elapsed()
5061        );
5062    }
5063
5064    /// Differential test: the cached non-ASCII path must agree with a plain reimplemented
5065    /// `chars().map(len_utf16).sum()` walk for every char-boundary offset on a non-ASCII line,
5066    /// including offsets on more than one non-ASCII line in the same document (cache isolation
5067    /// across lines, sharing the same underlying index `marker_byte_offset` populates).
5068    #[test]
5069    fn test_byte_offset_to_position_non_ascii_cache_matches_char_sum_result_across_lines() {
5070        fn slow_path(content: &str, line_start: usize, offset: usize) -> u32 {
5071            u32::try_from(
5072                content[line_start..offset]
5073                    .chars()
5074                    .map(char::len_utf16)
5075                    .sum::<usize>(),
5076            )
5077            .unwrap_or(u32::MAX)
5078        }
5079
5080        let content = "\u{1f680}rocket \u{2014} dash\nascii line\n\u{3000}ideographic \u{e9}nd";
5081        let table = LineOffsetTable::new(content);
5082        let line_starts = [
5083            table.line_start(0).unwrap(),
5084            table.line_start(1).unwrap(),
5085            table.line_start(2).unwrap(),
5086        ];
5087        let line_ends = [line_starts[1] - 1, line_starts[2] - 1, content.len()];
5088
5089        for (line, (&line_start, &line_end)) in line_starts.iter().zip(&line_ends).enumerate() {
5090            let line_text = &content[line_start..line_end];
5091            for (byte_offset, _) in line_text
5092                .char_indices()
5093                .chain(std::iter::once((line_text.len(), '\0')))
5094            {
5095                let offset = line_start + byte_offset;
5096                assert_eq!(
5097                    table.byte_offset_to_position(content, offset).character,
5098                    slow_path(content, line_start, offset),
5099                    "line {line} byte offset {byte_offset} desynced from the char-sum walk"
5100                );
5101            }
5102        }
5103    }
5104
5105    #[test]
5106    fn test_position_in_range_inside() {
5107        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
5108        let position = Position::new(5, 15);
5109        assert!(position_in_range(position, range));
5110    }
5111
5112    #[test]
5113    fn test_position_in_range_at_start() {
5114        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
5115        let position = Position::new(5, 10);
5116        assert!(position_in_range(position, range));
5117    }
5118
5119    #[test]
5120    fn test_position_in_range_at_end() {
5121        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
5122        let position = Position::new(5, 20);
5123        assert!(position_in_range(position, range));
5124    }
5125
5126    #[test]
5127    fn test_position_in_range_before() {
5128        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
5129        let position = Position::new(5, 5);
5130        assert!(!position_in_range(position, range));
5131    }
5132
5133    #[test]
5134    fn test_position_in_range_after() {
5135        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
5136        let position = Position::new(5, 25);
5137        assert!(!position_in_range(position, range));
5138    }
5139
5140    #[test]
5141    fn test_position_in_range_different_line_before() {
5142        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
5143        let position = Position::new(4, 15);
5144        assert!(!position_in_range(position, range));
5145    }
5146
5147    #[test]
5148    fn test_position_in_range_different_line_after() {
5149        let range = Range::new(Position::new(5, 10), Position::new(5, 20));
5150        let position = Position::new(6, 15);
5151        assert!(!position_in_range(position, range));
5152    }
5153
5154    #[test]
5155    fn test_position_in_range_multiline() {
5156        let range = Range::new(Position::new(5, 10), Position::new(7, 5));
5157        let position = Position::new(6, 0);
5158        assert!(position_in_range(position, range));
5159    }
5160
5161    #[test]
5162    fn test_escape_markdown_link_breakout_payload() {
5163        let payload = "real-pkg](https://legit-looking-typosquat.example/download)[real-pkg";
5164        let escaped = escape_markdown(payload);
5165        assert_eq!(
5166            escaped,
5167            r"real\-pkg\]\(https\:\/\/legit\-looking\-typosquat\.example\/download\)\[real\-pkg"
5168        );
5169        assert!(!escaped.contains("]("));
5170    }
5171
5172    #[test]
5173    fn test_escape_markdown_backslash_and_backtick() {
5174        assert_eq!(escape_markdown(r"a\b`c"), r"a\\b\`c");
5175    }
5176
5177    #[test]
5178    fn test_escape_markdown_autolink_angle_brackets() {
5179        // `<...>` around a bare URL is a CommonMark autolink; `<`/`>` must be escaped
5180        // so it cannot render as a live link independent of the `[]`/`()` escaping.
5181        let escaped = escape_markdown("pkg <https://evil.example>");
5182        assert_eq!(escaped, r"pkg \<https\:\/\/evil\.example\>");
5183        assert!(!escaped.contains('<') || escaped.contains(r"\<"));
5184    }
5185
5186    #[test]
5187    fn test_escape_markdown_control_chars_become_spaces() {
5188        assert_eq!(escape_markdown("a\nb"), "a b");
5189        assert_eq!(escape_markdown("a\r\nb"), "a  b");
5190        assert_eq!(escape_markdown("a\tb"), "a b");
5191        assert_eq!(escape_markdown("a\0b"), "a b");
5192    }
5193
5194    #[test]
5195    fn test_escape_markdown_newline_cannot_break_out_of_heading() {
5196        // A raw newline used to terminate the ATX heading line early, letting the
5197        // rest of the name (potentially another "# [...](...)" sequence) render as
5198        // separate, unescaped Markdown blocks.
5199        let escaped = escape_markdown("react\n# [fake](https://evil.example)");
5200        assert!(!escaped.contains('\n'));
5201    }
5202
5203    #[test]
5204    fn test_escape_markdown_hyphenated_name_round_trips_visually() {
5205        // Escaping a hyphen (ASCII punctuation) is visually inert on render — CommonMark
5206        // renders `\-` as a literal `-` — so common package names are unaffected in
5207        // practice even though the raw Markdown source now escapes them.
5208        assert_eq!(escape_markdown("tokio-util"), r"tokio\-util");
5209    }
5210
5211    #[test]
5212    fn test_markdown_code_span_plain_content() {
5213        assert_eq!(markdown_code_span("1.0.0"), "`1.0.0`");
5214    }
5215
5216    #[test]
5217    fn test_markdown_code_span_widens_fence_for_embedded_backticks() {
5218        assert_eq!(markdown_code_span("a`b"), "``a`b``");
5219        assert_eq!(markdown_code_span("``double``"), "``` ``double`` ```");
5220    }
5221
5222    #[test]
5223    fn test_markdown_code_span_pads_when_content_starts_or_ends_with_backtick() {
5224        let span = markdown_code_span("`leading");
5225        assert!(span.starts_with("`` `"));
5226    }
5227
5228    #[test]
5229    fn test_markdown_code_span_replaces_control_chars() {
5230        let span = markdown_code_span("1.0\n[evil](https://evil.example)");
5231        assert!(!span.contains('\n'));
5232    }
5233
5234    #[test]
5235    fn test_markdown_code_span_empty_content() {
5236        assert_eq!(markdown_code_span(""), "` `");
5237    }
5238
5239    #[test]
5240    fn test_markdown_code_span_backtick_payload_cannot_break_span() {
5241        // A payload closing the code span early to splice in a live link must not succeed.
5242        let payload = "1.0` <https://evil.example>` more";
5243        let span = markdown_code_span(payload);
5244        // The fence must be strictly longer than any backtick run in the content, so no
5245        // substring after the opening fence can act as a closing fence before the real one.
5246        let opening_fence_len = span.chars().take_while(|&c| c == '`').count();
5247        let inner = &span[opening_fence_len..span.len() - opening_fence_len];
5248        assert!(
5249            !inner.contains(&"`".repeat(opening_fence_len)),
5250            "content contains a run of backticks as long as the fence: {span}"
5251        );
5252    }
5253
5254    #[test]
5255    fn test_escape_markdown_replaces_bidi_and_invisible_characters() {
5256        for c in [
5257            '\u{202E}',  // RLO — the Trojan Source vector
5258            '\u{2066}',  // LRI
5259            '\u{200B}',  // ZWSP
5260            '\u{2060}',  // WORD JOINER
5261            '\u{2028}',  // LS
5262            '\u{FEFF}',  // BOM
5263            '\u{FFFA}',  // interlinear annotation separator
5264            '\u{E0041}', // Unicode tag character ("A")
5265        ] {
5266            let escaped = escape_markdown(&format!("a{c}b"));
5267            assert_eq!(escaped, "a b", "{c:?} must be replaced with a space");
5268        }
5269    }
5270
5271    /// #1323: `sanitize_invisible` already stripped these `Cf` characters while
5272    /// `is_markdown_unsafe`'s hand-picked list let them through.
5273    #[test]
5274    fn test_escape_markdown_replaces_1323_gap_characters() {
5275        for c in [
5276            '\u{00AD}',  // SOFT HYPHEN
5277            '\u{180E}',  // MONGOLIAN VOWEL SEPARATOR
5278            '\u{2061}',  // FUNCTION APPLICATION
5279            '\u{2062}',  // INVISIBLE TIMES
5280            '\u{2063}',  // INVISIBLE SEPARATOR
5281            '\u{2064}',  // INVISIBLE PLUS
5282            '\u{206A}',  // INHIBIT SYMMETRIC SWAPPING
5283            '\u{206B}',  // ACTIVATE SYMMETRIC SWAPPING
5284            '\u{206C}',  // INHIBIT ARABIC FORM SHAPING
5285            '\u{206D}',  // ACTIVATE ARABIC FORM SHAPING
5286            '\u{206E}',  // NATIONAL DIGIT SHAPES
5287            '\u{206F}',  // NOMINAL DIGIT SHAPES
5288            '\u{13430}', // Egyptian Hieroglyph format control (first)
5289            '\u{1343F}', // Egyptian Hieroglyph format control (last)
5290            '\u{1BCA0}', // Shorthand format control (first)
5291            '\u{1BCA3}', // Shorthand format control (last)
5292            '\u{1D173}', // Musical symbol format control (first)
5293            '\u{1D17A}', // Musical symbol format control (last)
5294        ] {
5295            let escaped = escape_markdown(&format!("a{c}b"));
5296            assert_eq!(escaped, "a b", "{c:?} must be replaced with a space");
5297        }
5298    }
5299
5300    /// #1323 M1: U+2065 (unassigned, sitting inside the consolidated U+2060-U+206F range)
5301    /// is now deliberately swept in by the range rather than carved out, so a future
5302    /// Unicode assignment of it can't silently open a hole.
5303    #[test]
5304    fn test_escape_markdown_now_blocks_unassigned_u2065() {
5305        assert_eq!(escape_markdown("a\u{2065}b"), "a b");
5306    }
5307
5308    /// #1323 must not regress #1248: legitimate directional marks and ZWNJ/ZWJ survive.
5309    #[test]
5310    fn test_escape_markdown_still_preserves_legitimate_bidi_marks_after_1323() {
5311        for c in ['\u{200F}', '\u{061C}', '\u{200E}', '\u{200C}', '\u{200D}'] {
5312            let escaped = escape_markdown(&format!("a{c}b"));
5313            assert_eq!(
5314                escaped,
5315                format!("a{c}b"),
5316                "{c:?} must survive verbatim (legitimate RTL/emoji use, #1248)"
5317            );
5318        }
5319    }
5320
5321    /// #1323 M2/critic S2: prefixed-format signs with genuine Arabic/Syriac/Kaithi
5322    /// mid-string use must also survive — they were exempt before #1323 and remain so.
5323    #[test]
5324    fn test_escape_markdown_preserves_prefixed_format_signs() {
5325        for c in [
5326            '\u{0600}',
5327            '\u{0601}',
5328            '\u{0602}',
5329            '\u{0603}',
5330            '\u{0604}',
5331            '\u{0605}',
5332            '\u{06DD}',
5333            '\u{070F}',
5334            '\u{0890}',
5335            '\u{0891}',
5336            '\u{08E2}',
5337            '\u{110BD}',
5338            '\u{110CD}',
5339        ] {
5340            let escaped = escape_markdown(&format!("a{c}b"));
5341            assert_eq!(
5342                escaped,
5343                format!("a{c}b"),
5344                "{c:?} must survive verbatim (legitimate Arabic/Syriac/Kaithi use, #1248)"
5345            );
5346        }
5347    }
5348
5349    /// #1323 critic S3: exhaustive drift guard. Scans the full Unicode code space and
5350    /// asserts that every character `sanitize_invisible` strips but `is_markdown_unsafe`
5351    /// does not is exactly the documented, named exempt set (#1248's RTL/ZWNJ/ZWJ marks
5352    /// plus the prefixed-format signs) — not a superset (an undocumented, silently
5353    /// widened exemption) and not a subset (a stale exempt-set constant hiding a real
5354    /// gap). Fails CI if `unicode-general-category` changes classification for any code
5355    /// point, or if either function's character list is hand-edited without updating the
5356    /// other.
5357    #[test]
5358    fn test_is_markdown_unsafe_exempt_set_matches_sanitize_invisible_drift_guard() {
5359        const EXEMPT_CHARS: &[char] = &[
5360            // #1248: RTL/ZWNJ/ZWJ marks, load-bearing in Persian/Arabic/Indic text shaping
5361            // and emoji ZWJ sequences.
5362            '\u{200E}',
5363            '\u{200F}',
5364            '\u{061C}',
5365            '\u{200C}',
5366            '\u{200D}',
5367            // #1248/critic S2: prefixed-format signs with genuine Arabic/Syriac/Kaithi
5368            // mid-string annotation use.
5369            '\u{0600}',
5370            '\u{0601}',
5371            '\u{0602}',
5372            '\u{0603}',
5373            '\u{0604}',
5374            '\u{0605}',
5375            '\u{06DD}',
5376            '\u{070F}',
5377            '\u{0890}',
5378            '\u{0891}',
5379            '\u{08E2}',
5380            '\u{110BD}',
5381            '\u{110CD}',
5382        ];
5383
5384        let mut undocumented_exemptions = Vec::new();
5385        for cp in 0..=0x0010_FFFFu32 {
5386            let Some(c) = char::from_u32(cp) else {
5387                continue;
5388            };
5389            if crate::redact::is_invisible(c)
5390                && !is_markdown_unsafe(c)
5391                && !EXEMPT_CHARS.contains(&c)
5392            {
5393                undocumented_exemptions.push(c);
5394            }
5395        }
5396        assert!(
5397            undocumented_exemptions.is_empty(),
5398            "is_markdown_unsafe silently exempts characters sanitize_invisible strips, \
5399             outside the documented #1248/#1323 exempt set: {undocumented_exemptions:?}"
5400        );
5401
5402        for &c in EXEMPT_CHARS {
5403            assert!(
5404                crate::redact::is_invisible(c) && !is_markdown_unsafe(c),
5405                "{c:?} is listed in the exempt set but is either not stripped by \
5406                 sanitize_invisible or is already blocked by is_markdown_unsafe — the \
5407                 exempt-set constant is stale"
5408            );
5409        }
5410    }
5411
5412    /// #1276: `Diagnostic::new`/`RelatedInformation::new` apply `replace_markdown_unsafe_chars`
5413    /// on top of producer-side sanitization that may have already called it —
5414    /// double-application must be a no-op, since `' '` (the substitution) is not itself
5415    /// `is_markdown_unsafe`.
5416    #[test]
5417    fn test_replace_markdown_unsafe_chars_is_idempotent() {
5418        for c in [
5419            '\u{202E}',
5420            '\u{2066}',
5421            '\u{200B}',
5422            '\u{2060}',
5423            '\u{2028}',
5424            '\u{2029}',
5425            '\u{FEFF}',
5426            '\u{FFFA}',
5427            '\u{E0041}',
5428            '\n',
5429            '\t',
5430        ] {
5431            let input = format!("a{c}b");
5432            let once = replace_markdown_unsafe_chars(&input);
5433            assert_eq!(once, "a b", "{c:?} must be replaced with a single space");
5434            let twice = replace_markdown_unsafe_chars(&once);
5435            assert_eq!(twice, once, "{c:?} must not change on re-application");
5436        }
5437        assert!(
5438            !is_markdown_unsafe(' '),
5439            "substituted space must be stable under re-application"
5440        );
5441    }
5442
5443    #[test]
5444    fn test_escape_markdown_preserves_legitimate_bidi_marks() {
5445        for c in [
5446            '\u{200F}', // RLM
5447            '\u{061C}', // ALM
5448            '\u{200E}', // LRM
5449            '\u{200D}', // ZWJ
5450        ] {
5451            let escaped = escape_markdown(&format!("a{c}b"));
5452            assert_eq!(
5453                escaped,
5454                format!("a{c}b"),
5455                "{c:?} must survive verbatim (legitimate RTL/emoji use)"
5456            );
5457        }
5458    }
5459
5460    #[test]
5461    fn test_markdown_code_span_replaces_bidi_and_invisible_characters() {
5462        for c in [
5463            '\u{202E}',
5464            '\u{2066}',
5465            '\u{200B}',
5466            '\u{2060}',
5467            '\u{2029}',
5468            '\u{FEFF}',
5469            '\u{FFFA}',
5470            '\u{E0041}',
5471        ] {
5472            let span = markdown_code_span(&format!("a{c}b"));
5473            assert_eq!(span, "`a b`", "{c:?} must be replaced with a space");
5474        }
5475    }
5476
5477    #[test]
5478    fn test_markdown_code_span_preserves_legitimate_bidi_marks() {
5479        for c in ['\u{200F}', '\u{061C}', '\u{200E}', '\u{200D}'] {
5480            let span = markdown_code_span(&format!("a{c}b"));
5481            assert_eq!(
5482                span,
5483                format!("`a{c}b`"),
5484                "{c:?} must survive verbatim (legitimate RTL/emoji use)"
5485            );
5486        }
5487    }
5488
5489    #[test]
5490    fn test_is_same_major_minor_full_match() {
5491        assert!(is_same_major_minor("1.2.3", "1.2.9"));
5492    }
5493
5494    #[test]
5495    fn test_is_same_major_minor_exact_match() {
5496        assert!(is_same_major_minor("1.2.3", "1.2.3"));
5497    }
5498
5499    #[test]
5500    fn test_is_same_major_minor_major_only_match() {
5501        assert!(is_same_major_minor("1", "1.2.3"));
5502        assert!(is_same_major_minor("1.2.3", "1"));
5503    }
5504
5505    #[test]
5506    fn test_is_same_major_minor_no_match_different_minor() {
5507        assert!(!is_same_major_minor("1.2.3", "1.3.0"));
5508    }
5509
5510    #[test]
5511    fn test_is_same_major_minor_no_match_different_major() {
5512        assert!(!is_same_major_minor("1.2.3", "2.2.3"));
5513    }
5514
5515    #[test]
5516    fn test_is_same_major_minor_empty_strings() {
5517        assert!(!is_same_major_minor("", ""));
5518        assert!(!is_same_major_minor("1.2.3", ""));
5519        assert!(!is_same_major_minor("", "1.2.3"));
5520    }
5521
5522    #[test]
5523    fn test_requirement_contains_template_placeholder_bare_and_braced() {
5524        assert!(requirement_contains_template_placeholder("$REACT_VERSION"));
5525        assert!(requirement_contains_template_placeholder(
5526            "${REACT_VERSION}"
5527        ));
5528        assert!(requirement_contains_template_placeholder("1.0.0-$BUILD"));
5529        assert!(requirement_contains_template_placeholder(
5530            "v${MAJOR}.${MINOR}"
5531        ));
5532    }
5533
5534    #[test]
5535    fn test_requirement_contains_template_placeholder_lowercase_and_mixed_case() {
5536        assert!(requirement_contains_template_placeholder("$react_version"));
5537        assert!(requirement_contains_template_placeholder(
5538            "${React_Version}"
5539        ));
5540    }
5541
5542    #[test]
5543    fn test_requirement_contains_template_placeholder_ordinary_requirements_not_flagged() {
5544        assert!(!requirement_contains_template_placeholder("1.2.3"));
5545        assert!(!requirement_contains_template_placeholder("^1.2.3"));
5546        assert!(!requirement_contains_template_placeholder("~1.2.3"));
5547        assert!(!requirement_contains_template_placeholder(">=1.0.0 <2.0.0"));
5548        assert!(!requirement_contains_template_placeholder(""));
5549        assert!(!requirement_contains_template_placeholder("price-is-$5"));
5550        assert!(!requirement_contains_template_placeholder("trailing-$"));
5551        assert!(!requirement_contains_template_placeholder("empty-${}"));
5552        assert!(!requirement_contains_template_placeholder("${123}"));
5553    }
5554
5555    #[test]
5556    fn test_requirement_contains_template_placeholder_unclosed_brace_fails_safe() {
5557        assert!(requirement_contains_template_placeholder("${VAR"));
5558    }
5559
5560    #[test]
5561    fn test_requirement_contains_template_placeholder_dollar_paren_form() {
5562        assert!(requirement_contains_template_placeholder(
5563            "$(LODASH_VERSION)"
5564        ));
5565        assert!(requirement_contains_template_placeholder(
5566            "require golang.org/x/net $(NET_VERSION)"
5567        ));
5568    }
5569
5570    /// #1417: unlike `${VAR`'s fail-open precedent, `$(` requires the closing `)` — an
5571    /// unclosed `$(VAR` must NOT be classified as a placeholder on that basis alone.
5572    #[test]
5573    fn test_requirement_contains_template_placeholder_dollar_paren_unclosed_not_matched() {
5574        assert!(!requirement_contains_template_placeholder("$(VAR"));
5575        assert!(!requirement_contains_template_placeholder(
5576            "price-is-$(five"
5577        ));
5578    }
5579
5580    #[test]
5581    fn test_requirement_contains_template_placeholder_dollar_paren_no_identifier_not_matched() {
5582        assert!(!requirement_contains_template_placeholder("$()"));
5583        assert!(!requirement_contains_template_placeholder("$(123)"));
5584        assert!(!requirement_contains_template_placeholder(
5585            "$(SERDE VERSION)"
5586        ));
5587    }
5588
5589    /// Spec 070 / issue #1421: `-` is accepted in subsequent positions inside `$(...)`, real
5590    /// MSBuild property-name syntax (`$(MOD-VERSION)`).
5591    #[test]
5592    fn test_requirement_contains_template_placeholder_dollar_paren_hyphen_form() {
5593        assert!(requirement_contains_template_placeholder("$(MOD-VERSION)"));
5594        assert!(requirement_contains_template_placeholder(
5595            "$(SERDE-VERSION)"
5596        ));
5597        assert!(requirement_contains_template_placeholder(
5598            "1.0.0-$(BUILD-SUFFIX)"
5599        ));
5600        assert!(!requirement_contains_template_placeholder("$(-VERSION)"));
5601    }
5602
5603    /// Spec 070 / issue #1421 impl-critic M2: a trailing hyphen, consecutive hyphens, and a
5604    /// leading underscore followed by a hyphen are all still valid subsequent-position
5605    /// characters and must match; a bracketed content consisting of only a hyphen fails the
5606    /// first-character constraint (never a valid leading character) and must NOT match.
5607    #[test]
5608    fn test_requirement_contains_template_placeholder_dollar_paren_hyphen_edge_cases() {
5609        assert!(requirement_contains_template_placeholder("$(VERSION-)"));
5610        assert!(requirement_contains_template_placeholder("$(MOD--VERSION)"));
5611        assert!(requirement_contains_template_placeholder("$(_-X)"));
5612        assert!(!requirement_contains_template_placeholder("$(-)"));
5613    }
5614
5615    /// Spec 070 / issue #1421: `.` anywhere before the closing `)` remains a deliberate,
5616    /// researched exclusion (not valid MSBuild syntax) — never matched, even combined with a
5617    /// hyphen.
5618    #[test]
5619    fn test_requirement_contains_template_placeholder_dollar_paren_dotted_form_not_matched() {
5620        assert!(!requirement_contains_template_placeholder("$(A.VERSION)"));
5621        assert!(!requirement_contains_template_placeholder(
5622            "$(MOD.SUB-VERSION)"
5623        ));
5624    }
5625
5626    /// #1391 fixture/detector drift guard: every entry in
5627    /// [`crate::conformance::GENERIC_TEMPLATE_PLACEHOLDERS`] (the mandatory conformance
5628    /// fixture every ecosystem's `formatter_conformance!` invocation is checked against) must
5629    /// actually be recognized by the shared detector itself — otherwise a fixture entry that
5630    /// silently stopped matching would make every crate's conformance test pass vacuously
5631    /// instead of proving anything about that form.
5632    #[test]
5633    fn test_generic_template_placeholders_fixture_matches_detector() {
5634        for &placeholder in crate::conformance::GENERIC_TEMPLATE_PLACEHOLDERS {
5635            assert!(
5636                requirement_contains_template_placeholder(placeholder),
5637                "{placeholder:?} (from GENERIC_TEMPLATE_PLACEHOLDERS) must be recognized by \
5638                 requirement_contains_template_placeholder"
5639            );
5640        }
5641    }
5642
5643    #[test]
5644    fn test_requirement_contains_template_placeholder_mustache_form() {
5645        assert!(requirement_contains_template_placeholder(
5646            "{{ STD_VERSION }}"
5647        ));
5648        assert!(requirement_contains_template_placeholder("{{STD_VERSION}}"));
5649        assert!(requirement_contains_template_placeholder(
5650            "{{ .NetVersion }}"
5651        ));
5652        assert!(requirement_contains_template_placeholder(
5653            "v1.2-{{ BUILD | default }}"
5654        ));
5655        // Unclosed still fails safe, mirroring the `${VAR` precedent.
5656        assert!(requirement_contains_template_placeholder("{{ VAR"));
5657    }
5658
5659    #[test]
5660    fn test_requirement_contains_template_placeholder_mustache_empty_not_flagged() {
5661        assert!(!requirement_contains_template_placeholder("{{}}"));
5662        assert!(!requirement_contains_template_placeholder("{{ }}"));
5663    }
5664
5665    /// Security M1 follow-up (impl-critic M1): `{% ... %}` Jinja2/Liquid statement tags
5666    /// alongside `{{ ... }}` expression tags.
5667    #[test]
5668    fn test_requirement_contains_template_placeholder_jinja_statement_tag() {
5669        assert!(requirement_contains_template_placeholder("{% if x %}"));
5670        assert!(requirement_contains_template_placeholder("{%if x%}"));
5671        assert!(requirement_contains_template_placeholder("{%- if x -%}"));
5672        assert!(requirement_contains_template_placeholder(
5673            "v1.2-{% BUILD %}"
5674        ));
5675        assert!(requirement_contains_template_placeholder("{% VAR"));
5676        assert!(!requirement_contains_template_placeholder("{%}"));
5677        assert!(!requirement_contains_template_placeholder("{% %}"));
5678    }
5679
5680    #[test]
5681    fn test_requirement_contains_template_placeholder_autoconf_form() {
5682        assert!(requirement_contains_template_placeholder(
5683            "@PACKAGE_VERSION@"
5684        ));
5685        assert!(requirement_contains_template_placeholder("v1.2-@BUILD@"));
5686        // No closing '@' — not flagged (unlike the '$'/'{{' forms, both delimiters required).
5687        assert!(!requirement_contains_template_placeholder(
5688            "@PACKAGE_VERSION"
5689        ));
5690        assert!(!requirement_contains_template_placeholder("me@example.com"));
5691    }
5692
5693    /// impl-critic M1: the dotted/hyphenated CMake/Autotools `configure_file` form — the
5694    /// actual common real-world `@..@` shape, unlike the bare-identifier form above.
5695    #[test]
5696    fn test_requirement_contains_template_placeholder_autoconf_dotted_form() {
5697        assert!(requirement_contains_template_placeholder(
5698            "@project.version@"
5699        ));
5700        assert!(requirement_contains_template_placeholder(
5701            "@PACKAGE_VERSION_MAJOR@"
5702        ));
5703        assert!(requirement_contains_template_placeholder("@some-flag@"));
5704        assert!(requirement_contains_template_placeholder(
5705            "v@project.version@"
5706        ));
5707        // No closing '@' — still not flagged, even with dots in the tail.
5708        assert!(!requirement_contains_template_placeholder(
5709            "@project.version"
5710        ));
5711    }
5712
5713    #[test]
5714    fn test_requirement_contains_template_placeholder_percent_form() {
5715        assert!(requirement_contains_template_placeholder("%VERSION%"));
5716        assert!(requirement_contains_template_placeholder("v1.2-%BUILD%"));
5717        assert!(!requirement_contains_template_placeholder("%VERSION"));
5718        assert!(!requirement_contains_template_placeholder("100%"));
5719    }
5720
5721    #[test]
5722    fn test_requirement_contains_template_placeholder_erb_form() {
5723        assert!(requirement_contains_template_placeholder("<%= version %>"));
5724        assert!(requirement_contains_template_placeholder("<%version%>"));
5725        assert!(requirement_contains_template_placeholder("<%- version %>"));
5726        assert!(!requirement_contains_template_placeholder("<%%>"));
5727        assert!(!requirement_contains_template_placeholder("<% %>"));
5728    }
5729
5730    #[test]
5731    fn test_is_safe_version_string_accepts_ordinary_versions() {
5732        assert!(is_safe_version_string("1.2.3"));
5733        assert!(is_safe_version_string("1.2.3-beta.1+build"));
5734        assert!(is_safe_version_string("v1.2.3"));
5735    }
5736
5737    #[test]
5738    fn test_is_safe_version_string_rejects_empty_or_whitespace() {
5739        assert!(!is_safe_version_string(""));
5740        assert!(!is_safe_version_string("   "));
5741        assert!(!is_safe_version_string("\t\n"));
5742    }
5743
5744    #[test]
5745    fn test_is_safe_version_string_rejects_control_and_structural_characters() {
5746        for bad in [
5747            "1.2.3\n",
5748            "1.2.3\t",
5749            "1.2.3\"",
5750            "1.2.3'",
5751            "1.2.3<",
5752            "1.2.3>",
5753            "1.2.3&",
5754            "1.2.3\\",
5755            "1.0.0\", \"malicious\": \"true",
5756        ] {
5757            assert!(
5758                !is_safe_version_string(bad),
5759                "expected {bad:?} to be rejected"
5760            );
5761        }
5762    }
5763
5764    #[test]
5765    fn test_is_safe_version_string_rejects_gradle_interpolation_payload() {
5766        // Regression (critic S2): `$`/`{`/`}` are outside the allowlist, so a Gradle
5767        // `${...}` interpolation payload can never reach a version literal via this gate.
5768        for bad in ["1.0${System.getenv(\"X\")}", "1.0$var", "${evil}"] {
5769            assert!(
5770                !is_safe_version_string(bad),
5771                "expected {bad:?} to be rejected"
5772            );
5773        }
5774    }
5775
5776    #[test]
5777    fn test_is_safe_version_string_rejects_invisible_unicode() {
5778        // Regression (critic M1): `char::is_control()` alone only covers category Cc — the
5779        // bidi override U+202E, zero-width space U+200B, and JS/JSON5 line terminators
5780        // U+2028/U+2029 must also be rejected.
5781        for bad in ["1.2.3\u{202E}", "1.2.3\u{200B}", "1.2.3\u{2028}"] {
5782            assert!(
5783                !is_safe_version_string(bad),
5784                "expected {bad:?} to be rejected"
5785            );
5786        }
5787    }
5788
5789    #[test]
5790    fn test_is_safe_version_string_accepts_pep440_epoch() {
5791        // PEP 440 epochs (`1!2.0`) are legitimate PyPI versions.
5792        assert!(is_safe_version_string("1!2.0"));
5793    }
5794
5795    #[test]
5796    fn test_is_safe_version_string_length_cap() {
5797        assert!(is_safe_version_string(&"1".repeat(64)));
5798        assert!(!is_safe_version_string(&"1".repeat(65)));
5799    }
5800
5801    #[test]
5802    fn test_is_safe_maven_coordinate_segment_accepts_real_ids() {
5803        assert!(is_safe_maven_coordinate_segment("org.apache.commons"));
5804        assert!(is_safe_maven_coordinate_segment("commons-lang3"));
5805        assert!(is_safe_maven_coordinate_segment("jackson-core_2.13"));
5806    }
5807
5808    #[test]
5809    fn test_is_safe_maven_coordinate_segment_rejects_empty() {
5810        assert!(!is_safe_maven_coordinate_segment(""));
5811    }
5812
5813    #[test]
5814    fn test_is_safe_maven_coordinate_segment_rejects_xml_structural_characters() {
5815        for bad in [
5816            "commons</artifactId><parent>",
5817            "commons\"",
5818            "commons'",
5819            "commons&amp;",
5820            "commons\nlang3",
5821            "commons\tlang3",
5822        ] {
5823            assert!(
5824                !is_safe_maven_coordinate_segment(bad),
5825                "expected {bad:?} to be rejected"
5826            );
5827        }
5828    }
5829
5830    #[test]
5831    fn test_is_safe_maven_coordinate_segment_rejects_group_artifact_colon() {
5832        assert!(!is_safe_maven_coordinate_segment(
5833            "org.apache.commons:commons-lang3"
5834        ));
5835    }
5836
5837    #[test]
5838    fn test_is_safe_maven_coordinate_segment_length_cap() {
5839        assert!(is_safe_maven_coordinate_segment(&"a".repeat(128)));
5840        assert!(!is_safe_maven_coordinate_segment(&"a".repeat(129)));
5841    }
5842
5843    #[test]
5844    fn test_is_safe_registry_url_accepts_real_urls() {
5845        assert!(is_safe_registry_url("https://github.com/apple/swift-nio"));
5846        assert!(is_safe_registry_url(
5847            "https://github.com/apple/swift-nio.git"
5848        ));
5849        assert!(is_safe_registry_url("https://github.com/apple/swift%2Dnio"));
5850    }
5851
5852    #[test]
5853    fn test_is_safe_registry_url_rejects_non_https_scheme() {
5854        // Every real Swift package registry response is HTTPS; accepting `http://` would
5855        // only hand a compromised registry a transport-downgrade lever.
5856        assert!(!is_safe_registry_url("http://example.com/repo"));
5857        assert!(!is_safe_registry_url("file:///etc/passwd"));
5858        assert!(!is_safe_registry_url("javascript:alert(1)"));
5859        assert!(!is_safe_registry_url("ftp://example.com/repo"));
5860        assert!(!is_safe_registry_url(""));
5861    }
5862
5863    #[test]
5864    fn test_is_safe_registry_url_rejects_swift_string_literal_breakout() {
5865        for bad in [
5866            "https://evil.example\", .exact(\"1.0.0\")), .package(url: \"https://real",
5867            "https://evil.example\\",
5868            "https://evil.example\nlet x = 1",
5869            "https://evil.example`echo`",
5870            "https://evil.example<script>",
5871        ] {
5872            assert!(
5873                !is_safe_registry_url(bad),
5874                "expected {bad:?} to be rejected"
5875            );
5876        }
5877    }
5878
5879    #[test]
5880    fn test_is_safe_registry_url_length_cap() {
5881        let prefix = "https://example.com/";
5882        let at_cap = format!("{prefix}{}", "a".repeat(2048 - prefix.len()));
5883        assert_eq!(at_cap.len(), 2048);
5884        assert!(is_safe_registry_url(&at_cap));
5885
5886        let over_cap = format!("{at_cap}a");
5887        assert_eq!(over_cap.len(), 2049);
5888        assert!(!is_safe_registry_url(&over_cap));
5889    }
5890
5891    #[test]
5892    fn test_is_safe_package_name_accepts_real_names_across_ecosystems() {
5893        for good in [
5894            "serde",                            // Cargo
5895            "requests",                         // PyPI
5896            "@scope/name",                      // npm/Deno scoped
5897            "monolog/monolog",                  // Composer vendor/package
5898            "github.com/org/repo",              // Go module path
5899            "path",                             // Dart
5900            "org.apache.commons:commons-lang3", // Gradle group:artifact
5901            "Newtonsoft.Json",                  // NuGet
5902            "rails",                            // Bundler
5903            "npm:react",                        // Deno npm-scheme specifier
5904            "jsr:@std/fs",                      // Deno jsr-scheme specifier
5905            "github.com/foo/bar~compat",        // Go path element with `~`
5906        ] {
5907            assert!(
5908                is_safe_package_name(good),
5909                "expected {good:?} to be accepted"
5910            );
5911        }
5912    }
5913
5914    #[test]
5915    fn test_is_safe_package_name_rejects_empty() {
5916        assert!(!is_safe_package_name(""));
5917    }
5918
5919    #[test]
5920    fn test_is_safe_package_name_rejects_non_ascii() {
5921        // Deliberately excluded: the allowlist is ASCII-only, so a legacy non-ASCII
5922        // npm package name (a handful exist, e.g. Unicode-normalized scopes) is
5923        // rejected rather than risking homograph/normalization tricks in a manifest.
5924        assert!(!is_safe_package_name("café"));
5925        assert!(!is_safe_package_name("пакет"));
5926    }
5927
5928    #[test]
5929    fn test_is_safe_package_name_accepts_dot_dot_shapes() {
5930        // `.`/`/` are individually legal (PyPI dotted names, npm/Composer scopes), so
5931        // `..`/`../..` pass the charset too. This is not a path-traversal risk: every
5932        // sink treats `name` as manifest text (a TOML/JSON/YAML/XML value or a
5933        // string-literal argument), never as a filesystem path.
5934        assert!(is_safe_package_name(".."));
5935        assert!(is_safe_package_name("../.."));
5936    }
5937
5938    #[test]
5939    fn test_is_safe_package_name_rejects_structural_breakout_characters() {
5940        for bad in [
5941            "evil\"\nbackdoor = \"9.9.9",
5942            "evil\", git = \"https://evil",
5943            "evil\\",
5944            "evil'",
5945            "evil<script>",
5946            "evil`echo`",
5947            "evil\ninjected = true",
5948            "evil\tname",
5949        ] {
5950            assert!(
5951                !is_safe_package_name(bad),
5952                "expected {bad:?} to be rejected"
5953            );
5954        }
5955    }
5956
5957    #[test]
5958    fn test_is_safe_package_name_length_cap() {
5959        assert!(is_safe_package_name(&"a".repeat(256)));
5960        assert!(!is_safe_package_name(&"a".repeat(257)));
5961    }
5962
5963    #[test]
5964    fn test_is_safe_feature_name_accepts_real_names() {
5965        for good in ["derive", "std_alloc-v2+extra"] {
5966            assert!(
5967                is_safe_feature_name(good),
5968                "expected {good:?} to be accepted"
5969            );
5970        }
5971    }
5972
5973    #[test]
5974    fn test_is_safe_feature_name_rejects_empty() {
5975        assert!(!is_safe_feature_name(""));
5976    }
5977
5978    #[test]
5979    fn test_is_safe_feature_name_rejects_non_ascii() {
5980        // ASCII-only, so homograph/bidi-spoofing characters are rejected outright.
5981        assert!(!is_safe_feature_name("café"));
5982        // U+202E RIGHT-TO-LEFT OVERRIDE.
5983        assert!(!is_safe_feature_name("evil\u{202E}reversed"));
5984    }
5985
5986    #[test]
5987    fn test_is_safe_feature_name_rejects_enable_syntax() {
5988        // `dep:`/`?`/`/` are enable-syntax, only in a feature's value list, never in its name key.
5989        for bad in ["dep:ravif", "rgb?/serde"] {
5990            assert!(
5991                !is_safe_feature_name(bad),
5992                "expected {bad:?} to be rejected"
5993            );
5994        }
5995    }
5996
5997    #[test]
5998    fn test_is_safe_feature_name_length_cap() {
5999        assert!(is_safe_feature_name(&"a".repeat(256)));
6000        assert!(!is_safe_feature_name(&"a".repeat(257)));
6001    }
6002
6003    #[test]
6004    fn test_is_same_major_minor_partial_versions() {
6005        assert!(is_same_major_minor("1.2", "1.2.3"));
6006        assert!(is_same_major_minor("1.2.3", "1.2"));
6007    }
6008
6009    #[test]
6010    fn test_ecosystem_formatter_defaults() {
6011        let formatter = MOCK_FORMATTER;
6012        assert_eq!(
6013            formatter.normalize_package_name(&pkg("test-pkg")),
6014            "test-pkg"
6015        );
6016        assert_eq!(formatter.yanked_message(), "This version has been yanked");
6017        assert_eq!(formatter.yanked_label(), "*(yanked)*");
6018    }
6019
6020    #[test]
6021    fn test_format_version_replacing_for_default_delegates_to_format_version_replacing() {
6022        let formatter = MOCK_FORMATTER;
6023        let dep = MockDep {
6024            name: pkg("test-pkg"),
6025            version_req: VersionReq::new("1.0.0"),
6026            version_range: Range::default(),
6027            name_range: Range::default(),
6028        };
6029        assert_eq!(
6030            formatter.format_version_replacing_for(&dep, &ConcreteVersion::new("1.2.3"), "1.0.0"),
6031            formatter.format_version_replacing(&ConcreteVersion::new("1.2.3"), "1.0.0")
6032        );
6033    }
6034
6035    #[test]
6036    fn test_ecosystem_formatter_version_satisfies() {
6037        let formatter = MOCK_FORMATTER;
6038
6039        assert!(formatter.version_satisfies_requirement(
6040            &ConcreteVersion::new("1.2.3"),
6041            &VersionReq::new("1.2.3")
6042        ));
6043
6044        assert!(formatter.version_satisfies_requirement(
6045            &ConcreteVersion::new("1.2.3"),
6046            &VersionReq::new("^1.2")
6047        ));
6048        assert!(formatter.version_satisfies_requirement(
6049            &ConcreteVersion::new("1.2.3"),
6050            &VersionReq::new("~1.2")
6051        ));
6052
6053        assert!(
6054            formatter.version_satisfies_requirement(
6055                &ConcreteVersion::new("1.2.3"),
6056                &VersionReq::new("1")
6057            )
6058        );
6059        assert!(formatter.version_satisfies_requirement(
6060            &ConcreteVersion::new("1.2.3"),
6061            &VersionReq::new("1.2")
6062        ));
6063
6064        assert!(!formatter.version_satisfies_requirement(
6065            &ConcreteVersion::new("1.2.3"),
6066            &VersionReq::new("2.0.0")
6067        ));
6068        assert!(!formatter.version_satisfies_requirement(
6069            &ConcreteVersion::new("1.2.3"),
6070            &VersionReq::new("1.3")
6071        ));
6072    }
6073
6074    #[test]
6075    fn test_ecosystem_formatter_custom_normalize() {
6076        struct PyPIFormatter;
6077
6078        impl PackageNaming for PyPIFormatter {
6079            fn normalize_package_name(&self, name: &PackageName) -> String {
6080                name.as_str().to_lowercase().replace('-', "_")
6081            }
6082        }
6083
6084        impl PackageRendering for PyPIFormatter {
6085            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
6086                format!(
6087                    ">={},<{}",
6088                    version,
6089                    version.as_str().split('.').next().unwrap_or("0")
6090                )
6091            }
6092
6093            fn package_url(&self, name: &PackageName) -> String {
6094                format!("https://pypi.org/project/{}", name.as_str())
6095            }
6096        }
6097
6098        impl RequirementResolution for PyPIFormatter {}
6099
6100        impl DiagnosticMessages for PyPIFormatter {}
6101
6102        impl DiagnosticPolicy for PyPIFormatter {}
6103
6104        impl SourcePolicy for PyPIFormatter {}
6105
6106        impl OsvNaming for PyPIFormatter {}
6107
6108        let formatter = PyPIFormatter;
6109        assert_eq!(
6110            formatter.normalize_package_name(&pkg("Test-Package")),
6111            "test_package"
6112        );
6113        assert_eq!(
6114            formatter.format_version_for_text_edit(&ConcreteVersion::new("1.2.3")),
6115            ">=1.2.3,<1"
6116        );
6117        assert_eq!(
6118            formatter.package_url(&pkg("requests")),
6119            "https://pypi.org/project/requests"
6120        );
6121    }
6122
6123    // --- upgrade_status_to_verdict structural staleness (issue #1531) ---
6124
6125    /// A [`Dependency`] whose `source()` is configurable, for exercising the
6126    /// `NonRegistrySource` structural-skip staleness gate — [`MockDep`] always reports
6127    /// [`DependencySource::Registry`], which can't represent "started non-registry, source
6128    /// changed to registry".
6129    struct DepWithSource {
6130        name: PackageName,
6131        name_range: crate::position::Range,
6132        source: DependencySource,
6133    }
6134
6135    impl Dependency for DepWithSource {
6136        fn name(&self) -> &PackageName {
6137            &self.name
6138        }
6139        fn name_range(&self) -> crate::position::Range {
6140            self.name_range
6141        }
6142        fn version_requirement(&self) -> Option<&VersionReq> {
6143            None
6144        }
6145        fn version_range(&self) -> Option<crate::position::Range> {
6146            None
6147        }
6148        fn source(&self) -> DependencySource {
6149            self.source.clone()
6150        }
6151        fn as_any(&self) -> &dyn std::any::Any {
6152            self
6153        }
6154    }
6155
6156    fn structural_skip_entry() -> UpgradeStatus {
6157        UpgradeStatus::StructurallyUnchecked(StructuralSkipReason::NonRegistrySource)
6158    }
6159
6160    /// Mirrors `deps-cargo`'s `CargoFormatter::source_is_public_registry_content` override
6161    /// (impl-critic S1, #1531): a verified crates.io mirror (`AlternateRegistry {
6162    /// mirrors_crates_io: true, .. }`, reached via a `[source.crates-io] replace-with` chain)
6163    /// counts as public-registry content alongside plain `Registry`. A local double rather than
6164    /// depending on `deps-cargo` from `deps-core` (wrong dependency direction).
6165    struct MockMirrorFormatter;
6166    impl PackageNaming for MockMirrorFormatter {}
6167    impl PackageRendering for MockMirrorFormatter {
6168        fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
6169            version.to_string()
6170        }
6171        fn package_url(&self, name: &PackageName) -> String {
6172            name.as_str().to_string()
6173        }
6174    }
6175    impl RequirementResolution for MockMirrorFormatter {}
6176    impl DiagnosticMessages for MockMirrorFormatter {}
6177    impl DiagnosticPolicy for MockMirrorFormatter {}
6178    impl SourcePolicy for MockMirrorFormatter {
6179        fn source_is_public_registry_content(&self, source: &DependencySource) -> bool {
6180            matches!(
6181                source,
6182                DependencySource::Registry
6183                    | DependencySource::AlternateRegistry {
6184                        mirrors_crates_io: true,
6185                        ..
6186                    }
6187            )
6188        }
6189    }
6190    impl OsvNaming for MockMirrorFormatter {}
6191
6192    /// #1531 regression: a dependency whose source is *still* non-registry keeps the existing
6193    /// structural-fallback behavior — the cached `NonRegistrySource` skip is not stale, so it
6194    /// resolves to `NotApplicable` for both the "latest" and per-candidate verdicts.
6195    #[test]
6196    fn structural_skip_same_source_stays_not_applicable() {
6197        let dep = DepWithSource {
6198            name: pkg("vendored-lib"),
6199            name_range: crate::position::Range::default(),
6200            source: DependencySource::Path {
6201                path: "../vendored-lib".to_string(),
6202            },
6203        };
6204        let formatter = crate::test_util::StubFormatter::new();
6205
6206        let mut latest_status = LatestStatusMap::new();
6207        latest_status.insert(
6208            crate::test_util::vuln_key("vendored-lib"),
6209            structural_skip_entry(),
6210        );
6211        assert_eq!(
6212            latest_verdict(
6213                Some(&latest_status),
6214                &dep,
6215                None,
6216                "vendored-lib",
6217                "1.0.0",
6218                &formatter
6219            ),
6220            LatestVerdict::NotApplicable
6221        );
6222
6223        let mut candidate_status = CandidateStatusMap::new();
6224        candidate_status.insert(
6225            crate::test_util::vuln_key("vendored-lib"),
6226            CandidateStatuses::Structural(StructuralSkipReason::NonRegistrySource),
6227        );
6228        assert_eq!(
6229            candidate_verdict(
6230                Some(&candidate_status),
6231                &dep,
6232                None,
6233                "vendored-lib",
6234                "1.0.0",
6235                &formatter
6236            ),
6237            LatestVerdict::NotApplicable
6238        );
6239    }
6240
6241    /// #1531: a dependency that *started* with a non-registry source (git/path) picks up a
6242    /// `CandidateUnverified{NonRegistrySource}` structural entry under its plain name key. If
6243    /// the manifest is then edited so the same dependency now resolves to a registry source,
6244    /// that cached entry is stale until the next phase B run overwrites it — the verdict must
6245    /// fail closed to `Unverified` for the new registry version, never silently pass through
6246    /// as `NotApplicable` ("safe to offer").
6247    #[test]
6248    fn structural_skip_source_changed_to_registry_is_not_stale_safe() {
6249        let dep = DepWithSource {
6250            name: pkg("vendored-lib"),
6251            name_range: crate::position::Range::default(),
6252            source: DependencySource::Registry,
6253        };
6254        let formatter = crate::test_util::StubFormatter::new();
6255
6256        let mut latest_status = LatestStatusMap::new();
6257        latest_status.insert(
6258            crate::test_util::vuln_key("vendored-lib"),
6259            structural_skip_entry(),
6260        );
6261        assert_eq!(
6262            latest_verdict(
6263                Some(&latest_status),
6264                &dep,
6265                None,
6266                "vendored-lib",
6267                "2.0.0",
6268                &formatter
6269            ),
6270            LatestVerdict::Unverified
6271        );
6272
6273        let mut candidate_status = CandidateStatusMap::new();
6274        candidate_status.insert(
6275            crate::test_util::vuln_key("vendored-lib"),
6276            CandidateStatuses::Structural(StructuralSkipReason::NonRegistrySource),
6277        );
6278        assert_eq!(
6279            candidate_verdict(
6280                Some(&candidate_status),
6281                &dep,
6282                None,
6283                "vendored-lib",
6284                "2.0.0",
6285                &formatter
6286            ),
6287            LatestVerdict::Unverified
6288        );
6289    }
6290
6291    /// #1531 impl-critic S1: phase B classifies `NonRegistrySource` via
6292    /// `formatter.source_is_public_registry_content`, not a bare `DependencySource::Registry`
6293    /// match — an ecosystem like `deps-cargo` widens that predicate to also accept a verified
6294    /// crates.io mirror (`AlternateRegistry { mirrors_crates_io: true, .. }`). A path dependency
6295    /// edited to such a mirrored registry dependency must be recognized as a source change too,
6296    /// not stay wrongly `NotApplicable` just because it isn't the literal `Registry` variant.
6297    #[test]
6298    fn structural_skip_source_changed_to_mirrored_alternate_registry_is_not_stale_safe() {
6299        let dep = DepWithSource {
6300            name: pkg("vendored-lib"),
6301            name_range: crate::position::Range::default(),
6302            source: DependencySource::AlternateRegistry {
6303                index: "https://crates-mirror.example.com/index".to_string(),
6304                mirrors_crates_io: true,
6305            },
6306        };
6307        let formatter = MockMirrorFormatter;
6308
6309        let mut latest_status = LatestStatusMap::new();
6310        latest_status.insert(
6311            crate::test_util::vuln_key("vendored-lib"),
6312            structural_skip_entry(),
6313        );
6314        assert_eq!(
6315            latest_verdict(
6316                Some(&latest_status),
6317                &dep,
6318                None,
6319                "vendored-lib",
6320                "2.0.0",
6321                &formatter
6322            ),
6323            LatestVerdict::Unverified
6324        );
6325
6326        let mut candidate_status = CandidateStatusMap::new();
6327        candidate_status.insert(
6328            crate::test_util::vuln_key("vendored-lib"),
6329            CandidateStatuses::Structural(StructuralSkipReason::NonRegistrySource),
6330        );
6331        assert_eq!(
6332            candidate_verdict(
6333                Some(&candidate_status),
6334                &dep,
6335                None,
6336                "vendored-lib",
6337                "2.0.0",
6338                &formatter
6339            ),
6340            LatestVerdict::Unverified
6341        );
6342    }
6343
6344    /// #1624 critique M3: `UnmappableName` is structural but — unlike `NonRegistrySource` —
6345    /// [`LatestStatusMap`] still records a real candidate version for it (see
6346    /// `deps_engine::classify::osv::build_latest_check_targets`), so it stays a
6347    /// `CandidateUnverified{version, reason}` entry there while [`CandidateStatusMap`] (which
6348    /// has no single-version slot for the whole dependency) represents the identical skip as
6349    /// `CandidateStatuses::Structural`. Both must resolve to `NotApplicable`.
6350    #[test]
6351    fn unmappable_name_resolves_to_not_applicable_for_latest_and_candidate() {
6352        let dep = DepWithSource {
6353            name: pkg("jsr-pinned"),
6354            name_range: crate::position::Range::default(),
6355            source: DependencySource::Registry,
6356        };
6357        let formatter = crate::test_util::StubFormatter::new();
6358
6359        let mut latest_status = LatestStatusMap::new();
6360        latest_status.insert(
6361            crate::test_util::vuln_key("jsr-pinned"),
6362            UpgradeStatus::CandidateUnverified {
6363                version: ConcreteVersion::new("1.0.0"),
6364                reason: SkipReason::UnmappableName,
6365            },
6366        );
6367        assert_eq!(
6368            latest_verdict(
6369                Some(&latest_status),
6370                &dep,
6371                None,
6372                "jsr-pinned",
6373                "1.0.0",
6374                &formatter
6375            ),
6376            LatestVerdict::NotApplicable
6377        );
6378
6379        let mut candidate_status = CandidateStatusMap::new();
6380        candidate_status.insert(
6381            crate::test_util::vuln_key("jsr-pinned"),
6382            CandidateStatuses::Structural(StructuralSkipReason::UnmappableName),
6383        );
6384        assert_eq!(
6385            candidate_verdict(
6386                Some(&candidate_status),
6387                &dep,
6388                None,
6389                "jsr-pinned",
6390                "1.0.0",
6391                &formatter
6392            ),
6393            LatestVerdict::NotApplicable
6394        );
6395    }
6396
6397    /// Spec 076 SC-014: `fallback_edit_excludes_newer`, one test per [`FallbackEditRejection`]
6398    /// variant plus `Writable`. `deps-core` cannot depend on the ecosystem crates, so this
6399    /// module uses two stub formatters (a semver-backed stub mirroring Cargo's real semantics,
6400    /// and a NuGet-floor stub) — the same probe values are pinned against REAL formatters in
6401    /// `deps-cargo`/`deps-nuget` as part of T005/SC-018.
6402    mod fallback_edit_excludes_newer_tests {
6403        use super::*;
6404        use crate::ParseResult;
6405        use crate::edit::{ManifestEdit, ManifestReparse};
6406        use crate::position::Position;
6407
6408        /// Real-semver-backed matcher, mirroring Cargo's actual resolution semantics.
6409        struct SemverMatcher(semver::VersionReq);
6410        impl RequirementMatcher for SemverMatcher {
6411            fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
6412                semver::Version::parse(version.as_str())
6413                    .ok()
6414                    .map(|v| self.0.matches(&v))
6415            }
6416            fn strict_prerelease_exclusion(&self) -> bool {
6417                true
6418            }
6419        }
6420
6421        /// SC-014's "semver-backed stub": real `semver::VersionReq` compilation, default
6422        /// `is_bounded_requirement_up_to_date`/`bounded_requirement_already_resolves_to` (Cargo-like).
6423        struct SemverFormatter;
6424        impl PackageNaming for SemverFormatter {}
6425        impl PackageRendering for SemverFormatter {
6426            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
6427                version.to_string()
6428            }
6429            fn package_url(&self, name: &PackageName) -> String {
6430                name.as_str().to_string()
6431            }
6432        }
6433        impl RequirementResolution for SemverFormatter {
6434            fn compile_bounded_requirement(
6435                &self,
6436                requirement: BoundedVersionReq<'_>,
6437            ) -> Option<Box<dyn RequirementMatcher>> {
6438                semver::VersionReq::parse(requirement.as_str())
6439                    .ok()
6440                    .map(|req| Box::new(SemverMatcher(req)) as Box<dyn RequirementMatcher>)
6441            }
6442        }
6443        impl DiagnosticMessages for SemverFormatter {}
6444        impl DiagnosticPolicy for SemverFormatter {}
6445        impl SourcePolicy for SemverFormatter {}
6446        impl OsvNaming for SemverFormatter {}
6447
6448        /// Same as [`SemverFormatter`], but `compile_bounded_requirement` fails for the literal text
6449        /// `"uncompilable"` — isolates the a1 `EditedUncompilable` check from a0, which a
6450        /// formatter that always fails to compile could not do (phase 1 would reject first).
6451        struct SelectivelyUncompilableFormatter;
6452        impl PackageNaming for SelectivelyUncompilableFormatter {}
6453        impl PackageRendering for SelectivelyUncompilableFormatter {
6454            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
6455                version.to_string()
6456            }
6457            fn package_url(&self, name: &PackageName) -> String {
6458                name.as_str().to_string()
6459            }
6460        }
6461        impl RequirementResolution for SelectivelyUncompilableFormatter {
6462            fn compile_bounded_requirement(
6463                &self,
6464                requirement: BoundedVersionReq<'_>,
6465            ) -> Option<Box<dyn RequirementMatcher>> {
6466                if requirement.as_str() == "uncompilable" {
6467                    return None;
6468                }
6469                semver::VersionReq::parse(requirement.as_str())
6470                    .ok()
6471                    .map(|req| Box::new(SemverMatcher(req)) as Box<dyn RequirementMatcher>)
6472            }
6473        }
6474        impl DiagnosticMessages for SelectivelyUncompilableFormatter {}
6475        impl DiagnosticPolicy for SelectivelyUncompilableFormatter {}
6476        impl SourcePolicy for SelectivelyUncompilableFormatter {}
6477        impl OsvNaming for SelectivelyUncompilableFormatter {}
6478
6479        /// A formatter with no `compile_bounded_requirement` override at all (trait default `None`) —
6480        /// for the a0 `OriginalUncompilable` check.
6481        struct NoCompileFormatter;
6482        impl PackageNaming for NoCompileFormatter {}
6483        impl PackageRendering for NoCompileFormatter {
6484            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
6485                version.to_string()
6486            }
6487            fn package_url(&self, name: &PackageName) -> String {
6488                name.as_str().to_string()
6489            }
6490        }
6491        impl RequirementResolution for NoCompileFormatter {}
6492        impl DiagnosticMessages for NoCompileFormatter {}
6493        impl DiagnosticPolicy for NoCompileFormatter {}
6494        impl SourcePolicy for NoCompileFormatter {}
6495        impl OsvNaming for NoCompileFormatter {}
6496
6497        /// Issue #1580 (CWE-400 defense-in-depth mirroring #1472/#1578): otherwise
6498        /// semver-backed like [`SemverFormatter`] — a real compiling matcher, so the a0-pre/a1-pre
6499        /// gates in `fallback_edit_excludes_newer` are the only thing rejecting an oversized
6500        /// requirement (`compile_bounded_requirement` cannot even receive one).
6501        struct SemverBackedFormatter;
6502        impl PackageNaming for SemverBackedFormatter {}
6503        impl PackageRendering for SemverBackedFormatter {
6504            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
6505                version.to_string()
6506            }
6507            fn package_url(&self, name: &PackageName) -> String {
6508                name.as_str().to_string()
6509            }
6510        }
6511        impl RequirementResolution for SemverBackedFormatter {
6512            fn compile_bounded_requirement(
6513                &self,
6514                requirement: BoundedVersionReq<'_>,
6515            ) -> Option<Box<dyn RequirementMatcher>> {
6516                semver::VersionReq::parse(requirement.as_str())
6517                    .ok()
6518                    .map(|req| Box::new(SemverMatcher(req)) as Box<dyn RequirementMatcher>)
6519            }
6520        }
6521        impl DiagnosticMessages for SemverBackedFormatter {}
6522        impl DiagnosticPolicy for SemverBackedFormatter {}
6523        impl SourcePolicy for SemverBackedFormatter {}
6524        impl OsvNaming for SemverBackedFormatter {}
6525
6526        /// A floor matcher: `matches` is membership at-or-above the floor (mirrors NuGet's
6527        /// bare `Version="X"` shape), but resolution never goes below OR above the floor
6528        /// itself — a floor always resolves to its own lowest member.
6529        struct NugetFloorMatcher(semver::Version);
6530        impl RequirementMatcher for NugetFloorMatcher {
6531            fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
6532                semver::Version::parse(version.as_str())
6533                    .ok()
6534                    .map(|v| v >= self.0)
6535            }
6536            fn strict_prerelease_exclusion(&self) -> bool {
6537                false
6538            }
6539        }
6540
6541        /// SC-014's "NuGet-floor stub" (spec 076 M2): `is_bounded_requirement_up_to_date` and
6542        /// `bounded_requirement_already_resolves_to` overridden with the floor rule — floor at or
6543        /// above target is up to date; a floor resolves ONLY to itself, never forward.
6544        struct NugetFloorFormatter;
6545        impl PackageNaming for NugetFloorFormatter {}
6546        impl PackageRendering for NugetFloorFormatter {
6547            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
6548                version.to_string()
6549            }
6550            fn package_url(&self, name: &PackageName) -> String {
6551                name.as_str().to_string()
6552            }
6553        }
6554        impl RequirementResolution for NugetFloorFormatter {
6555            fn compile_bounded_requirement(
6556                &self,
6557                requirement: BoundedVersionReq<'_>,
6558            ) -> Option<Box<dyn RequirementMatcher>> {
6559                semver::Version::parse(requirement.as_str())
6560                    .ok()
6561                    .map(|floor| Box::new(NugetFloorMatcher(floor)) as Box<dyn RequirementMatcher>)
6562            }
6563            fn is_bounded_requirement_up_to_date(
6564                &self,
6565                requirement: BoundedVersionReq<'_>,
6566                latest: &ConcreteVersion,
6567            ) -> bool {
6568                semver::Version::parse(requirement.as_str())
6569                    .ok()
6570                    .zip(semver::Version::parse(latest.as_str()).ok())
6571                    .is_some_and(|(floor, latest)| floor >= latest)
6572            }
6573            fn bounded_requirement_already_resolves_to(
6574                &self,
6575                requirement: BoundedVersionReq<'_>,
6576                target: &ConcreteVersion,
6577            ) -> bool {
6578                semver::Version::parse(requirement.as_str())
6579                    .ok()
6580                    .zip(semver::Version::parse(target.as_str()).ok())
6581                    .is_some_and(|(floor, target)| floor == target)
6582            }
6583        }
6584        impl DiagnosticMessages for NugetFloorFormatter {}
6585        impl DiagnosticPolicy for NugetFloorFormatter {}
6586        impl SourcePolicy for NugetFloorFormatter {}
6587        impl OsvNaming for NugetFloorFormatter {}
6588
6589        /// Fix-cycle (#1571) stub matcher for a `!=`-exclusion requirement grammar
6590        /// (PyPI/Composer/Bundler all support one): `requirement` is `"<range>,!=<excluded>"`,
6591        /// `<range>` compiled with real `semver::VersionReq` and `<excluded>` an exact,
6592        /// string-compared exclusion — mirrors the shape enough to prove the guard's floor
6593        /// comparison alone cannot see a hole punched inside an otherwise-matching span.
6594        struct ExclusionMatcher {
6595            range: semver::VersionReq,
6596            excluded: String,
6597        }
6598        impl RequirementMatcher for ExclusionMatcher {
6599            fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
6600                if version.as_str() == self.excluded {
6601                    return Some(false);
6602                }
6603                semver::Version::parse(version.as_str())
6604                    .ok()
6605                    .map(|v| self.range.matches(&v))
6606            }
6607            fn strict_prerelease_exclusion(&self) -> bool {
6608                true
6609            }
6610            // Fix-cycle (#1571 critic S1): the intensional signal a real `!=` matcher
6611            // (PyPI/Composer/Bundler) provides — mirrors this stub's own `excluded` field
6612            // directly, the same way `Pep440Matcher`/`ComposerMatcher`/`RubygemsMatcher` read
6613            // their own parsed `!=` term instead of inferring it from `available`.
6614            fn explicitly_excludes(&self, version: &ConcreteVersion) -> bool {
6615                version.as_str() == self.excluded
6616            }
6617        }
6618
6619        struct ExclusionFormatter;
6620        impl PackageNaming for ExclusionFormatter {}
6621        impl PackageRendering for ExclusionFormatter {
6622            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
6623                version.to_string()
6624            }
6625            fn package_url(&self, name: &PackageName) -> String {
6626                name.as_str().to_string()
6627            }
6628        }
6629        impl RequirementResolution for ExclusionFormatter {
6630            fn compile_bounded_requirement(
6631                &self,
6632                requirement: BoundedVersionReq<'_>,
6633            ) -> Option<Box<dyn RequirementMatcher>> {
6634                let (range, excluded) = requirement.as_str().split_once(",!=")?;
6635                let range = semver::VersionReq::parse(range).ok()?;
6636                Some(Box::new(ExclusionMatcher {
6637                    range,
6638                    excluded: excluded.to_string(),
6639                }) as Box<dyn RequirementMatcher>)
6640            }
6641            // Never "already up to date" — isolates the new check from c0, mirroring how
6642            // `SemverFormatter`'s own default already keeps c0/d0 independent for its tests.
6643            fn is_bounded_requirement_up_to_date(
6644                &self,
6645                _requirement: BoundedVersionReq<'_>,
6646                _latest: &ConcreteVersion,
6647            ) -> bool {
6648                false
6649            }
6650        }
6651        impl DiagnosticMessages for ExclusionFormatter {}
6652        impl DiagnosticPolicy for ExclusionFormatter {}
6653        impl SourcePolicy for ExclusionFormatter {}
6654        impl OsvNaming for ExclusionFormatter {}
6655
6656        struct StubDep {
6657            name: PackageName,
6658            requirement: Option<VersionReq>,
6659            version_start: Position,
6660        }
6661        impl Dependency for StubDep {
6662            fn name(&self) -> &PackageName {
6663                &self.name
6664            }
6665            fn name_range(&self) -> Range {
6666                Range::default()
6667            }
6668            fn version_requirement(&self) -> Option<&VersionReq> {
6669                self.requirement.as_ref()
6670            }
6671            fn version_range(&self) -> Option<Range> {
6672                Some(Range::new(self.version_start, self.version_start))
6673            }
6674            fn source(&self) -> crate::parser::DependencySource {
6675                crate::parser::DependencySource::Registry
6676            }
6677            fn as_any(&self) -> &dyn std::any::Any {
6678                self
6679            }
6680        }
6681
6682        struct StubParseResult {
6683            deps: Vec<StubDep>,
6684            uri: url::Url,
6685        }
6686        impl ParseResult for StubParseResult {
6687            fn dependencies(&self) -> Vec<&dyn Dependency> {
6688                self.deps.iter().map(|d| d as &dyn Dependency).collect()
6689            }
6690            fn workspace_root(&self) -> Option<&std::path::Path> {
6691                None
6692            }
6693            fn uri(&self) -> &url::Url {
6694                &self.uri
6695            }
6696            fn as_any(&self) -> &dyn std::any::Any {
6697                self
6698            }
6699        }
6700
6701        fn dep(requirement: &str) -> StubDep {
6702            StubDep {
6703                name: pkg("pkg"),
6704                requirement: Some(VersionReq::new(requirement)),
6705                version_start: Position::new(0, 0),
6706            }
6707        }
6708
6709        /// Matches `dep()`'s own `version_range()` (a zero-width span at `version_start`) so
6710        /// the D5 `CandidateSpanMismatch` precondition passes for every test that isn't
6711        /// specifically exercising it.
6712        fn edit() -> ManifestEdit {
6713            ManifestEdit {
6714                range: Range::new(Position::new(0, 0), Position::new(0, 0)),
6715                new_text: "x".to_string(),
6716            }
6717        }
6718
6719        fn versions(strs: &[&str]) -> Vec<ConcreteVersion> {
6720            strs.iter().map(|s| ConcreteVersion::new(*s)).collect()
6721        }
6722
6723        fn reparsed_to(requirement: &str) -> impl ManifestReparse {
6724            let requirement = requirement.to_string();
6725            move |_content: &str| -> Option<Box<dyn ParseResult>> {
6726                Some(Box::new(StubParseResult {
6727                    deps: vec![StubDep {
6728                        name: pkg("pkg"),
6729                        requirement: Some(VersionReq::new(requirement.as_str())),
6730                        version_start: Position::new(0, 0),
6731                    }],
6732                    uri: crate::test_util::test_uri("/test/manifest.toml"),
6733                }))
6734            }
6735        }
6736
6737        /// Issue #1580: R0 exceeds `MAX_REQUIREMENT_LEN` — the a0-pre gate must reject before
6738        /// `compile_bounded_requirement` is ever called; `SemverBackedFormatter`
6739        /// compiles normally, so only the gate can produce `Rejected`.
6740        #[test]
6741        fn original_oversized_a0_pre() {
6742            let oversized = "1".repeat(MAX_REQUIREMENT_LEN + 1);
6743            let verdict = fallback_edit_excludes_newer(
6744                &SemverBackedFormatter,
6745                &reparsed_to("1.1.0"),
6746                "content",
6747                &dep(&oversized),
6748                &edit(),
6749                &ConcreteVersion::new("1.1.0"),
6750                &versions(&["1.1.0"]),
6751            );
6752            assert_eq!(
6753                verdict,
6754                FallbackEditVerdict::Rejected(FallbackEditRejection::OriginalOversized)
6755            );
6756        }
6757
6758        #[test]
6759        fn original_uncompilable_a0() {
6760            let verdict = fallback_edit_excludes_newer(
6761                &NoCompileFormatter,
6762                &reparsed_to("1.1.0"),
6763                "content",
6764                &dep("^1.0"),
6765                &edit(),
6766                &ConcreteVersion::new("1.1.0"),
6767                &versions(&["1.1.0"]),
6768            );
6769            assert_eq!(
6770                verdict,
6771                FallbackEditVerdict::Rejected(FallbackEditRejection::OriginalUncompilable)
6772            );
6773        }
6774
6775        #[test]
6776        fn original_already_up_to_date_c0() {
6777            // NuGet-shaped floor `2.0.0`; a fallback below the floor is already "up to date".
6778            let verdict = fallback_edit_excludes_newer(
6779                &NugetFloorFormatter,
6780                &reparsed_to("1.9.0"),
6781                "content",
6782                &dep("2.0.0"),
6783                &edit(),
6784                &ConcreteVersion::new("1.9.0"),
6785                &versions(&["1.9.0"]),
6786            );
6787            assert_eq!(
6788                verdict,
6789                FallbackEditVerdict::Rejected(FallbackEditRejection::OriginalAlreadyUpToDate)
6790            );
6791        }
6792
6793        #[test]
6794        fn original_resolves_past_fallback_d0() {
6795            // Absent-path downgrade: unedited `^2.0` already resolves to 2.0.0, strictly newer
6796            // than the 1.9.0 fallback.
6797            let verdict = fallback_edit_excludes_newer(
6798                &SemverFormatter,
6799                &reparsed_to("1.9.0"),
6800                "content",
6801                &dep("^2.0"),
6802                &edit(),
6803                &ConcreteVersion::new("1.9.0"),
6804                &versions(&["2.0.0", "1.9.0"]),
6805            );
6806            assert_eq!(
6807                verdict,
6808                FallbackEditVerdict::Rejected(FallbackEditRejection::OriginalResolvesPastFallback)
6809            );
6810        }
6811
6812        #[test]
6813        fn original_excludes_fallback_via_not_equal() {
6814            // R0 ">=1.0.0,<2.0.0,!=1.5.0" (PyPI/Composer/Bundler `!=` shape): 1.5.0 sits inside
6815            // the nominal `[1.0.0,2.0.0)` span but is punched out by the exclusion, while 1.6.0
6816            // still matches and is strictly newer — the floor comparison alone (d0) would pass
6817            // this (the floor, 1.0.0, is not newer than 1.5.0), so only the #1571 check catches
6818            // it.
6819            let verdict = fallback_edit_excludes_newer(
6820                &ExclusionFormatter,
6821                &reparsed_to("1.5.0"),
6822                "content",
6823                &dep(">=1.0.0,<2.0.0,!=1.5.0"),
6824                &edit(),
6825                &ConcreteVersion::new("1.5.0"),
6826                &versions(&["1.6.0", "1.5.0", "1.0.0"]),
6827            );
6828            assert_eq!(
6829                verdict,
6830                FallbackEditVerdict::Rejected(FallbackEditRejection::OriginalExcludesFallback)
6831            );
6832        }
6833
6834        #[test]
6835        fn original_excludes_fallback_via_not_equal_is_caught_even_when_vacuous() {
6836            // Critic fix-cycle S1: NO listed `available` entry strictly newer than `fallback`
6837            // matches R0 here (2.0.0 fails the `<2.0.0` ceiling too), so the purely structural
6838            // "some newer entry still matches" heuristic alone is vacuous — it cannot tell this
6839            // apart from `>=1.0.0,<1.5.0` (a legitimate ceiling) over the identical `available`
6840            // list. Only `RequirementMatcher::explicitly_excludes` catches it.
6841            let verdict = fallback_edit_excludes_newer(
6842                &ExclusionFormatter,
6843                &reparsed_to("1.5.0"),
6844                "content",
6845                &dep(">=1.0.0,<2.0.0,!=1.5.0"),
6846                &edit(),
6847                &ConcreteVersion::new("1.5.0"),
6848                &versions(&["2.0.0", "1.5.0", "1.0.0"]),
6849            );
6850            assert_eq!(
6851                verdict,
6852                FallbackEditVerdict::Rejected(FallbackEditRejection::OriginalExcludesFallback)
6853            );
6854        }
6855
6856        #[test]
6857        fn ceiling_exceeding_fallback_is_not_excluded_via_not_equal_check() {
6858            // Cargo-shaped out-of-range acceptance (FR-025): `^1.0` fails `matches` for a
6859            // fallback of 2.5.0 too, but no R0-matching entry is newer than it — the #1571
6860            // check must not fire here, or it would reject spec 076's intentional
6861            // ceiling-exceeding case.
6862            let verdict = fallback_edit_excludes_newer(
6863                &SemverFormatter,
6864                &reparsed_to("^2.5.0"),
6865                "content",
6866                &dep("^1.0"),
6867                &edit(),
6868                &ConcreteVersion::new("2.5.0"),
6869                &versions(&["3.0.0", "2.5.0", "1.0.0"]),
6870            );
6871            assert_eq!(verdict, FallbackEditVerdict::Writable);
6872        }
6873
6874        #[test]
6875        fn reparse_failed() {
6876            // R0 "1.0" is a caret [1.0.0,2.0.0) — fallback 1.5.0 sits at its own floor (the
6877            // only listed entry, matching), so phase 1 passes and re-parse is actually reached.
6878            let reparse = |_content: &str| -> Option<Box<dyn ParseResult>> { None };
6879            let verdict = fallback_edit_excludes_newer(
6880                &SemverFormatter,
6881                &reparse,
6882                "content",
6883                &dep("1.0"),
6884                &edit(),
6885                &ConcreteVersion::new("1.5.0"),
6886                &versions(&["1.5.0"]),
6887            );
6888            assert_eq!(
6889                verdict,
6890                FallbackEditVerdict::Rejected(FallbackEditRejection::ReparseFailed)
6891            );
6892        }
6893
6894        #[test]
6895        fn occurrence_not_unique() {
6896            let reparse = |_content: &str| -> Option<Box<dyn ParseResult>> {
6897                Some(Box::new(StubParseResult {
6898                    deps: vec![
6899                        StubDep {
6900                            name: pkg("pkg"),
6901                            requirement: Some(VersionReq::new("1.5.0")),
6902                            version_start: Position::new(0, 0),
6903                        },
6904                        StubDep {
6905                            name: pkg("pkg"),
6906                            requirement: Some(VersionReq::new("1.5.0")),
6907                            version_start: Position::new(0, 0),
6908                        },
6909                    ],
6910                    uri: crate::test_util::test_uri("/test/manifest.toml"),
6911                }) as Box<dyn ParseResult>)
6912            };
6913            let verdict = fallback_edit_excludes_newer(
6914                &SemverFormatter,
6915                &reparse,
6916                "content",
6917                &dep("1.0"),
6918                &edit(),
6919                &ConcreteVersion::new("1.5.0"),
6920                &versions(&["1.5.0"]),
6921            );
6922            assert_eq!(
6923                verdict,
6924                FallbackEditVerdict::Rejected(FallbackEditRejection::OccurrenceNotUnique)
6925            );
6926        }
6927
6928        /// Issue #1580: R1 (the re-parsed EDITED requirement) exceeds `MAX_REQUIREMENT_LEN` —
6929        /// R0 ("1.0") is small and compiles fine, so phase 1 passes and phase 2 is reached; the
6930        /// a1-pre gate must then reject before `compile_bounded_requirement(R1)` is ever called.
6931        #[test]
6932        fn edited_oversized_a1_pre() {
6933            let oversized = "1".repeat(MAX_REQUIREMENT_LEN + 1);
6934            let verdict = fallback_edit_excludes_newer(
6935                &SemverBackedFormatter,
6936                &reparsed_to(&oversized),
6937                "content",
6938                &dep("1.0"),
6939                &edit(),
6940                &ConcreteVersion::new("1.5.0"),
6941                &versions(&["1.5.0"]),
6942            );
6943            assert_eq!(
6944                verdict,
6945                FallbackEditVerdict::Rejected(FallbackEditRejection::EditedOversized)
6946            );
6947        }
6948
6949        #[test]
6950        fn edited_uncompilable_a1() {
6951            let verdict = fallback_edit_excludes_newer(
6952                &SelectivelyUncompilableFormatter,
6953                &reparsed_to("uncompilable"),
6954                "content",
6955                &dep("1.0"),
6956                &edit(),
6957                &ConcreteVersion::new("1.5.0"),
6958                &versions(&["1.5.0"]),
6959            );
6960            assert_eq!(
6961                verdict,
6962                FallbackEditVerdict::Rejected(FallbackEditRejection::EditedUncompilable)
6963            );
6964        }
6965
6966        #[test]
6967        fn edited_excludes_fallback_b1() {
6968            // The re-parse stub yields an unsatisfiable-for-the-fallback `^5` — round-1 critic
6969            // M5: the written edit must actually express the fallback, not merely compile.
6970            let verdict = fallback_edit_excludes_newer(
6971                &SemverFormatter,
6972                &reparsed_to("^5"),
6973                "content",
6974                &dep("1.0"),
6975                &edit(),
6976                &ConcreteVersion::new("1.5.0"),
6977                &versions(&["1.5.0"]),
6978            );
6979            assert_eq!(
6980                verdict,
6981                FallbackEditVerdict::Rejected(FallbackEditRejection::EditedExcludesFallback)
6982            );
6983        }
6984
6985        #[test]
6986        fn edited_admits_newer_d1() {
6987            // R0 "2.0.0" is a caret [2.0.0,3.0.0), whose floor (2.0.1, the only listed match
6988            // at or above the fallback's own position) is at the fallback — phase 1 passes.
6989            // The re-parsed edit "2.0.1" is ALSO a caret ([2.0.1,3.0.0)), which still admits
6990            // the fresh 2.1.0 — d1 must reject that auto-follow regardless of d0's own verdict.
6991            let verdict = fallback_edit_excludes_newer(
6992                &SemverFormatter,
6993                &reparsed_to("2.0.1"),
6994                "content",
6995                &dep("2.0.0"),
6996                &edit(),
6997                &ConcreteVersion::new("2.0.1"),
6998                &versions(&["2.1.0", "2.0.1"]),
6999            );
7000            assert_eq!(
7001                verdict,
7002                FallbackEditVerdict::Rejected(FallbackEditRejection::EditedAdmitsNewer)
7003            );
7004        }
7005
7006        #[test]
7007        fn writable() {
7008            let verdict = fallback_edit_excludes_newer(
7009                &SemverFormatter,
7010                &reparsed_to("^2.5.0"),
7011                "content",
7012                &dep("2.0"),
7013                &edit(),
7014                &ConcreteVersion::new("2.5.0"),
7015                &versions(&["3.0.0", "2.5.0"]),
7016            );
7017            assert_eq!(verdict, FallbackEditVerdict::Writable);
7018        }
7019
7020        /// Spec 076 M2: the d0 `resolves_to` loosening for a NuGet `Located` floor. Spec 075
7021        /// rejected this (raw `matches` sees the floor as admitting the fresh version); this
7022        /// guard writes it, since a floor resolves only to its own lowest member.
7023        #[test]
7024        fn nuget_located_floor_loosening_is_writable() {
7025            // The floor's own value ("1.0.0") must be listed for the fix-cycle floor-comparison
7026            // scan to find it — realistic for production `available` (every published version,
7027            // unfiltered), since the floor is the user's own already-published declared pin.
7028            let verdict = fallback_edit_excludes_newer(
7029                &NugetFloorFormatter,
7030                &reparsed_to("1.1.0"),
7031                "content",
7032                &dep("1.0.0"),
7033                &edit(),
7034                &ConcreteVersion::new("1.1.0"),
7035                &versions(&["1.2.0", "1.1.0", "1.0.0"]),
7036            );
7037            assert_eq!(verdict, FallbackEditVerdict::Writable);
7038        }
7039
7040        /// Fix-cycle (impl-critic M2, D5 precondition): a `candidate` whose range does not
7041        /// equal this occurrence's own `version_range()` (e.g. an edit built for a different
7042        /// line) must be rejected before any check reads it — not silently treated as
7043        /// `Writable` just because R0/R1 happen to agree (a NuGet-floor R0 that already equals
7044        /// its own fallback resolves to `Writable` under every other check here).
7045        #[test]
7046        fn candidate_span_mismatch_is_rejected() {
7047            let mismatched_candidate = ManifestEdit {
7048                range: Range::new(Position::new(1, 0), Position::new(1, 0)),
7049                new_text: "1.1.0".to_string(),
7050            };
7051            let verdict = fallback_edit_excludes_newer(
7052                &NugetFloorFormatter,
7053                &reparsed_to("1.1.0"),
7054                "content",
7055                &dep("1.0.0"),
7056                &mismatched_candidate,
7057                &ConcreteVersion::new("1.1.0"),
7058                &versions(&["1.2.0", "1.1.0"]),
7059            );
7060            assert_eq!(
7061                verdict,
7062                FallbackEditVerdict::Rejected(FallbackEditRejection::CandidateSpanMismatch)
7063            );
7064        }
7065    }
7066}