Skip to main content

deps_core/
edit.rs

1//! Protocol-agnostic manifest-edit planning (issue #1329, spec 068).
2//!
3//! Ungated (unlike `lsp_helpers::code_lenses`/`lsp_helpers::code_actions`, which require the
4//! `lsp-responses` feature): `deps-cli update` links this module directly,
5//! and CI forbids `tower-lsp-server` from reaching `deps-cli`'s non-dev dependency tree (spec
6//! 064, #1083). [`collect_update_edits`] and [`plan_vulnerability_fix`] are the domain-level
7//! planners `deps-lsp`'s `lsp_helpers::code_lenses::collect_update_all_edits` and
8//! `lsp_helpers::code_actions::build_vulnerability_fix_action` now delegate to as thin
9//! adapters — the extraction shape spec 064 already applied to [`crate::diagnostic::Diagnostic`]
10//! and spec 063 applied to [`crate::Dependency`]/[`crate::lockfile::LockFileCache`].
11
12use crate::lsp_helpers::{
13    EcosystemFormatter, LineOffsetTable, RequirementGate, RequirementStatus, VersionData,
14    is_safe_version_string, literal_span_matches, requirement_is_oversized,
15    requirement_len_exceeds_cap, resolve_in_use_version, slice_for_range, strip_whitespace,
16    warn_rejected_value,
17};
18use crate::{ConcreteVersion, Dependency, ParseResult, VersionReq};
19
20/// Whether `current` — or `dep`'s own literal version span — is an unexpanded placeholder.
21///
22/// Checks both `current` (the requirement text a caller is about to consider rewriting) and
23/// [`Dependency::version_literal`] (when present) rather than `current` alone, folding
24/// `deps-swift`'s literal-vs-synthesized-comparator distinction (a `from: "\(v)"` declaration's
25/// `current` is a synthesized `">=\(v), <1.0.0"` comparator, while `version_literal` is the raw
26/// `\(v)` interpolation actually embedded in the manifest) into one shared check instead of a
27/// per-ecosystem `format_version_replacing_for` override having to re-derive it.
28///
29/// This is the gate every central edit-planning call site
30/// ([`collect_update_candidates`], [`plan_verified_fix`],
31/// `crate::lsp_helpers::code_actions`'s unsatisfiable-requirement fix builder and REFACTOR
32/// "Update to X" loop) checks before ever calling [`replacement_text`] — see that function's
33/// doc for why the two are split into a boolean gate and a text-producing step rather than one
34/// combined call.
35///
36/// # Examples
37///
38/// ```
39/// use deps_core::edit::requirement_is_placeholder_for;
40/// use deps_core::lsp_helpers::{DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering, RequirementResolution, SourcePolicy};
41/// use deps_core::{ConcreteVersion, Dependency, PackageName, VersionReq};
42///
43/// struct PlainFormatter;
44/// impl PackageNaming for PlainFormatter {}
45/// impl PackageRendering for PlainFormatter {
46///     fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
47///         version.to_string()
48///     }
49///     fn package_url(&self, name: &PackageName) -> String {
50///         name.as_str().to_string()
51///     }
52/// }
53/// impl RequirementResolution for PlainFormatter {}
54/// impl DiagnosticMessages for PlainFormatter {}
55/// impl DiagnosticPolicy for PlainFormatter {}
56/// impl SourcePolicy for PlainFormatter {}
57/// impl OsvNaming for PlainFormatter {}
58///
59/// struct PlainDependency(PackageName);
60/// impl Dependency for PlainDependency {
61///     fn name(&self) -> &PackageName {
62///         &self.0
63///     }
64///     fn name_range(&self) -> deps_core::position::Range {
65///         deps_core::position::Range::default()
66///     }
67///     fn version_requirement(&self) -> Option<&VersionReq> {
68///         None
69///     }
70///     fn version_range(&self) -> Option<deps_core::position::Range> {
71///         None
72///     }
73///     fn source(&self) -> deps_core::parser::DependencySource {
74///         deps_core::parser::DependencySource::Registry
75///     }
76///     fn as_any(&self) -> &dyn std::any::Any {
77///         self
78///     }
79/// }
80///
81/// let dep = PlainDependency(PackageName::new("example"));
82/// assert!(!requirement_is_placeholder_for(&PlainFormatter, &dep, "^1.2"));
83/// assert!(requirement_is_placeholder_for(&PlainFormatter, &dep, "{{ version }}"));
84/// ```
85#[must_use]
86pub fn requirement_is_placeholder_for(
87    formatter: &dyn EcosystemFormatter,
88    dep: &dyn Dependency,
89    current: &str,
90) -> bool {
91    formatter.requirement_is_placeholder(&VersionReq::new(current))
92        || dep
93            .version_literal()
94            .is_some_and(|literal| formatter.requirement_is_placeholder(&VersionReq::new(literal)))
95}
96
97/// The only production path that rewrites a manifest's version-requirement text.
98///
99/// `None` when [`requirement_is_placeholder_for`] says `current`/`dep`'s literal is an
100/// unexpanded placeholder, else `Some(formatter.format_version_replacing_for(dep, version,
101/// current))`.
102///
103/// Centralizing the placeholder gate here — rather than leaving it to each ecosystem's
104/// [`PackageRendering::format_version_replacing`](crate::lsp_helpers::PackageRendering::format_version_replacing)/
105/// [`format_version_replacing_for`](crate::lsp_helpers::PackageRendering::format_version_replacing_for)
106/// override to re-check — means a formatter implementation can never destructively rewrite a
107/// placeholder no matter how it is reached: the 14 previously hand-rolled per-crate guards
108/// inside those methods are gone (#1391), and this free function is the sole call site that
109/// still invokes them in production. Every central edit-planning call site
110/// ([`collect_update_candidates`], [`plan_verified_fix`],
111/// `crate::lsp_helpers::code_actions`'s unsatisfiable-requirement fix builder and REFACTOR
112/// "Update to X" loop) already checks [`requirement_is_placeholder_for`] as an early gate for
113/// its own control-flow reasons (skip vs. `Err`/`None`/`continue` differ per caller) before
114/// ever reaching this call — that earlier check makes this function's own `None` branch
115/// unreachable in practice, and is kept anyway as a fail-closed defense-in-depth backstop
116/// rather than relied upon as the only gate.
117///
118/// # Examples
119///
120/// ```
121/// use deps_core::edit::replacement_text;
122/// use deps_core::lsp_helpers::{DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering, RequirementResolution, SourcePolicy};
123/// use deps_core::{ConcreteVersion, Dependency, PackageName, VersionReq};
124///
125/// struct PlainFormatter;
126/// impl PackageNaming for PlainFormatter {}
127/// impl PackageRendering for PlainFormatter {
128///     fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
129///         version.to_string()
130///     }
131///     fn package_url(&self, name: &PackageName) -> String {
132///         name.as_str().to_string()
133///     }
134/// }
135/// impl RequirementResolution for PlainFormatter {}
136/// impl DiagnosticMessages for PlainFormatter {}
137/// impl DiagnosticPolicy for PlainFormatter {}
138/// impl SourcePolicy for PlainFormatter {}
139/// impl OsvNaming for PlainFormatter {}
140///
141/// struct PlainDependency(PackageName);
142/// impl Dependency for PlainDependency {
143///     fn name(&self) -> &PackageName {
144///         &self.0
145///     }
146///     fn name_range(&self) -> deps_core::position::Range {
147///         deps_core::position::Range::default()
148///     }
149///     fn version_requirement(&self) -> Option<&VersionReq> {
150///         None
151///     }
152///     fn version_range(&self) -> Option<deps_core::position::Range> {
153///         None
154///     }
155///     fn source(&self) -> deps_core::parser::DependencySource {
156///         deps_core::parser::DependencySource::Registry
157///     }
158///     fn as_any(&self) -> &dyn std::any::Any {
159///         self
160///     }
161/// }
162///
163/// let dep = PlainDependency(PackageName::new("example"));
164/// assert_eq!(
165///     replacement_text(&PlainFormatter, &dep, &ConcreteVersion::new("1.2.3"), "^1.0"),
166///     Some("1.2.3".to_string())
167/// );
168/// assert_eq!(
169///     replacement_text(&PlainFormatter, &dep, &ConcreteVersion::new("1.2.3"), "{{ version }}"),
170///     None
171/// );
172/// ```
173#[must_use]
174pub fn replacement_text(
175    formatter: &dyn EcosystemFormatter,
176    dep: &dyn Dependency,
177    version: &ConcreteVersion,
178    current: &str,
179) -> Option<String> {
180    if requirement_is_placeholder_for(formatter, dep, current) {
181        return None;
182    }
183    Some(formatter.format_version_replacing_for(dep, version, current))
184}
185
186/// Protocol-agnostic replacement for `ls_types::TextEdit` — a single manifest-text
187/// replacement.
188///
189/// # Examples
190///
191/// ```
192/// use deps_core::edit::ManifestEdit;
193/// use deps_core::position::{Position, Range};
194///
195/// let edit = ManifestEdit {
196///     range: Range::new(Position::new(0, 9), Position::new(0, 14)),
197///     new_text: "1.2.0".to_string(),
198/// };
199/// assert_eq!(edit.new_text, "1.2.0");
200/// ```
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct ManifestEdit {
203    /// The span in the manifest source this edit replaces.
204    pub range: crate::position::Range,
205    /// The replacement text.
206    pub new_text: String,
207}
208
209/// A manifest-edit plan with attribution — what makes ignore-rule/`--package` filtering
210/// possible. The `ls_types::TextEdit` this generalizes discards everything but the edit
211/// itself.
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct PlannedUpdate {
214    /// The dependency's declared (raw) name.
215    pub name: String,
216    /// [`crate::lsp_helpers::PackageNaming::normalize_package_name`]'s output for `name`.
217    pub normalized_name: String,
218    /// The dependency name's span in the manifest source.
219    pub name_range: crate::position::Range,
220    /// The version this dependency is currently pinned to, when it could be resolved (see
221    /// [`collect_update_edits`]'s doc for exactly how). `None` covers two distinct cases: a
222    /// genuinely unresolvable in-use version (`collect_update_candidates`) — a caller that
223    /// classifies it via [`classify_update`] gets [`UpdateKind::Unknown`] for this case, the
224    /// same as for any other string [`classify_update`] can't parse as a leading dotted-numeric
225    /// version — and [`plan_verified_fix`], which never attempts to resolve this field at all
226    /// and always leaves it `None` regardless of whether the in-use version is knowable.
227    pub current: Option<ConcreteVersion>,
228    /// The version this edit would move the dependency to.
229    pub target: ConcreteVersion,
230    /// The edit itself.
231    pub edit: ManifestEdit,
232}
233
234/// A type with a `[start, end)` span, generalizing over [`ManifestEdit`] (this crate) and
235/// `ls_types::TextEdit` (the `lsp-responses`-gated impl in [`crate::lsp_helpers`]).
236///
237/// Implemented locally for a foreign type (`ls_types::TextEdit`) in the gated module —
238/// orphan-rule-clean since this trait is local to this crate.
239pub trait EditSpan {
240    /// The span's inclusive start, as `(line, character)`.
241    fn start(&self) -> (u32, u32);
242    /// The span's exclusive end, as `(line, character)`.
243    fn end(&self) -> (u32, u32);
244}
245
246impl EditSpan for ManifestEdit {
247    fn start(&self) -> (u32, u32) {
248        (self.range.start.line, self.range.start.character)
249    }
250
251    fn end(&self) -> (u32, u32) {
252        (self.range.end.line, self.range.end.character)
253    }
254}
255
256impl EditSpan for PlannedUpdate {
257    fn start(&self) -> (u32, u32) {
258        self.edit.start()
259    }
260
261    fn end(&self) -> (u32, u32) {
262        self.edit.end()
263    }
264}
265
266/// Sorts `edits` by start position and drops any edit that overlaps the previous one.
267///
268/// A dropped edit's start falls before the previous (surviving) edit's end — an overlap no
269/// LSP `WorkspaceEdit`/manifest rewrite can apply safely. Generic replacement for the
270/// type-specific `TextEdit` version this crate carried before #1329 — see [`EditSpan`]'s doc.
271///
272/// **Breaking**: this is a breaking `pub` API change on `deps-core` (the old signature only
273/// accepted `Vec<ls_types::TextEdit>`) — see `CHANGELOG.md`'s `[Unreleased]` section.
274///
275/// `caller` names the collector in the `tracing::warn!` emitted for each dropped edit.
276///
277/// # Examples
278///
279/// ```
280/// use deps_core::edit::{ManifestEdit, dedup_overlapping_edits};
281/// use deps_core::position::{Position, Range};
282///
283/// let edits = vec![
284///     ManifestEdit {
285///         range: Range::new(Position::new(0, 0), Position::new(0, 5)),
286///         new_text: "a".to_string(),
287///     },
288///     ManifestEdit {
289///         range: Range::new(Position::new(0, 2), Position::new(0, 7)),
290///         new_text: "b".to_string(),
291///     },
292/// ];
293/// let kept = dedup_overlapping_edits(edits, "test");
294/// assert_eq!(kept.len(), 1);
295/// ```
296pub fn dedup_overlapping_edits<E: EditSpan>(mut edits: Vec<E>, caller: &str) -> Vec<E> {
297    edits.sort_by_key(EditSpan::start);
298
299    let mut non_overlapping: Vec<E> = Vec::with_capacity(edits.len());
300    for edit in edits {
301        let overlaps_prev = non_overlapping
302            .last()
303            .is_some_and(|prev: &E| edit.start() < prev.end());
304        if overlaps_prev {
305            tracing::warn!(
306                start = ?edit.start(),
307                end = ?edit.end(),
308                caller,
309                "dropping overlapping edit"
310            );
311            continue;
312        }
313        non_overlapping.push(edit);
314    }
315
316    non_overlapping
317}
318
319/// Applies `edits` to `content`, returning the rewritten manifest text.
320///
321/// `edits` must be non-overlapping (see [`dedup_overlapping_edits`]) — this function does not
322/// itself check for overlap, it splices every edit's range in reverse start-position order so
323/// an earlier edit's byte offsets are never invalidated by a later one's length change. Each
324/// edit's `range.start` must not come after its own `range.end` (an inverted range); an edit
325/// violating this precondition is skipped rather than applied — every current parser
326/// produces structurally ordered ranges, so this is a defense-in-depth guard against a
327/// future parser bug, not an expected code path (this function is `pub` `deps-core` API
328/// consumed in-process by `deps-cli`, unlike its pre-#1329 callers, which only ever handed
329/// equivalent ranges back to an LSP client as `TextEdit`s for the editor to apply).
330///
331/// # Examples
332///
333/// ```
334/// use deps_core::edit::{ManifestEdit, apply_edits};
335/// use deps_core::position::{Position, Range};
336///
337/// let content = "serde = \"1.0.0\"\n";
338/// let edits = vec![ManifestEdit {
339///     range: Range::new(Position::new(0, 9), Position::new(0, 14)),
340///     new_text: "1.2.0".to_string(),
341/// }];
342/// assert_eq!(apply_edits(content, &edits), "serde = \"1.2.0\"\n");
343/// ```
344#[must_use]
345pub fn apply_edits(content: &str, edits: &[ManifestEdit]) -> String {
346    let table = LineOffsetTable::new(content);
347    let mut byte_edits: Vec<(usize, usize, &str)> = edits
348        .iter()
349        .filter_map(|edit| {
350            let start = table.position_to_byte_offset(content, edit.range.start);
351            let end = table.position_to_byte_offset(content, edit.range.end);
352            if start > end {
353                tracing::warn!(range = ?edit.range, "dropping edit with an inverted range");
354                return None;
355            }
356            Some((start, end, edit.new_text.as_str()))
357        })
358        .collect();
359    byte_edits.sort_by_key(|&(start, ..)| start);
360
361    let mut result = content.to_string();
362    for &(start, end, new_text) in byte_edits.iter().rev() {
363        result.replace_range(start..end, new_text);
364    }
365    result
366}
367
368/// Re-parses manifest text, abstracting over the production path.
369///
370/// [`EcosystemReparse`]/[`crate::ecosystem::parse_manifest_now`] is the production impl, so
371/// [`crate::lsp_helpers::fallback_edit_excludes_newer`] (spec 076 FR-024) can be unit-tested
372/// with a stub instead of a real ecosystem parser.
373///
374/// FR-024's guard validates the manifest's EFFECTIVE post-edit requirement, not the replacement
375/// span's own text in isolation — some grammars (Swift's `from:`/`.exact`/`.upToNextMinor`
376/// labels, Bundler's multi-constraint literals) build the effective requirement from context
377/// outside the edited span, so only re-parsing the whole edited document proves what the edit
378/// actually expresses.
379///
380/// # Examples
381///
382/// ```
383/// use deps_core::edit::ManifestReparse;
384///
385/// let reparse = |_content: &str| None;
386/// assert!(reparse.reparse("anything").is_none());
387/// ```
388pub trait ManifestReparse {
389    /// Re-parses `content` (an edited scratch copy of a manifest), or `None` if it could not be
390    /// parsed (fail closed).
391    fn reparse(&self, content: &str) -> Option<Box<dyn ParseResult>>;
392}
393
394/// Blanket impl so a test can pass a plain closure as a [`ManifestReparse`] instead of defining
395/// a stub type.
396impl<F: Fn(&str) -> Option<Box<dyn ParseResult>>> ManifestReparse for F {
397    fn reparse(&self, content: &str) -> Option<Box<dyn ParseResult>> {
398        self(content)
399    }
400}
401
402/// Production [`ManifestReparse`]: re-parses via [`crate::ecosystem::parse_manifest_now`].
403///
404/// That is the synchronous, dependency-count-capped sibling of
405/// [`crate::ecosystem::parse_manifest_blocking`] (spec 076 FR-024).
406///
407/// # Examples
408///
409/// ```
410/// use deps_core::edit::{EcosystemReparse, ManifestReparse};
411/// use deps_core::Ecosystem;
412/// use url::Url;
413///
414/// # fn example(ecosystem: &dyn Ecosystem, uri: Url) {
415/// let reparse = EcosystemReparse {
416///     ecosystem,
417///     uri: &uri,
418/// };
419/// let _ = reparse.reparse("content");
420/// # }
421/// ```
422pub struct EcosystemReparse<'a> {
423    /// The ecosystem to re-parse through.
424    pub ecosystem: &'a dyn crate::Ecosystem,
425    /// The manifest document's URI, for position tracking.
426    pub uri: &'a url::Url,
427}
428
429impl ManifestReparse for EcosystemReparse<'_> {
430    fn reparse(&self, content: &str) -> Option<Box<dyn ParseResult>> {
431        crate::ecosystem::parse_manifest_now(self.ecosystem, content, self.uri)
432    }
433}
434
435/// The magnitude of a version bump, per semver-shaped leading-numeric-segment comparison.
436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437pub enum UpdateKind {
438    /// The leading numeric segment (major) differs.
439    Major,
440    /// Major is equal, minor differs.
441    Minor,
442    /// Major and minor are equal (only patch, or nothing, differs).
443    Patch,
444    /// `from`/`to` could not both be classified as a version with an unambiguous leading
445    /// dotted-numeric segment (a SHA pin, a Go pseudo-version, a range/comparator string,
446    /// `*`/`latest`/`workspace:*`, Gradle's `{strictly}!!{preferred}`, ...).
447    Unknown,
448}
449
450/// Classifies the version bump from `from` to `to`.
451///
452/// Returns [`UpdateKind::Unknown`] unless **both** strings have an unambiguous leading
453/// dotted-numeric segment (an optional `v`/`V` prefix, then one or more all-digit
454/// dot-separated components, then either nothing or a `-`/`+` suffix) — deliberately not
455/// built on [`crate::lsp_helpers::is_same_major_minor`], whose permissive `_ => true`
456/// fallback arm is wrong for this purpose. A Go pseudo-version
457/// (`v0.0.0-20210101000000-abcdef123456`) would otherwise parse as a plain leading version
458/// (`0.0.0`) by accident, so it is rejected by an explicit shape check before the general
459/// parse runs.
460///
461/// # Examples
462///
463/// ```
464/// use deps_core::edit::{UpdateKind, classify_update};
465///
466/// assert_eq!(classify_update("1.2.3", "2.0.0"), UpdateKind::Major);
467/// assert_eq!(classify_update("1.2.3", "1.3.0"), UpdateKind::Minor);
468/// assert_eq!(classify_update("1.2.3", "1.2.4"), UpdateKind::Patch);
469/// assert_eq!(classify_update("*", "1.0.0"), UpdateKind::Unknown);
470/// assert_eq!(
471///     classify_update("v0.0.0-20210101000000-abcdef123456", "v1.0.0"),
472///     UpdateKind::Unknown
473/// );
474/// ```
475#[must_use]
476pub fn classify_update(from: &str, to: &str) -> UpdateKind {
477    match (
478        parse_leading_dotted_numeric(from),
479        parse_leading_dotted_numeric(to),
480    ) {
481        (Some(f), Some(t)) => {
482            if f.0 != t.0 {
483                UpdateKind::Major
484            } else if f.1 != t.1 {
485                UpdateKind::Minor
486            } else {
487                UpdateKind::Patch
488            }
489        }
490        _ => UpdateKind::Unknown,
491    }
492}
493
494/// Whether `s` has the shape of a Go pseudo-version: an optional `v`/`V` prefix, then a
495/// component ending in a hyphen-separated 14-digit timestamp and a 12-hex-digit commit hash
496/// (`vX.Y.Z-yyyymmddhhmmss-abcdefabcdef`, or the pre-release-shaped
497/// `vX.Y.Z-0.yyyymmddhhmmss-abcdefabcdef`, whose timestamp segment carries a `0.` prefix).
498///
499/// Checked before [`parse_leading_dotted_numeric`] runs its general parse, since a pseudo-
500/// version's leading `vX.Y.Z` would otherwise parse as an ordinary version by accident.
501fn is_go_pseudo_version(s: &str) -> bool {
502    let body = s.strip_prefix(['v', 'V']).unwrap_or(s);
503    let mut parts = body.rsplitn(3, '-');
504    let Some(hash) = parts.next() else {
505        return false;
506    };
507    let Some(timestamp) = parts.next() else {
508        return false;
509    };
510    if parts.next().is_none() {
511        return false;
512    }
513    let timestamp = timestamp.strip_prefix("0.").unwrap_or(timestamp);
514    hash.len() == 12
515        && hash.bytes().all(|b| b.is_ascii_hexdigit())
516        && timestamp.len() == 14
517        && timestamp.bytes().all(|b| b.is_ascii_digit())
518}
519
520/// Parses `s`'s leading `(major, minor, patch)` dotted-numeric segment, or `None` when `s` is
521/// not unambiguously one (see [`classify_update`]'s doc). A component beyond the third is
522/// still required to be all-digit (so a 4-component version like NuGet's assembly-version
523/// style is accepted), but only the first three are returned.
524fn parse_leading_dotted_numeric(s: &str) -> Option<(u64, u64, u64)> {
525    if is_go_pseudo_version(s) {
526        return None;
527    }
528    let stripped = s.strip_prefix(['v', 'V']).unwrap_or(s);
529    let core = match stripped.find(['-', '+']) {
530        Some(idx) => stripped.get(..idx)?,
531        None => stripped,
532    };
533    if core.is_empty() {
534        return None;
535    }
536
537    let mut major = 0u64;
538    let mut minor = 0u64;
539    let mut patch = 0u64;
540    let mut count = 0usize;
541    for part in core.split('.') {
542        if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
543            return None;
544        }
545        let n: u64 = part.parse().ok()?;
546        match count {
547            0 => major = n,
548            1 => minor = n,
549            2 => patch = n,
550            _ => {}
551        }
552        count += 1;
553    }
554    if count == 0 {
555        return None;
556    }
557    Some((major, minor, patch))
558}
559
560/// Default-mode planner: every dependency [`RequirementStatus::Outdated`] would report, as a
561/// [`PlannedUpdate`].
562///
563/// Verbatim move of the logic `lsp_helpers::code_lenses::collect_update_all_edits` used to
564/// implement directly (moved here for #1329/spec 068's `deps_core::edit` extraction; that
565/// function is now a thin adapter over this one — see this crate's `lib.rs` "LSP type
566/// stability" doc section) — the literal-span guard, the `is_safe_version_string` check, the
567/// `requirement_status_for == Outdated` filter, the no-op guard, and overlap dedup are
568/// unchanged. See that function's historical doc (preserved in git history) for the full
569/// per-guard rationale.
570///
571/// [`PlannedUpdate::current`] is the dependency's resolved in-use version
572/// ([`resolve_in_use_version`], **per occurrence**, never the collapsed per-name map — spec
573/// 050's fix), when `versions` carries an [`crate::EcosystemId`]
574/// ([`VersionData::with_ecosystem`]) and resolution succeeds; empty otherwise, which
575/// [`classify_update`] always reports as [`UpdateKind::Unknown`] against any target.
576///
577/// # Examples
578///
579/// ```
580/// use deps_core::edit::collect_update_edits;
581/// use deps_core::lsp_helpers::{
582///     DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
583///     PackageVersions, RequirementResolution, SourcePolicy, VersionData,
584/// };
585/// use deps_core::{ConcreteVersion, Dependency, ParseResult, PackageName, VersionReq};
586/// use std::any::Any;
587/// use std::collections::HashMap;
588/// use deps_core::position::{Position, Range};
589///
590/// struct MockFormatter;
591/// impl PackageNaming for MockFormatter {}
592/// impl PackageRendering for MockFormatter {
593///     fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
594///         version.to_string()
595///     }
596///     fn package_url(&self, name: &PackageName) -> String {
597///         format!("https://example.com/{}", name.as_str())
598///     }
599/// }
600/// impl RequirementResolution for MockFormatter {}
601/// impl DiagnosticMessages for MockFormatter {}
602/// impl DiagnosticPolicy for MockFormatter {}
603/// impl SourcePolicy for MockFormatter {}
604/// impl OsvNaming for MockFormatter {}
605///
606/// struct MockDep {
607///     name: PackageName,
608///     version_req: VersionReq,
609///     version_range: Range,
610///     name_range: Range,
611/// }
612/// impl Dependency for MockDep {
613///     fn name(&self) -> &PackageName { &self.name }
614///     fn name_range(&self) -> Range { self.name_range }
615///     fn version_requirement(&self) -> Option<&VersionReq> { Some(&self.version_req) }
616///     fn version_range(&self) -> Option<Range> { Some(self.version_range) }
617///     fn source(&self) -> deps_core::parser::DependencySource {
618///         deps_core::parser::DependencySource::Registry
619///     }
620///     fn as_any(&self) -> &dyn Any { self }
621/// }
622///
623/// struct MockParseResult { deps: Vec<MockDep>, uri: url::Url }
624/// impl ParseResult for MockParseResult {
625///     fn dependencies(&self) -> Vec<&dyn Dependency> {
626///         self.deps.iter().map(|d| d as &dyn Dependency).collect()
627///     }
628///     fn workspace_root(&self) -> Option<&std::path::Path> { None }
629///     fn uri(&self) -> &url::Url { &self.uri }
630///     fn as_any(&self) -> &dyn Any { self }
631/// }
632///
633/// let content = r#"serde = "1.0.0""#;
634/// let parse_result = MockParseResult {
635///     deps: vec![MockDep {
636///         name: PackageName::new("serde"),
637///         version_req: VersionReq::new("1.0.0"),
638///         version_range: Range::new(Position::new(0, 9), Position::new(0, 14)),
639///         name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
640///     }],
641///     uri: deps_core::test_util::test_uri("/test/Cargo.toml"),
642/// };
643///
644/// let mut cached = HashMap::new();
645/// cached.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
646/// let resolved = HashMap::new();
647///
648/// let planned = collect_update_edits(
649///     &parse_result,
650///     content,
651///     VersionData::new(&cached, &resolved),
652///     &MockFormatter,
653/// );
654///
655/// assert_eq!(planned.len(), 1);
656/// assert_eq!(planned[0].edit.new_text, "1.2.0");
657/// assert_eq!(planned[0].name, "serde");
658/// ```
659#[must_use]
660pub fn collect_update_edits(
661    parse_result: &dyn ParseResult,
662    content: &str,
663    versions: VersionData<'_>,
664    formatter: &dyn EcosystemFormatter,
665) -> Vec<PlannedUpdate> {
666    let planned: Vec<PlannedUpdate> =
667        collect_update_candidates(parse_result, content, versions, formatter)
668            .into_iter()
669            .filter_map(|candidate| match candidate {
670                UpdateCandidate::Planned(planned) => Some(planned),
671                UpdateCandidate::Unplannable { .. } => None,
672            })
673            .collect();
674
675    dedup_overlapping_edits(planned, "collect_update_edits")
676}
677
678/// Why a dependency [`crate::lsp_helpers::RequirementStatus::Outdated`] could not become a
679/// writable [`PlannedUpdate`].
680#[derive(Debug, Clone, Copy, PartialEq, Eq)]
681pub enum UnplannableReason {
682    /// The registry's cached `latest` value failed the [`is_safe_version_string`] gate.
683    UnsafeLatestVersion,
684    /// The declared span does not hold the literal requirement text — a Maven
685    /// `${property}` reference, a Gradle DSL variable/version-catalog alias, or similar.
686    NonLiteralSpan,
687    /// The formatter's rewrite would be a no-op (no single unambiguous rewrite exists, e.g.
688    /// `deps-gradle`'s `{strictly}!!{preferred}` shorthand).
689    NoOpRewrite,
690    /// Issue #1517: OSV checked this exact `latest` version and found it affected by a
691    /// non-informational advisory (or a confirmed-malicious record) —
692    /// [`crate::lsp_helpers::LatestVerdict::Flagged`]. Writing this version into the manifest
693    /// would be the exact supply-chain exposure this variant exists to prevent; the shared
694    /// planner this variant lives in ([`collect_update_candidates`]) is used by both the LSP
695    /// code lens and `deps-cli update`'s default mode, so both refuse it identically.
696    LatestFlaggedByOsv,
697    /// Issue #1517: `latest`'s OSV status could not be verified —
698    /// [`crate::lsp_helpers::LatestVerdict::Unverified`] (never checked, a transient
699    /// failure/timeout, or the checked version has since diverged from what's now cached as
700    /// `latest`). Fails closed the same way [`Self::LatestFlaggedByOsv`] does: an unverified
701    /// version is never distinguishable from a flagged one at write time, so neither is ever
702    /// planned.
703    LatestUnverified,
704}
705
706/// One [`crate::lsp_helpers::RequirementStatus::Outdated`] dependency's planning outcome.
707#[derive(Debug, Clone)]
708pub enum UpdateCandidate {
709    /// A safe edit was planned.
710    Planned(PlannedUpdate),
711    /// The dependency is `Outdated` but no edit could be safely produced — callers that
712    /// report per-dependency outcomes (`deps-cli update`) must surface this rather than
713    /// silently dropping the dependency; [`collect_update_edits`] (the LSP-facing planner,
714    /// which only needs the writable subset) drops it instead.
715    Unplannable {
716        /// The dependency's declared (raw) name.
717        name: String,
718        /// [`crate::lsp_helpers::PackageNaming::normalize_package_name`]'s output for `name`.
719        normalized_name: String,
720        /// The dependency name's span in the manifest source.
721        name_range: crate::position::Range,
722        /// Why no edit could be planned.
723        reason: UnplannableReason,
724    },
725}
726
727/// Every [`crate::lsp_helpers::RequirementStatus::Outdated`] dependency, classified into
728/// [`UpdateCandidate::Planned`] or [`UpdateCandidate::Unplannable`].
729///
730/// [`collect_update_edits`] is a thin filter over this function (only the `Planned` subset,
731/// deduped) — this is the richer form `deps-cli update`'s default-mode planner consumes so an
732/// `Unplannable` dependency can be reported to the operator instead of vanishing from the
733/// plan with no signal (spec 068 S4).
734///
735/// A dependency that never reaches `Outdated` status at all (no `version_range`, unknown to
736/// the registry, no declared requirement, an empty requirement, or genuinely up to date) is
737/// not a candidate and produces no entry here — reporting those would be noise, not signal
738/// (most manifest dependencies are exactly this case on any given run). An unexpanded
739/// placeholder ([`crate::lsp_helpers::RequirementResolution::bounded_requirement_is_placeholder`],
740/// #1370) is checked and skipped the same way, independent of whatever status an ecosystem's
741/// own classification logic reports for it — this is one of the four central edit-planning
742/// gates that predicate consults.
743#[must_use]
744pub fn collect_update_candidates(
745    parse_result: &dyn ParseResult,
746    content: &str,
747    versions: VersionData<'_>,
748    formatter: &dyn EcosystemFormatter,
749) -> Vec<UpdateCandidate> {
750    let deps = parse_result.dependencies();
751    let mut candidates: Vec<UpdateCandidate> = Vec::with_capacity(deps.len());
752    // Built once and reused for every dependency below (matches the pre-extraction
753    // `collect_update_all_edits`'s own rationale for doing so).
754    let line_offsets = LineOffsetTable::new(content);
755    // Issue #1517: shared by both the LSP code lens (`collect_update_edits`) and `deps-cli
756    // update`'s default mode planner — the single gate neither can bypass.
757    let vuln_keys = versions.ecosystem.map(|ecosystem| {
758        crate::osv::vulnerability_keys(
759            parse_result,
760            versions.resolved,
761            versions.resolved_version_candidates,
762            formatter,
763            ecosystem,
764        )
765    });
766
767    for dep in deps {
768        let Some(version_range) = dep.version_range() else {
769            continue;
770        };
771
772        let normalized_name = formatter.normalize_package_name(dep.name());
773        let Some(latest) = versions
774            .cached
775            .get(normalized_name.as_str())
776            .or_else(|| versions.cached.get(dep.name()))
777            .map(|v| &v.latest)
778        else {
779            continue;
780        };
781
782        let Some(version_req) = dep.version_requirement() else {
783            continue;
784        };
785        if version_req.as_str().is_empty() {
786            // Defense-in-depth: an empty requirement would trivially satisfy the literal-span
787            // guard below (both sides normalize to "") and could anchor an edit on a
788            // non-literal span.
789            continue;
790        }
791        // #1370: central placeholder gate, checked independently of `requirement_status_for`'s
792        // own classification — a placeholder is never a rewrite candidate regardless of what
793        // status an ecosystem's own (possibly still-buggy) classification logic reports for it,
794        // the same defense-in-depth reasoning as the other three central edit-planning gates.
795        if requirement_is_placeholder_for(formatter, dep, version_req.as_str()) {
796            continue;
797        }
798        if formatter.requirement_status_for(dep, version_req, latest) != RequirementStatus::Outdated
799        {
800            continue;
801        }
802
803        // From here, `dep` is a confirmed `Outdated` candidate — every remaining guard below
804        // must surface as `UpdateCandidate::Unplannable`, not a silent `continue`.
805        let name = dep.name().as_str().to_string();
806        let name_range = dep.name_range();
807
808        if !is_safe_version_string(latest.as_str()) {
809            warn_rejected_value(
810                "is_safe_version_string",
811                "update edit plan",
812                latest.as_str(),
813            );
814            candidates.push(UpdateCandidate::Unplannable {
815                name,
816                normalized_name,
817                name_range,
818                reason: UnplannableReason::UnsafeLatestVersion,
819            });
820            continue;
821        }
822
823        // Issue #1517: never plan an edit that writes a flagged/unverified `latest` into the
824        // manifest — the exact live-exploitable gap this issue closes (`deps-cli update`'s
825        // default mode previously wrote a known-malicious version straight into the manifest).
826        // Checked before the literal-span/no-op guards below: those are about *whether* an
827        // edit can be produced at all, this is about whether the edit's *target* is safe to
828        // write, and the latter must never be skipped just because the former also failed.
829        match crate::lsp_helpers::latest_verdict(
830            versions.latest_status,
831            dep,
832            vuln_keys.as_ref(),
833            &normalized_name,
834            latest.as_str(),
835            formatter,
836        ) {
837            crate::lsp_helpers::LatestVerdict::Verified
838            | crate::lsp_helpers::LatestVerdict::NotApplicable => {}
839            crate::lsp_helpers::LatestVerdict::Flagged { .. } => {
840                candidates.push(UpdateCandidate::Unplannable {
841                    name,
842                    normalized_name,
843                    name_range,
844                    reason: UnplannableReason::LatestFlaggedByOsv,
845                });
846                continue;
847            }
848            crate::lsp_helpers::LatestVerdict::Unverified => {
849                candidates.push(UpdateCandidate::Unplannable {
850                    name,
851                    normalized_name,
852                    name_range,
853                    reason: UnplannableReason::LatestUnverified,
854                });
855                continue;
856            }
857        }
858
859        // Intentionally not calling `dependency_version_range_is_literal` (#919) —
860        // empty-requirement semantics differ (edit: nothing to update; completion: everything
861        // to offer).
862        let slice = slice_for_range(content, &line_offsets, version_range);
863        let literal_target = dep
864            .version_literal()
865            .unwrap_or_else(|| version_req.as_str());
866        if !literal_span_matches(slice, literal_target) {
867            candidates.push(UpdateCandidate::Unplannable {
868                name,
869                normalized_name,
870                name_range,
871                reason: UnplannableReason::NonLiteralSpan,
872            });
873            continue;
874        }
875
876        // Unreachable after the gate above; fails closed rather than unwrapping.
877        let Some(new_text) = replacement_text(formatter, dep, latest, version_req.as_str()) else {
878            continue;
879        };
880        if strip_whitespace(&new_text) == strip_whitespace(literal_target) {
881            candidates.push(UpdateCandidate::Unplannable {
882                name,
883                normalized_name,
884                name_range,
885                reason: UnplannableReason::NoOpRewrite,
886            });
887            continue;
888        }
889
890        // FR-003: per occurrence, never the collapsed per-name map — a renamed/aliased
891        // dependency (spec 050) must classify against its own resolved pin. `None` here means
892        // genuinely unresolvable (not, as for `plan_verified_fix`, simply never attempted) — a
893        // caller that classifies this field via `classify_update` gets `UpdateKind::Unknown`
894        // for it, since there is no version string to pass in.
895        let current = versions.ecosystem.and_then(|ecosystem| {
896            resolve_in_use_version(
897                dep,
898                &normalized_name,
899                versions.resolved,
900                versions.resolved_version_candidates,
901                formatter,
902                ecosystem,
903            )
904        });
905
906        candidates.push(UpdateCandidate::Planned(PlannedUpdate {
907            name,
908            normalized_name,
909            name_range,
910            current,
911            target: latest.clone(),
912            edit: ManifestEdit {
913                range: version_range,
914                new_text,
915            },
916        }));
917    }
918
919    candidates
920}
921
922/// Why [`plan_vulnerability_fix`] could not produce a [`PlannedUpdate`] for a dependency
923/// already known to be [`crate::osv::ScanOutcome::Vulnerable`].
924///
925/// Mirrors [`UnplannableReason`]'s role for the default-mode planner (spec 068's typed-skip
926/// convention) — before this type existed, every one of these six causes collapsed into a
927/// single `None`, forcing `deps-cli update --security-only`'s
928/// `classify_vulnerable_dependency` to re-run [`resolve_recommended_fix`]'s and
929/// `fix_target_is_verified`'s own chain itself just to tell `NoVerifiedFix` apart from
930/// `RequiresLockfileUpdate` (#1350).
931#[derive(Debug, Clone, Copy, PartialEq, Eq)]
932pub enum VulnFixSkip {
933    /// No advisory on `dv` has a claimable fix — [`crate::osv::DependencyVulnerabilities::recommended_fix`]
934    /// returned `None`.
935    NoRecommendedFix,
936    /// The recommended fix's version, converted to this ecosystem's native namespace, failed
937    /// [`is_safe_version_string`].
938    UnsafeVersion,
939    /// The internal fix-target verification gate could not confirm the fix target F against
940    /// OSV — F was never live-checked, or the live check found F still vulnerable to an
941    /// advisory this recommendation claims to resolve.
942    UnverifiedTarget,
943    /// The dependency's declared requirement, left unedited, already resolves forward to the
944    /// fix target under this ecosystem's own resolution rules — nothing to rewrite (#1344).
945    RequirementAlreadyResolves,
946    /// The formatter's rewrite would be textually identical to the declared literal — no edit
947    /// to make.
948    NoOpRewrite,
949    /// `current` is an unexpanded placeholder/interpolation
950    /// ([`crate::lsp_helpers::RequirementResolution::bounded_requirement_is_placeholder`]) — there is
951    /// no concrete version text to replace, so the requirement is never rewritten regardless
952    /// of what any other resolution predicate or the formatter's own rewrite logic would do
953    /// with it (#1370).
954    UnresolvedPlaceholder,
955    /// `current`, the dependency's requirement, or its version literal exceeds
956    /// [`crate::lsp_helpers::MAX_REQUIREMENT_LEN`] — unmodellable text is never rewritten,
957    /// fail-closed and distinct from a placeholder so a caller can report the size cause.
958    OversizedRequirement,
959}
960
961/// Resolves and validates the OSV-recommended fix for `dv`.
962///
963/// The common prefix every vulnerability-fix caller needs before it can decide what to do
964/// next: is there a fix, and is its version string safe to act on. Shared by
965/// [`plan_vulnerability_fix`] and `deps-engine`'s phase-B fix-target verification
966/// (`classify::osv::resolve_fix_target`), which independently ran the same two steps before
967/// this was extracted (#1350).
968///
969/// # Errors
970///
971/// Returns [`VulnFixSkip::NoRecommendedFix`] when `dv.recommended_fix(latest)` is `None`, or
972/// [`VulnFixSkip::UnsafeVersion`] when the fix's version fails [`is_safe_version_string`].
973///
974/// # Arguments
975///
976/// * `latest` - This dependency's entry in the shared [`crate::osv::LatestStatusMap`] (issue
977///   #1517), forwarded verbatim to [`crate::osv::DependencyVulnerabilities::recommended_fix`] —
978///   see that method's doc for its effect.
979///
980/// # Examples
981///
982/// ```
983/// use deps_core::edit::{VulnFixSkip, resolve_recommended_fix};
984/// use deps_core::lsp_helpers::{
985///     DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
986///     RequirementResolution, SourcePolicy,
987/// };
988/// use deps_core::osv::{Advisory, Capped, DependencyVulnerabilities, VulnSeverity};
989/// use deps_core::{ConcreteVersion, PackageName};
990/// use std::sync::Arc;
991///
992/// struct MockFormatter;
993/// impl PackageNaming for MockFormatter {}
994/// impl PackageRendering for MockFormatter {
995///     fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
996///         version.to_string()
997///     }
998///     fn package_url(&self, name: &PackageName) -> String {
999///         format!("https://example.com/{}", name.as_str())
1000///     }
1001/// }
1002/// impl RequirementResolution for MockFormatter {}
1003/// impl DiagnosticMessages for MockFormatter {}
1004/// impl DiagnosticPolicy for MockFormatter {}
1005/// impl SourcePolicy for MockFormatter {}
1006/// impl OsvNaming for MockFormatter {}
1007///
1008/// let dv = DependencyVulnerabilities::new(Capped::new(Vec::<Arc<Advisory>>::new(), 0));
1009/// assert_eq!(
1010///     resolve_recommended_fix(&dv, None, &MockFormatter),
1011///     Err(VulnFixSkip::NoRecommendedFix)
1012/// );
1013/// ```
1014pub fn resolve_recommended_fix(
1015    dv: &crate::osv::DependencyVulnerabilities,
1016    latest: Option<&crate::osv::UpgradeStatus>,
1017    formatter: &dyn EcosystemFormatter,
1018) -> Result<(crate::osv::FixRecommendation, String), VulnFixSkip> {
1019    let fix = dv
1020        .recommended_fix(latest)
1021        .ok_or(VulnFixSkip::NoRecommendedFix)?;
1022    let version_native = formatter.osv_version_to_native(&fix.version).into_string();
1023    if !is_safe_version_string(&version_native) {
1024        warn_rejected_value(
1025            "is_safe_version_string",
1026            "vulnerability fix plan",
1027            &version_native,
1028        );
1029        return Err(VulnFixSkip::UnsafeVersion);
1030    }
1031    Ok((fix, version_native))
1032}
1033
1034/// Whether `dv.fix_target_status` clears `fix`'s target version as an honest, presentable fix
1035/// (#462 FR-003) — verbatim move of `lsp_helpers::code_actions`'s private helper of the same
1036/// name and doc.
1037///
1038/// `CandidateClean { version: F }` always clears it. A `CandidateVulnerable { version: F,
1039/// advisory_ids }` result can also clear it — F may legitimately still be affected by
1040/// advisories `recommended_fix()` never claimed to resolve in the first place — but is
1041/// suppressed the moment `advisory_ids` names either a *claimed* advisory or one this
1042/// dependency's own `advisories` never recorded at all. Any other state means F was never
1043/// actually verified, so it is rejected too. `known_ids` is built from `dv.advisories`, the
1044/// fix-computation set capped at [`crate::osv::MAX_ADVISORY_RECORDS`] — not the smaller,
1045/// render-only [`crate::osv::DependencyVulnerabilities::advisories_for_display`] — so this gate
1046/// sees the same advisory set `recommended_fix()` itself claimed against (#1422).
1047///
1048/// `pub(crate)`: `deps-cli update --security-only` (#1329) used to call this directly to
1049/// distinguish "no verified fix target" from [`plan_vulnerability_fix`]'s other `None` cause,
1050/// but now matches on [`VulnFixSkip`] directly (#1350), so this no longer needs to be `pub`.
1051#[must_use]
1052pub(crate) fn fix_target_is_verified(
1053    dv: &crate::osv::DependencyVulnerabilities,
1054    fix: &crate::osv::FixRecommendation,
1055    version_native: &str,
1056) -> bool {
1057    use crate::osv::UpgradeStatus;
1058    use std::collections::HashSet;
1059
1060    match &dv.fix_target_status {
1061        UpgradeStatus::CandidateClean { version } => version == version_native,
1062        UpgradeStatus::CandidateVulnerable {
1063            version,
1064            advisory_ids,
1065            ..
1066        } => {
1067            if version != version_native || !advisory_ids.is_complete() {
1068                return false;
1069            }
1070            let known_ids: HashSet<&str> = dv
1071                .advisories
1072                .items()
1073                .iter()
1074                .map(|a| a.id.as_str())
1075                .collect();
1076            advisory_ids
1077                .items()
1078                .iter()
1079                .all(|id| known_ids.contains(id.as_str()) && !fix.advisory_ids.contains(id))
1080        }
1081        UpgradeStatus::NotChecked
1082        | UpgradeStatus::CandidateUnverified { .. }
1083        | UpgradeStatus::StructurallyUnchecked(_) => false,
1084    }
1085}
1086
1087/// [`resolve_recommended_fix`] plus the internal `fix_target_is_verified` gate — resolves the
1088/// OSV-recommended fix for `dv` AND confirms its target version's own OSV status.
1089///
1090/// Callers that need to know "is there a *usable* fix" (not just "is there *a* fix") call
1091/// this instead of [`resolve_recommended_fix`] alone — see [`plan_vulnerability_fix`] and
1092/// `deps-cli`'s `classify_vulnerable_dependency` (#1350 S1 regression fix: that caller must
1093/// run this check *before* any other requirement/range/yanked-status decision, exactly as it
1094/// did when it held its own copy of the `fix_target_is_verified` call, or an unverified fix
1095/// target gets misreported as `RequiresLockfileUpdate`/`Unfixable(Yanked)` instead of
1096/// `Unfixable(NoVerifiedFix)`).
1097///
1098/// `deps-engine`'s phase-B fix-target verification (`classify::osv::resolve_fix_target`) is
1099/// the producer of `dv.fix_target_status` in the first place, so it calls
1100/// [`resolve_recommended_fix`] directly instead of this function — gating on a status it has
1101/// not computed yet would be circular.
1102///
1103/// # Errors
1104///
1105/// Returns anything [`resolve_recommended_fix`] can return, plus
1106/// [`VulnFixSkip::UnverifiedTarget`] when the internal `fix_target_is_verified` gate rejects
1107/// the fix target.
1108///
1109/// # Examples
1110///
1111/// ```
1112/// use deps_core::edit::{VulnFixSkip, resolve_verified_fix};
1113/// use deps_core::lsp_helpers::{
1114///     DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
1115///     RequirementResolution, SourcePolicy,
1116/// };
1117/// use deps_core::osv::{
1118///     Advisory, Capped, DependencyVulnerabilities, OsvVersion, UpgradeStatus, VulnSeverity,
1119/// };
1120/// use deps_core::{ConcreteVersion, PackageName};
1121/// use std::sync::Arc;
1122///
1123/// struct MockFormatter;
1124/// impl PackageNaming for MockFormatter {}
1125/// impl PackageRendering for MockFormatter {
1126///     fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
1127///         version.to_string()
1128///     }
1129///     fn package_url(&self, name: &PackageName) -> String {
1130///         format!("https://example.com/{}", name.as_str())
1131///     }
1132/// }
1133/// impl RequirementResolution for MockFormatter {}
1134/// impl DiagnosticMessages for MockFormatter {}
1135/// impl DiagnosticPolicy for MockFormatter {}
1136/// impl SourcePolicy for MockFormatter {}
1137/// impl OsvNaming for MockFormatter {}
1138///
1139/// let advisory = Arc::new(
1140///     Advisory::new(
1141///         "RUSTSEC-2024-0001".to_string(),
1142///         "2024-01-01T00:00:00Z".to_string(),
1143///         VulnSeverity::High,
1144///     )
1145///     .expect("valid osv id")
1146///     .with_fixed_versions(vec![OsvVersion::new("1.2.0")]),
1147/// );
1148/// // `fix_target_status` left at its `NotChecked` default — never live-checked yet.
1149/// let unverified = DependencyVulnerabilities::new(Capped::new(vec![advisory.clone()], 1));
1150/// assert_eq!(
1151///     resolve_verified_fix(&unverified, None, &MockFormatter),
1152///     Err(VulnFixSkip::UnverifiedTarget)
1153/// );
1154///
1155/// let verified = DependencyVulnerabilities::new(Capped::new(vec![advisory], 1))
1156///     .with_fix_target_status(UpgradeStatus::CandidateClean { version: ConcreteVersion::new("1.2.0") });
1157/// assert!(resolve_verified_fix(&verified, None, &MockFormatter).is_ok());
1158/// ```
1159pub fn resolve_verified_fix(
1160    dv: &crate::osv::DependencyVulnerabilities,
1161    latest: Option<&crate::osv::UpgradeStatus>,
1162    formatter: &dyn EcosystemFormatter,
1163) -> Result<(crate::osv::FixRecommendation, String), VulnFixSkip> {
1164    let (fix, version_native) = resolve_recommended_fix(dv, latest, formatter)?;
1165    if !fix_target_is_verified(dv, &fix, &version_native) {
1166        return Err(VulnFixSkip::UnverifiedTarget);
1167    }
1168    Ok((fix, version_native))
1169}
1170
1171/// Security-mode planner: plans the vulnerability-fix edit for one dependency.
1172///
1173/// The dependency must already be known to be [`crate::osv::ScanOutcome::Vulnerable`]; this
1174/// targets [`crate::osv::DependencyVulnerabilities::recommended_fix`] rather than `latest`.
1175///
1176/// Originally a verbatim move of `lsp_helpers::code_actions::build_vulnerability_fix_action`'s
1177/// planning core (the no-op guard, `is_safe_version_string`, `osv_version_to_native`,
1178/// `format_version_replacing_for`, and the `fix_target_is_verified` gate) — yank/timeout
1179/// filtering is deliberately **not** moved: both `deps-lsp` and `deps-cli` apply their own
1180/// source and policy for that (see `lsp_helpers::code_actions::generate_code_actions`'s
1181/// yank-filtering block and `deps-cli`'s `update::security` module).
1182///
1183/// #1344: also gated on whether `dep`'s own declared requirement, left unedited, already
1184/// *resolves forward* to the fix target under this ecosystem's own resolution rules
1185/// (`formatter.requirement_already_resolves_to(..)`) — not just the textual no-op guard below
1186/// (which only catches the declared literal already *spelling* the fix version verbatim).
1187/// `serde = "1"` with fix `1.0.2` must not be rewritten to `serde = "1.0.2"`: the declared
1188/// range already accepts `1.0.2`, so re-resolving already gets there without a manifest edit
1189/// (a `cargo update`/lock-file refresh, for an ecosystem that has one — not every ecosystem
1190/// does: Maven and Gradle have none, and a NuGet project's is opt-in. For those, suppression
1191/// is instead because the requirement already structurally expresses the fix — e.g. a Maven
1192/// dynamic range/`LATEST`/`SNAPSHOT` resolves it at build time — not because of any lock
1193/// file). `bounded_requirement_already_resolves_to`'s default asks only "is the fix a member of the
1194/// requirement's accepted set", correct for a requirement a resolver picks its *newest*
1195/// admissible member from, but wrong for one a resolver instead pins to its *lowest*
1196/// admissible member (NuGet's bare `Version="1.0.0"` floor, #1344) — ecosystems with that
1197/// resolution shape override the method rather than relying on the default; see
1198/// `deps-nuget`'s override. Ecosystems with no `compile_bounded_requirement` override (GitHub Actions,
1199/// GitLab CI — SHA/tag pins, not version ranges) fall through to the textual no-op guard as
1200/// the only available signal. Originally only checked by `deps-cli update --security-only`'s
1201/// `classify_vulnerable_dependency` (#1329); moved here so `deps-lsp`'s vulnerability-fix code
1202/// action shares the same decision instead of reimplementing it.
1203///
1204/// `dv` and `current` are supplied by the caller rather than resolved internally — the
1205/// pre-extraction function rebuilt `crate::osv::vulnerability_keys` per call, tolerable for
1206/// one position-driven LSP request but O(n²) for a planner iterating every dependency in a
1207/// manifest; both call sites now build that map once and look up each dependency's key
1208/// themselves.
1209///
1210/// # Examples
1211///
1212/// ```
1213/// use deps_core::edit::plan_vulnerability_fix;
1214/// use deps_core::lsp_helpers::{
1215///     DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
1216///     RequirementResolution, SourcePolicy,
1217/// };
1218/// use deps_core::osv::{
1219///     Advisory, Capped, DependencyVulnerabilities, OsvVersion, UpgradeStatus, VulnSeverity,
1220/// };
1221/// use deps_core::{ConcreteVersion, Dependency, PackageName, VersionReq};
1222/// use deps_core::position::{Position, Range};
1223/// use std::any::Any;
1224/// use std::sync::Arc;
1225///
1226/// struct MockFormatter;
1227/// impl PackageNaming for MockFormatter {}
1228/// impl PackageRendering for MockFormatter {
1229///     fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
1230///         version.to_string()
1231///     }
1232///     fn package_url(&self, name: &PackageName) -> String {
1233///         format!("https://example.com/{}", name.as_str())
1234///     }
1235/// }
1236/// impl RequirementResolution for MockFormatter {}
1237/// impl DiagnosticMessages for MockFormatter {}
1238/// impl DiagnosticPolicy for MockFormatter {}
1239/// impl SourcePolicy for MockFormatter {}
1240/// impl OsvNaming for MockFormatter {}
1241///
1242/// struct MockDep {
1243///     name: PackageName,
1244///     version_req: VersionReq,
1245///     version_range: Range,
1246///     name_range: Range,
1247/// }
1248/// impl Dependency for MockDep {
1249///     fn name(&self) -> &PackageName { &self.name }
1250///     fn name_range(&self) -> Range { self.name_range }
1251///     fn version_requirement(&self) -> Option<&VersionReq> { Some(&self.version_req) }
1252///     fn version_range(&self) -> Option<Range> { Some(self.version_range) }
1253///     fn source(&self) -> deps_core::parser::DependencySource {
1254///         deps_core::parser::DependencySource::Registry
1255///     }
1256///     fn as_any(&self) -> &dyn Any { self }
1257/// }
1258///
1259/// let dep = MockDep {
1260///     name: PackageName::new("serde"),
1261///     version_req: VersionReq::new("1.0.0"),
1262///     version_range: Range::new(Position::new(0, 9), Position::new(0, 14)),
1263///     name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
1264/// };
1265///
1266/// let advisory = Arc::new(
1267///     Advisory::new(
1268///         "RUSTSEC-2024-0001".to_string(),
1269///         "2024-01-01T00:00:00Z".to_string(),
1270///         VulnSeverity::High,
1271///     )
1272///     .expect("valid osv id")
1273///     .with_fixed_versions(vec![OsvVersion::new("1.2.0")]),
1274/// );
1275/// let dv = DependencyVulnerabilities::new(Capped::new(vec![advisory], 1))
1276///     .with_fix_target_status(UpgradeStatus::CandidateClean { version: ConcreteVersion::new("1.2.0") });
1277///
1278/// let planned =
1279///     plan_vulnerability_fix(&dep, dep.version_range, "1.0.0", &dv, None, &MockFormatter);
1280/// assert_eq!(planned.unwrap().edit.new_text, "1.2.0");
1281/// ```
1282///
1283/// # Errors
1284///
1285/// Returns [`VulnFixSkip`] for any of the six causes that make an edit impossible or
1286/// unnecessary — see that type's variants.
1287pub fn plan_vulnerability_fix(
1288    dep: &dyn Dependency,
1289    version_range: crate::position::Range,
1290    current: &str,
1291    dv: &crate::osv::DependencyVulnerabilities,
1292    latest: Option<&crate::osv::UpgradeStatus>,
1293    formatter: &dyn EcosystemFormatter,
1294) -> Result<PlannedUpdate, VulnFixSkip> {
1295    let (_fix, version_native) = resolve_verified_fix(dv, latest, formatter)?;
1296    plan_verified_fix(dep, version_range, current, &version_native, formatter)
1297}
1298
1299/// The remainder of [`plan_vulnerability_fix`]'s decision, for a fix target already resolved
1300/// and verified via [`resolve_verified_fix`].
1301///
1302/// Split out for a caller that already holds `version_native` because it needed it for its
1303/// own decision (e.g. a yank check) before ever reaching this point —
1304/// [`plan_vulnerability_fix`] itself calls [`resolve_verified_fix`] internally, so calling
1305/// that first and then [`plan_vulnerability_fix`] would run the same resolve-and-verify chain
1306/// twice per dependency (code review finding, #1350). `deps-cli`'s
1307/// `classify_vulnerable_dependency` is this function's only caller outside
1308/// [`plan_vulnerability_fix`] itself.
1309///
1310/// # Errors
1311///
1312/// Returns [`VulnFixSkip::OversizedRequirement`], [`VulnFixSkip::UnresolvedPlaceholder`],
1313/// [`VulnFixSkip::RequirementAlreadyResolves`], or [`VulnFixSkip::NoOpRewrite`] — the causes
1314/// that can still make an edit unnecessary once the fix is already known resolved and verified.
1315pub fn plan_verified_fix(
1316    dep: &dyn Dependency,
1317    version_range: crate::position::Range,
1318    current: &str,
1319    version_native: &str,
1320    formatter: &dyn EcosystemFormatter,
1321) -> Result<PlannedUpdate, VulnFixSkip> {
1322    // Checked before the placeholder gate: that gate reports oversized text as a placeholder,
1323    // which would misattribute a size-based skip to `UnresolvedPlaceholder`.
1324    if requirement_len_exceeds_cap(current)
1325        || dep
1326            .version_requirement()
1327            .is_some_and(requirement_is_oversized)
1328        || dep
1329            .version_literal()
1330            .is_some_and(requirement_len_exceeds_cap)
1331    {
1332        return Err(VulnFixSkip::OversizedRequirement);
1333    }
1334
1335    // #1370: central placeholder gate, checked first — an unexpanded placeholder has no
1336    // concrete version text to replace, independent of whether `bounded_requirement_already_resolves_to`
1337    // or the formatter's own rewrite logic would coincidentally treat it as safe. Checked
1338    // against `current` (the exact text a caller is about to consider rewriting), not
1339    // `dep.version_requirement()`, since a caller may reach this with `current` derived from
1340    // somewhere other than the dependency's own preserved requirement field.
1341    if requirement_is_placeholder_for(formatter, dep, current) {
1342        return Err(VulnFixSkip::UnresolvedPlaceholder);
1343    }
1344
1345    let fix_concrete = ConcreteVersion::new(version_native);
1346    let requirement_already_resolves_to_fix =
1347        dep.version_requirement().is_some_and(|version_req| {
1348            formatter.requirement_already_resolves_to(version_req, &fix_concrete)
1349        });
1350    if requirement_already_resolves_to_fix {
1351        return Err(VulnFixSkip::RequirementAlreadyResolves);
1352    }
1353
1354    // Unreachable after the gate above; fails closed rather than unwrapping.
1355    let Some(new_text) = replacement_text(formatter, dep, &fix_concrete, current) else {
1356        return Err(VulnFixSkip::UnresolvedPlaceholder);
1357    };
1358    let literal_target = dep.version_literal().unwrap_or(current);
1359    if strip_whitespace(literal_target) == strip_whitespace(&new_text) {
1360        return Err(VulnFixSkip::NoOpRewrite);
1361    }
1362
1363    let normalized_name = formatter.normalize_package_name(dep.name());
1364    Ok(PlannedUpdate {
1365        name: dep.name().as_str().to_string(),
1366        normalized_name,
1367        name_range: dep.name_range(),
1368        // `current` here is the requirement text passed in for placeholder/no-op checks
1369        // above, not a resolved pinned version — `PlannedUpdate::current` documents only the
1370        // latter, so this caller has none to report.
1371        current: None,
1372        target: ConcreteVersion::new(version_native),
1373        edit: ManifestEdit {
1374            range: version_range,
1375            new_text,
1376        },
1377    })
1378}
1379
1380#[cfg(test)]
1381mod tests {
1382    use super::*;
1383    use crate::position::Position;
1384
1385    fn range(sl: u32, sc: u32, el: u32, ec: u32) -> crate::position::Range {
1386        crate::position::Range::new(Position::new(sl, sc), Position::new(el, ec))
1387    }
1388
1389    // --- classify_update / UpdateKind ---
1390
1391    #[test]
1392    fn test_classify_update_major() {
1393        assert_eq!(classify_update("1.2.3", "2.0.0"), UpdateKind::Major);
1394    }
1395
1396    #[test]
1397    fn test_classify_update_minor() {
1398        assert_eq!(classify_update("1.2.3", "1.3.0"), UpdateKind::Minor);
1399    }
1400
1401    #[test]
1402    fn test_classify_update_patch() {
1403        assert_eq!(classify_update("1.2.3", "1.2.4"), UpdateKind::Patch);
1404    }
1405
1406    #[test]
1407    fn test_classify_update_v_prefix() {
1408        assert_eq!(classify_update("v1.2.3", "v2.0.0"), UpdateKind::Major);
1409    }
1410
1411    #[test]
1412    fn test_classify_update_prerelease_and_build_suffix_ignored() {
1413        assert_eq!(
1414            classify_update("1.2.3-alpha.1", "1.2.3+build.5"),
1415            UpdateKind::Patch
1416        );
1417    }
1418
1419    #[test]
1420    fn test_classify_update_github_actions_sha_pin_is_unknown() {
1421        assert_eq!(
1422            classify_update(
1423                "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
1424                "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3"
1425            ),
1426            UpdateKind::Unknown
1427        );
1428    }
1429
1430    #[test]
1431    fn test_classify_update_go_pseudo_version_is_unknown() {
1432        assert_eq!(
1433            classify_update("v0.0.0-20210101000000-abcdef123456", "v1.0.0"),
1434            UpdateKind::Unknown
1435        );
1436        assert_eq!(
1437            classify_update(
1438                "v1.2.3-0.20210101000000-abcdef123456",
1439                "v1.2.3-0.20210102000000-abcdef654321"
1440            ),
1441            UpdateKind::Unknown
1442        );
1443    }
1444
1445    #[test]
1446    fn test_classify_update_maven_nuget_range_syntax_is_unknown() {
1447        assert_eq!(classify_update("[1.0,2.0)", "2.0.0"), UpdateKind::Unknown);
1448        assert_eq!(classify_update("1.0.0", "[1.0,2.0)"), UpdateKind::Unknown);
1449    }
1450
1451    #[test]
1452    fn test_classify_update_wildcard_latest_workspace_star_is_unknown() {
1453        assert_eq!(classify_update("*", "1.0.0"), UpdateKind::Unknown);
1454        assert_eq!(classify_update("latest", "1.0.0"), UpdateKind::Unknown);
1455        assert_eq!(classify_update("workspace:*", "1.0.0"), UpdateKind::Unknown);
1456    }
1457
1458    #[test]
1459    fn test_classify_update_gradle_strictly_preferred_syntax_is_unknown() {
1460        assert_eq!(
1461            classify_update("{strictly 1.0}!!1.2", "1.3.0"),
1462            UpdateKind::Unknown
1463        );
1464    }
1465
1466    #[test]
1467    fn test_classify_update_equal_versions_is_patch() {
1468        assert_eq!(classify_update("1.2.3", "1.2.3"), UpdateKind::Patch);
1469    }
1470
1471    #[test]
1472    fn test_classify_update_four_component_version() {
1473        assert_eq!(classify_update("1.0.0.0", "1.0.1.0"), UpdateKind::Patch);
1474    }
1475
1476    // --- dedup_overlapping_edits<ManifestEdit> ---
1477
1478    #[test]
1479    fn test_dedup_overlapping_manifest_edits_drops_the_later_overlap() {
1480        let edits = vec![
1481            ManifestEdit {
1482                range: range(0, 0, 0, 5),
1483                new_text: "a".to_string(),
1484            },
1485            ManifestEdit {
1486                range: range(0, 2, 0, 7),
1487                new_text: "b".to_string(),
1488            },
1489        ];
1490        let kept = dedup_overlapping_edits(edits, "test");
1491        assert_eq!(kept.len(), 1);
1492        assert_eq!(kept[0].new_text, "a");
1493    }
1494
1495    #[test]
1496    fn test_dedup_non_overlapping_manifest_edits_keeps_both() {
1497        let edits = vec![
1498            ManifestEdit {
1499                range: range(0, 0, 0, 5),
1500                new_text: "a".to_string(),
1501            },
1502            ManifestEdit {
1503                range: range(1, 0, 1, 5),
1504                new_text: "b".to_string(),
1505            },
1506        ];
1507        let kept = dedup_overlapping_edits(edits, "test");
1508        assert_eq!(kept.len(), 2);
1509    }
1510
1511    // --- apply_edits ---
1512
1513    #[test]
1514    fn test_apply_edits_single_line() {
1515        let content = "serde = \"1.0.0\"\n";
1516        let edits = vec![ManifestEdit {
1517            range: range(0, 9, 0, 14),
1518            new_text: "1.2.0".to_string(),
1519        }];
1520        assert_eq!(apply_edits(content, &edits), "serde = \"1.2.0\"\n");
1521    }
1522
1523    #[test]
1524    fn test_apply_edits_multiple_lines_reverse_splice() {
1525        let content = "serde = \"1.0.0\"\ntokio = \"1.0.0\"\n";
1526        let edits = vec![
1527            ManifestEdit {
1528                range: range(0, 9, 0, 14),
1529                new_text: "1.2.0".to_string(),
1530            },
1531            ManifestEdit {
1532                range: range(1, 9, 1, 14),
1533                new_text: "1.3.0".to_string(),
1534            },
1535        ];
1536        assert_eq!(
1537            apply_edits(content, &edits),
1538            "serde = \"1.2.0\"\ntokio = \"1.3.0\"\n"
1539        );
1540    }
1541
1542    #[test]
1543    fn test_apply_edits_empty_slice_is_a_no_op() {
1544        let content = "serde = \"1.0.0\"\n";
1545        assert_eq!(apply_edits(content, &[]), content);
1546    }
1547
1548    /// Security-S2 regression: an inverted range (`start > end`) must be dropped, not panic
1549    /// `replace_range`. `apply_edits` splices ranges in-process now (deps-cli), unlike its
1550    /// pre-#1329 callers, which only ever handed ranges back to an LSP client.
1551    #[test]
1552    fn test_apply_edits_drops_inverted_range_instead_of_panicking() {
1553        let content = "serde = \"1.0.0\"\n";
1554        let edits = vec![ManifestEdit {
1555            range: range(0, 14, 0, 9),
1556            new_text: "evil".to_string(),
1557        }];
1558        assert_eq!(apply_edits(content, &edits), content);
1559    }
1560
1561    #[test]
1562    fn test_apply_edits_valid_edit_still_applies_alongside_a_dropped_inverted_one() {
1563        let content = "serde = \"1.0.0\"\ntokio = \"1.0.0\"\n";
1564        let edits = vec![
1565            ManifestEdit {
1566                range: range(0, 14, 0, 9),
1567                new_text: "evil".to_string(),
1568            },
1569            ManifestEdit {
1570                range: range(1, 9, 1, 14),
1571                new_text: "1.3.0".to_string(),
1572            },
1573        ];
1574        assert_eq!(
1575            apply_edits(content, &edits),
1576            "serde = \"1.0.0\"\ntokio = \"1.3.0\"\n"
1577        );
1578    }
1579
1580    #[test]
1581    fn test_apply_edits_non_ascii_line_with_several_edits() {
1582        let content = "h\u{e9}llo \u{1f600} a = \"1.0.0\", b = \"2.0.0\"\n";
1583        let edit = |sc, ec, text: &str| ManifestEdit {
1584            range: range(0, sc, 0, ec),
1585            new_text: text.to_string(),
1586        };
1587        let edits = vec![
1588            edit(27, 32, "2.2.2"),
1589            edit(6, 8, "X"),
1590            edit(14, 19, "1.1.0"),
1591        ];
1592        assert_eq!(
1593            apply_edits(content, &edits),
1594            "h\u{e9}llo X a = \"1.1.0\", b = \"2.2.2\"\n"
1595        );
1596    }
1597
1598    #[test]
1599    fn test_apply_edits_many_edits_on_one_long_non_ascii_line() {
1600        const UNITS: usize = 5000;
1601        const UNIT_UTF16_LEN: u32 = 18;
1602        const VERSION_COL: u32 = 10;
1603        let unit = |version: &str| format!("\u{e9}\u{1f600}dep = \"{version}\", ");
1604        let content = "\u{e9}\u{1f600}dep = \"1.0.0\", ".repeat(UNITS);
1605        let edits: Vec<ManifestEdit> = (0..UNITS)
1606            .filter(|i| i % 30 == 0)
1607            .map(|i| {
1608                let start = VERSION_COL + UNIT_UTF16_LEN * u32::try_from(i).unwrap();
1609                ManifestEdit {
1610                    range: range(0, start, 0, start + 5),
1611                    new_text: "9.9.9".to_string(),
1612                }
1613            })
1614            .collect();
1615        assert!(edits.len() >= 100);
1616        let expected: String = (0..UNITS)
1617            .map(|i| unit(if i % 30 == 0 { "9.9.9" } else { "1.0.0" }))
1618            .collect();
1619        assert_eq!(apply_edits(&content, &edits), expected);
1620    }
1621
1622    // --- collect_update_edits / collect_update_candidates ---
1623
1624    mod collect_update_tests {
1625        use super::*;
1626        use crate::lsp_helpers::PackageVersions;
1627        use crate::lsp_helpers::test_support::{MOCK_FORMATTER, MockDep, MockParseResult, pkg};
1628        use crate::{EcosystemId, PackageName};
1629        use std::collections::HashMap;
1630
1631        fn dep(name: &str, req: &str, version_range: crate::position::Range) -> MockDep {
1632            MockDep {
1633                name: pkg(name),
1634                version_req: crate::VersionReq::new(req),
1635                version_range,
1636                name_range: crate::position::Range::default(),
1637            }
1638        }
1639
1640        fn parse_result(deps: Vec<MockDep>) -> MockParseResult {
1641            MockParseResult {
1642                deps,
1643                uri: crate::test_util::test_uri("/test/Cargo.toml"),
1644            }
1645        }
1646
1647        /// spec 050 (T002): a renamed/aliased dependency's two occurrences must each
1648        /// classify `current` against their own resolved pin, never the collapsed per-name
1649        /// map.
1650        #[test]
1651        fn test_collect_update_edits_renamed_occurrence_resolves_its_own_pin() {
1652            let content = "serde = \"0.9\"\nserde = \"1.0\"\n";
1653            let pr = parse_result(vec![
1654                dep("serde", "0.9", range(0, 9, 0, 12)),
1655                dep("serde", "1.0", range(1, 9, 1, 12)),
1656            ]);
1657            let mut cached = HashMap::new();
1658            cached.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
1659            let resolved_versions: HashMap<PackageName, ConcreteVersion> = HashMap::new();
1660            let mut candidates = HashMap::new();
1661            candidates.insert(
1662                pkg("serde"),
1663                vec![
1664                    ConcreteVersion::from("0.9.15"),
1665                    ConcreteVersion::from("1.0.219"),
1666                ],
1667            );
1668            let versions = VersionData::new(&cached, &resolved_versions)
1669                .with_resolved_version_candidates(&candidates)
1670                .with_ecosystem(EcosystemId::Cargo);
1671
1672            let mut planned = collect_update_edits(&pr, content, versions, &MOCK_FORMATTER);
1673            planned.sort_by_key(|p| p.edit.range.start.line);
1674
1675            assert_eq!(planned.len(), 2);
1676            assert_eq!(planned[0].current, Some(ConcreteVersion::from("0.9.15")));
1677            assert_eq!(planned[1].current, Some(ConcreteVersion::from("1.0.219")));
1678        }
1679
1680        #[test]
1681        fn test_collect_update_candidates_non_literal_span_is_unplannable() {
1682            // Mirrors the Maven `${property}` class: `version_range` spans a reference, not
1683            // the literal requirement text.
1684            let content = "<version>${slf4j.version}</version>";
1685            let pr = parse_result(vec![dep("slf4j-api", "2.0.16", range(0, 9, 0, 25))]);
1686            let mut cached = HashMap::new();
1687            cached.insert("slf4j-api".into(), PackageVersions::latest_only("2.1.0"));
1688            let resolved: HashMap<PackageName, ConcreteVersion> = HashMap::new();
1689            let versions = VersionData::new(&cached, &resolved);
1690
1691            let candidates = collect_update_candidates(&pr, content, versions, &MOCK_FORMATTER);
1692            assert_eq!(candidates.len(), 1);
1693            assert!(matches!(
1694                candidates[0],
1695                UpdateCandidate::Unplannable {
1696                    reason: UnplannableReason::NonLiteralSpan,
1697                    ..
1698                }
1699            ));
1700            assert!(
1701                collect_update_edits(&pr, content, versions, &MOCK_FORMATTER).is_empty(),
1702                "an unplannable candidate must never reach collect_update_edits's writable subset"
1703            );
1704        }
1705
1706        /// Issue #1517 (the P0 this fix addresses): OSV flagged the registry's "latest" as
1707        /// malicious — `collect_update_candidates` (shared by the LSP code lens and
1708        /// `deps-cli update`'s default mode) must never plan writing it into the manifest.
1709        #[test]
1710        fn test_collect_update_candidates_osv_flagged_latest_is_unplannable() {
1711            let content = "feed-widget-helper = \"1.0.4\"\n";
1712            let pr = parse_result(vec![dep(
1713                "feed-widget-helper",
1714                "1.0.4",
1715                range(0, 22, 0, 29),
1716            )]);
1717            let mut cached = HashMap::new();
1718            cached.insert(
1719                "feed-widget-helper".into(),
1720                PackageVersions::latest_only("1.0.8"),
1721            );
1722            let resolved: HashMap<PackageName, ConcreteVersion> = HashMap::new();
1723            let mut latest_status = crate::osv::LatestStatusMap::new();
1724            latest_status.insert(
1725                crate::test_util::vuln_key("feed-widget-helper"),
1726                crate::osv::UpgradeStatus::CandidateVulnerable {
1727                    version: ConcreteVersion::new("1.0.8"),
1728                    advisory_ids: crate::osv::Capped::new(vec!["MAL-2026-16332".to_string()], 1),
1729                    worst_severity: Some(crate::osv::VulnSeverity::Malicious),
1730                },
1731            );
1732            let versions = VersionData::new(&cached, &resolved).with_latest_status(&latest_status);
1733
1734            let candidates = collect_update_candidates(&pr, content, versions, &MOCK_FORMATTER);
1735            assert_eq!(candidates.len(), 1, "{candidates:?}");
1736            assert!(matches!(
1737                candidates[0],
1738                UpdateCandidate::Unplannable {
1739                    reason: UnplannableReason::LatestFlaggedByOsv,
1740                    ..
1741                }
1742            ));
1743            assert!(
1744                collect_update_edits(&pr, content, versions, &MOCK_FORMATTER).is_empty(),
1745                "a flagged latest must never reach collect_update_edits's writable subset"
1746            );
1747        }
1748
1749        /// Issue #1517 AC4: a `latest_status` map with no entry for this dependency (the
1750        /// pre-phase-B window, or a transient skip) must fail closed, never be planned as if
1751        /// verified safe.
1752        #[test]
1753        fn test_collect_update_candidates_unverified_latest_is_unplannable() {
1754            let content = "pkg = \"1.0.0\"\n";
1755            let pr = parse_result(vec![dep("pkg", "1.0.0", range(0, 6, 0, 13))]);
1756            let mut cached = HashMap::new();
1757            cached.insert("pkg".into(), PackageVersions::latest_only("2.0.0"));
1758            let resolved: HashMap<PackageName, ConcreteVersion> = HashMap::new();
1759            // Empty map, not `None`: the pre-phase-B window attaches `Some(&empty_map)`.
1760            let latest_status = crate::osv::LatestStatusMap::new();
1761            let versions = VersionData::new(&cached, &resolved).with_latest_status(&latest_status);
1762
1763            let candidates = collect_update_candidates(&pr, content, versions, &MOCK_FORMATTER);
1764            assert_eq!(candidates.len(), 1, "{candidates:?}");
1765            assert!(matches!(
1766                candidates[0],
1767                UpdateCandidate::Unplannable {
1768                    reason: UnplannableReason::LatestUnverified,
1769                    ..
1770                }
1771            ));
1772        }
1773
1774        #[test]
1775        fn test_collect_update_candidates_no_op_rewrite_is_unplannable() {
1776            struct NoOpFormatter;
1777            impl crate::lsp_helpers::PackageNaming for NoOpFormatter {}
1778            impl crate::lsp_helpers::PackageRendering for NoOpFormatter {
1779                fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
1780                    version.to_string()
1781                }
1782                fn package_url(&self, name: &PackageName) -> String {
1783                    name.as_str().to_string()
1784                }
1785                fn format_version_replacing(
1786                    &self,
1787                    _version: &ConcreteVersion,
1788                    current: &str,
1789                ) -> String {
1790                    current.to_string()
1791                }
1792            }
1793            impl crate::lsp_helpers::RequirementResolution for NoOpFormatter {
1794                fn is_bounded_requirement_up_to_date(
1795                    &self,
1796                    _requirement: crate::lsp_helpers::BoundedVersionReq<'_>,
1797                    _latest: &ConcreteVersion,
1798                ) -> bool {
1799                    false
1800                }
1801            }
1802            impl crate::lsp_helpers::DiagnosticMessages for NoOpFormatter {}
1803            impl crate::lsp_helpers::DiagnosticPolicy for NoOpFormatter {}
1804            impl crate::lsp_helpers::SourcePolicy for NoOpFormatter {}
1805            impl crate::lsp_helpers::OsvNaming for NoOpFormatter {}
1806
1807            let content = "pkg = \"1.0.0\"\n";
1808            let pr = parse_result(vec![dep("pkg", "1.0.0", range(0, 7, 0, 12))]);
1809            let mut cached = HashMap::new();
1810            cached.insert("pkg".into(), PackageVersions::latest_only("1.2.0"));
1811            let resolved: HashMap<PackageName, ConcreteVersion> = HashMap::new();
1812            let versions = VersionData::new(&cached, &resolved);
1813
1814            let candidates = collect_update_candidates(&pr, content, versions, &NoOpFormatter);
1815            assert_eq!(candidates.len(), 1);
1816            assert!(matches!(
1817                candidates[0],
1818                UpdateCandidate::Unplannable {
1819                    reason: UnplannableReason::NoOpRewrite,
1820                    ..
1821                }
1822            ));
1823        }
1824
1825        #[test]
1826        fn test_collect_update_candidates_unsafe_latest_is_unplannable() {
1827            let content = "serde = \"1.0.0\"\n";
1828            let pr = parse_result(vec![dep("serde", "1.0.0", range(0, 8, 0, 15))]);
1829            let mut cached = HashMap::new();
1830            cached.insert(
1831                "serde".into(),
1832                PackageVersions::latest_only("1.2.0\", \"evil\": \"true"),
1833            );
1834            let resolved: HashMap<PackageName, ConcreteVersion> = HashMap::new();
1835            let versions = VersionData::new(&cached, &resolved);
1836
1837            let candidates = collect_update_candidates(&pr, content, versions, &MOCK_FORMATTER);
1838            assert_eq!(candidates.len(), 1);
1839            assert!(matches!(
1840                candidates[0],
1841                UpdateCandidate::Unplannable {
1842                    reason: UnplannableReason::UnsafeLatestVersion,
1843                    ..
1844                }
1845            ));
1846        }
1847
1848        #[test]
1849        fn test_collect_update_candidates_up_to_date_dependency_produces_no_candidate() {
1850            // A dependency that is not `Outdated` at all must produce no entry — reporting
1851            // every up-to-date dependency as a "candidate" would be noise, not signal.
1852            let content = "serde = \"^1.0\"\n";
1853            let pr = parse_result(vec![dep("serde", "^1.0", range(0, 8, 0, 14))]);
1854            let mut cached = HashMap::new();
1855            cached.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
1856            let resolved: HashMap<PackageName, ConcreteVersion> = HashMap::new();
1857            let versions = VersionData::new(&cached, &resolved);
1858
1859            let candidates = collect_update_candidates(&pr, content, versions, &MOCK_FORMATTER);
1860            assert!(candidates.is_empty());
1861        }
1862
1863        /// #1556 N3 (review): `current` in `collect_update_candidates` — the value
1864        /// `deps-cli update --update-types` feeds into `classify_update` — now flows through
1865        /// `resolve_in_use_version`, so a GitHub Actions SHA pin whose `resolved_pin_version`
1866        /// hook resolves to a full-semver tag now classifies as a real `UpdateKind` instead of
1867        /// `Unknown`. This is a genuine, user-visible CLI behavior change introduced by this
1868        /// PR's core fix that had zero coverage before: `test_classify_update_github_actions_
1869        /// sha_pin_is_unknown` only calls `classify_update` directly on two raw SHA strings and
1870        /// never goes through `resolve_in_use_version`/`resolved_pin_version` at all — that test
1871        /// still covers the unresolved case (the hook returning nothing) and must keep passing
1872        /// unchanged.
1873        #[test]
1874        fn test_collect_update_candidates_github_actions_sha_pin_current_resolves_via_hook() {
1875            struct ShaPinResolvedFormatter;
1876            impl crate::lsp_helpers::PackageNaming for ShaPinResolvedFormatter {}
1877            impl crate::lsp_helpers::PackageRendering for ShaPinResolvedFormatter {
1878                fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
1879                    version.to_string()
1880                }
1881                fn package_url(&self, name: &PackageName) -> String {
1882                    name.as_str().to_string()
1883                }
1884            }
1885            impl crate::lsp_helpers::RequirementResolution for ShaPinResolvedFormatter {
1886                fn is_bounded_requirement_up_to_date(
1887                    &self,
1888                    _requirement: crate::lsp_helpers::BoundedVersionReq<'_>,
1889                    _latest: &ConcreteVersion,
1890                ) -> bool {
1891                    false
1892                }
1893                fn resolved_pin_version(
1894                    &self,
1895                    _dep: &dyn Dependency,
1896                ) -> Option<crate::lsp_helpers::ResolvedPin> {
1897                    Some(crate::lsp_helpers::ResolvedPin::most_specific(
1898                        ConcreteVersion::new("v1.3.0"),
1899                    ))
1900                }
1901            }
1902            impl crate::lsp_helpers::DiagnosticMessages for ShaPinResolvedFormatter {}
1903            impl crate::lsp_helpers::DiagnosticPolicy for ShaPinResolvedFormatter {}
1904            impl crate::lsp_helpers::SourcePolicy for ShaPinResolvedFormatter {}
1905            impl crate::lsp_helpers::OsvNaming for ShaPinResolvedFormatter {}
1906
1907            let sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
1908            let content = format!("actions/checkout = \"{sha}\"\n");
1909            let sha_start = u32::try_from(content.find(sha).unwrap()).unwrap();
1910            let sha_end = sha_start + u32::try_from(sha.len()).unwrap();
1911            let pr = parse_result(vec![dep(
1912                "actions/checkout",
1913                sha,
1914                range(0, sha_start, 0, sha_end),
1915            )]);
1916            let mut cached = HashMap::new();
1917            cached.insert(
1918                "actions/checkout".into(),
1919                PackageVersions::latest_only("v1.4.0"),
1920            );
1921            let resolved: HashMap<PackageName, ConcreteVersion> = HashMap::new();
1922            let versions =
1923                VersionData::new(&cached, &resolved).with_ecosystem(EcosystemId::GithubActions);
1924
1925            let candidates =
1926                collect_update_candidates(&pr, &content, versions, &ShaPinResolvedFormatter);
1927
1928            assert_eq!(candidates.len(), 1, "{candidates:?}");
1929            let UpdateCandidate::Planned(planned) = &candidates[0] else {
1930                panic!("expected a planned update, got {:?}", candidates[0]);
1931            };
1932            assert_eq!(planned.current, Some(ConcreteVersion::from("v1.3.0")));
1933            assert_eq!(
1934                classify_update(
1935                    planned.current.as_ref().unwrap().as_str(),
1936                    planned.target.as_str()
1937                ),
1938                UpdateKind::Minor
1939            );
1940        }
1941    }
1942
1943    // --- plan_vulnerability_fix: #1344 requirement-already-admits-fix gate, #1347
1944    // requirement_is_unresolved hardening ---
1945
1946    mod plan_vulnerability_fix_tests {
1947        use super::*;
1948        use crate::PackageName;
1949        use crate::VersionReq;
1950        use crate::lsp_helpers::test_support::{
1951            MOCK_FORMATTER, MockDep, StrictSemverFormatter, pkg,
1952        };
1953        use crate::lsp_helpers::{
1954            DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
1955            RequirementMatcher, RequirementResolution, SourcePolicy,
1956        };
1957        use crate::osv::{
1958            Advisory, Capped, DependencyVulnerabilities, OsvVersion, UpgradeStatus, VulnSeverity,
1959        };
1960
1961        fn dep(name: &str, req: &str, version_range: crate::position::Range) -> MockDep {
1962            MockDep {
1963                name: PackageName::new(name),
1964                version_req: crate::VersionReq::new(req),
1965                version_range,
1966                name_range: crate::position::Range::default(),
1967            }
1968        }
1969
1970        fn verified_dv(fixed_version: &str) -> DependencyVulnerabilities {
1971            let advisory = std::sync::Arc::new(
1972                Advisory::new(
1973                    "RUSTSEC-2024-0001".to_string(),
1974                    "2024-01-01T00:00:00Z".to_string(),
1975                    VulnSeverity::High,
1976                )
1977                .expect("valid osv id")
1978                .with_fixed_versions(vec![OsvVersion::new(fixed_version)]),
1979            );
1980            DependencyVulnerabilities::new(Capped::new(vec![advisory], 1)).with_fix_target_status(
1981                UpgradeStatus::CandidateClean {
1982                    version: ConcreteVersion::new(fixed_version),
1983                },
1984            )
1985        }
1986
1987        /// Reproduces `GithubActionsFormatter`'s real semantics: `requirement_is_unresolved`
1988        /// is `true` for a full-SHA pin (it means "not decidable from text alone", not
1989        /// "unexpanded placeholder"), yet `format_version_replacing_for` still produces a
1990        /// legitimate SHA-preserving rewrite for it. Guards against reintroducing #1347's C1
1991        /// regression (gating `plan_vulnerability_fix` on `requirement_is_unresolved`, which
1992        /// would silently drop this ecosystem's working vulnerability remediation) — a real
1993        /// cross-crate `GithubActionsFormatter` can't be used here (`deps-core` cannot depend
1994        /// on `deps-github-actions`), so this mock reproduces its documented override shape
1995        /// instead.
1996        struct ShaPinFormatter;
1997        impl PackageNaming for ShaPinFormatter {}
1998        impl PackageRendering for ShaPinFormatter {
1999            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
2000                version.to_string()
2001            }
2002            fn package_url(&self, name: &crate::PackageName) -> String {
2003                name.as_str().to_string()
2004            }
2005            fn format_version_replacing(
2006                &self,
2007                version: &ConcreteVersion,
2008                _current: &str,
2009            ) -> String {
2010                format!("{version} # v-resolved")
2011            }
2012        }
2013        impl RequirementResolution for ShaPinFormatter {
2014            fn bounded_requirement_is_unresolved(
2015                &self,
2016                _requirement: crate::lsp_helpers::BoundedVersionReq<'_>,
2017            ) -> bool {
2018                true
2019            }
2020        }
2021        impl DiagnosticMessages for ShaPinFormatter {}
2022        impl DiagnosticPolicy for ShaPinFormatter {}
2023        impl SourcePolicy for ShaPinFormatter {}
2024        impl OsvNaming for ShaPinFormatter {}
2025
2026        /// A caret range ("^1" is what Cargo/npm-shaped bare "1" compiles to) that already
2027        /// admits the fix target must suppress the edit — the declared requirement needs no
2028        /// manifest change, only a lock-file update.
2029        #[test]
2030        fn test_requirement_already_admitting_fix_returns_none() {
2031            let d = dep("serde", "1", range(0, 8, 0, 9));
2032            let dv = verified_dv("1.0.2");
2033
2034            let planned =
2035                plan_vulnerability_fix(&d, d.version_range, "1", &dv, None, &StrictSemverFormatter);
2036            assert_eq!(planned, Err(VulnFixSkip::RequirementAlreadyResolves));
2037        }
2038
2039        /// #1472 defense-in-depth: an oversized requirement must not trip
2040        /// `RequirementAlreadyResolves`, even when `bounded_requirement_already_resolves_to` would
2041        /// otherwise report `true` — proven via `ExactMatchFormatter`'s exact string-equality
2042        /// matcher, so an oversized requirement identical to the fix target trivially "already
2043        /// resolves" without the gate. With the gate, planning stops earlier with the distinct
2044        /// `OversizedRequirement` skip (#1665) — not `UnresolvedPlaceholder`, which the
2045        /// placeholder gate's fail-closed `true` for oversized text would otherwise report; the
2046        /// at-cap control proves the size check is a strict `>`, not `>=`.
2047        #[test]
2048        fn test_oversized_requirement_does_not_report_already_resolves() {
2049            use crate::lsp_helpers::MAX_REQUIREMENT_LEN;
2050            use crate::lsp_helpers::test_support::ExactMatchFormatter;
2051
2052            let at_cap = "1".repeat(MAX_REQUIREMENT_LEN);
2053            let d = dep(
2054                "serde",
2055                &at_cap,
2056                range(0, 8, 0, 8 + u32::try_from(at_cap.len()).unwrap()),
2057            );
2058            assert_eq!(
2059                plan_verified_fix(&d, d.version_range, &at_cap, &at_cap, &ExactMatchFormatter),
2060                Err(VulnFixSkip::RequirementAlreadyResolves)
2061            );
2062
2063            let oversized = "1".repeat(MAX_REQUIREMENT_LEN + 1);
2064            let d = dep(
2065                "serde",
2066                &oversized,
2067                range(0, 8, 0, 8 + u32::try_from(oversized.len()).unwrap()),
2068            );
2069
2070            let planned = plan_verified_fix(
2071                &d,
2072                d.version_range,
2073                &oversized,
2074                &oversized,
2075                &ExactMatchFormatter,
2076            );
2077
2078            assert_eq!(planned, Err(VulnFixSkip::OversizedRequirement));
2079        }
2080
2081        /// #1665: the oversized check also covers the dependency's own requirement and version
2082        /// literal, not only the `current` text a caller passes in — each arm alone must yield
2083        /// `OversizedRequirement` rather than a placeholder or rewrite verdict.
2084        #[test]
2085        fn test_oversized_dependency_requirement_or_literal_is_oversized_skip() {
2086            use crate::lsp_helpers::MAX_REQUIREMENT_LEN;
2087            use crate::lsp_helpers::test_support::ExactMatchFormatter;
2088
2089            struct LiteralDep {
2090                inner: MockDep,
2091                literal: String,
2092            }
2093            impl Dependency for LiteralDep {
2094                fn name(&self) -> &PackageName {
2095                    self.inner.name()
2096                }
2097                fn name_range(&self) -> crate::position::Range {
2098                    self.inner.name_range()
2099                }
2100                fn version_requirement(&self) -> Option<&VersionReq> {
2101                    self.inner.version_requirement()
2102                }
2103                fn version_range(&self) -> Option<crate::position::Range> {
2104                    self.inner.version_range()
2105                }
2106                fn version_literal(&self) -> Option<&str> {
2107                    Some(&self.literal)
2108                }
2109                fn source(&self) -> crate::parser::DependencySource {
2110                    self.inner.source()
2111                }
2112                fn as_any(&self) -> &dyn std::any::Any {
2113                    self
2114                }
2115            }
2116
2117            let oversized = "1".repeat(MAX_REQUIREMENT_LEN + 1);
2118
2119            let oversized_requirement = dep("serde", &oversized, range(0, 8, 0, 9));
2120            assert_eq!(
2121                plan_verified_fix(
2122                    &oversized_requirement,
2123                    oversized_requirement.version_range,
2124                    "1",
2125                    "2",
2126                    &ExactMatchFormatter,
2127                ),
2128                Err(VulnFixSkip::OversizedRequirement)
2129            );
2130
2131            let oversized_literal = LiteralDep {
2132                inner: dep("serde", "1", range(0, 8, 0, 9)),
2133                literal: oversized,
2134            };
2135            assert_eq!(
2136                plan_verified_fix(
2137                    &oversized_literal,
2138                    range(0, 8, 0, 9),
2139                    "1",
2140                    "2",
2141                    &ExactMatchFormatter,
2142                ),
2143                Err(VulnFixSkip::OversizedRequirement)
2144            );
2145        }
2146
2147        /// A requirement the comparator confirms does NOT admit the fix target must still
2148        /// produce a rewrite — the #1344 gate must not suppress legitimate fixes.
2149        #[test]
2150        fn test_requirement_not_admitting_fix_returns_planned_edit() {
2151            let d = dep("serde", "0.9", range(0, 8, 0, 11));
2152            let dv = verified_dv("1.0.2");
2153
2154            let planned = plan_vulnerability_fix(
2155                &d,
2156                d.version_range,
2157                "0.9",
2158                &dv,
2159                None,
2160                &StrictSemverFormatter,
2161            )
2162            .expect("0.9 does not admit 1.0.2, so an edit must be planned");
2163            assert_eq!(planned.edit.new_text, "1.0.2");
2164            assert_eq!(planned.target.as_str(), "1.0.2");
2165        }
2166
2167        /// An ecosystem with no `compile_bounded_requirement` override (e.g. GitHub Actions/GitLab CI)
2168        /// has no comparator to consult, so the gate is inert and the textual no-op guard is
2169        /// the only available signal — a genuinely different target must still be planned.
2170        #[test]
2171        fn test_no_compile_requirement_override_falls_back_to_no_op_guard() {
2172            let d = dep("serde", "1", range(0, 8, 0, 9));
2173            let dv = verified_dv("1.0.2");
2174
2175            let planned =
2176                plan_vulnerability_fix(&d, d.version_range, "1", &dv, None, &MOCK_FORMATTER)
2177                    .unwrap();
2178            assert_eq!(planned.edit.new_text, "\"1.0.2\"");
2179        }
2180
2181        /// A malformed `fixed_versions` entry (as if it somehow reached this dependency's
2182        /// `advisories` despite OSV's own wire-boundary validation) must be rejected before
2183        /// any other gate runs — `VulnFixSkip::UnsafeVersion`, not a planned edit.
2184        #[test]
2185        fn test_unsafe_fix_version_is_rejected() {
2186            let d = dep("serde", "0.9", range(0, 8, 0, 11));
2187            // A space is not in `is_safe_version_string`'s allowlist.
2188            let dv = verified_dv("1.2.0 evil");
2189
2190            let planned =
2191                plan_vulnerability_fix(&d, d.version_range, "0.9", &dv, None, &MOCK_FORMATTER);
2192            assert_eq!(planned, Err(VulnFixSkip::UnsafeVersion));
2193        }
2194
2195        /// A genuine textual no-op — the formatter's rewrite is byte-identical to the literal
2196        /// fallback `current` text — must be `VulnFixSkip::NoOpRewrite`, distinct from
2197        /// `RequirementAlreadyResolves` above (no `compile_bounded_requirement` override here, so that
2198        /// gate never fires; this is the plain textual guard alone).
2199        #[test]
2200        fn test_true_no_op_rewrite_is_rejected() {
2201            let d = dep("serde", "1", range(0, 8, 0, 9));
2202            let dv = verified_dv("1.0.2");
2203
2204            // `MOCK_FORMATTER.format_version_for_text_edit` quotes its input, so the
2205            // already-quoted literal fallback below is byte-identical to the planned rewrite.
2206            let planned = plan_vulnerability_fix(
2207                &d,
2208                d.version_range,
2209                "\"1.0.2\"",
2210                &dv,
2211                None,
2212                &MOCK_FORMATTER,
2213            );
2214            assert_eq!(planned, Err(VulnFixSkip::NoOpRewrite));
2215        }
2216
2217        /// #1422 regression: a dependency with more than `ADVISORY_DISPLAY_CAP` advisories must
2218        /// still resolve and verify a fix computed from an advisory beyond that display cap —
2219        /// `resolve_recommended_fix`/`fix_target_is_verified` must read the full
2220        /// `MAX_ADVISORY_RECORDS`-capped `advisories` set, not the render-only
2221        /// `advisories_for_display` truncation.
2222        #[test]
2223        fn test_plan_vulnerability_fix_uses_advisory_beyond_display_cap() {
2224            use crate::osv::ADVISORY_DISPLAY_CAP;
2225
2226            let mut advisories: Vec<std::sync::Arc<Advisory>> = (0..ADVISORY_DISPLAY_CAP + 3)
2227                .map(|i| {
2228                    std::sync::Arc::new(
2229                        Advisory::new(
2230                            format!("ADV-{i}"),
2231                            "2024-01-01T00:00:00Z".to_string(),
2232                            VulnSeverity::High,
2233                        )
2234                        .expect("valid osv id")
2235                        .with_fixed_versions(vec![OsvVersion::new("1.0.0")]),
2236                    )
2237                })
2238                .collect();
2239            // The true highest fix sits past ADVISORY_DISPLAY_CAP; every other advisory fixes
2240            // at a lower version.
2241            let beyond_cap_index = ADVISORY_DISPLAY_CAP + 1;
2242            advisories[beyond_cap_index] = std::sync::Arc::new(
2243                Advisory::new(
2244                    format!("ADV-{beyond_cap_index}"),
2245                    "2024-01-01T00:00:00Z".to_string(),
2246                    VulnSeverity::High,
2247                )
2248                .expect("valid osv id")
2249                .with_fixed_versions(vec![OsvVersion::new("2.0.0")]),
2250            );
2251            let total = advisories.len();
2252            let dv = DependencyVulnerabilities::new(Capped::new(advisories, total))
2253                .with_fix_target_status(UpgradeStatus::CandidateClean {
2254                    version: ConcreteVersion::new("2.0.0"),
2255                });
2256
2257            let d = dep("serde", "0.9", range(0, 8, 0, 11));
2258            let planned =
2259                plan_vulnerability_fix(&d, d.version_range, "0.9", &dv, None, &MOCK_FORMATTER)
2260                    .expect("fix beyond the display cap must still be recommended and verified");
2261            assert_eq!(planned.edit.new_text, "\"2.0.0\"");
2262            assert_eq!(planned.target.as_str(), "2.0.0");
2263        }
2264
2265        /// A synthetic formatter mimicking a resolver that pins a bare requirement to its
2266        /// *lowest* admissible member (NuGet's bare `Version="1.0.0"` floor, #1344 C1) rather
2267        /// than following forward to the newest one. Its raw `compile_bounded_requirement` matcher is
2268        /// deliberately permissive (matches any version at or above the floor, exactly like
2269        /// NuGet's lower-bound-only shape) so this test proves `plan_vulnerability_fix` consults
2270        /// `bounded_requirement_already_resolves_to`'s override — which correctly refuses to
2271        /// suppress — rather than the raw matcher alone.
2272        struct FloorFormatter;
2273
2274        struct FloorMatcher(String);
2275        impl RequirementMatcher for FloorMatcher {
2276            fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
2277                Some(version.as_str() >= self.0.as_str())
2278            }
2279
2280            fn strict_prerelease_exclusion(&self) -> bool {
2281                false
2282            }
2283        }
2284
2285        impl PackageNaming for FloorFormatter {}
2286        impl PackageRendering for FloorFormatter {
2287            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
2288                version.to_string()
2289            }
2290            fn package_url(&self, name: &PackageName) -> String {
2291                name.as_str().to_string()
2292            }
2293        }
2294        impl RequirementResolution for FloorFormatter {
2295            fn compile_bounded_requirement(
2296                &self,
2297                requirement: crate::lsp_helpers::BoundedVersionReq<'_>,
2298            ) -> Option<Box<dyn RequirementMatcher>> {
2299                Some(Box::new(FloorMatcher(requirement.as_str().to_string())))
2300            }
2301
2302            fn bounded_requirement_already_resolves_to(
2303                &self,
2304                _requirement: crate::lsp_helpers::BoundedVersionReq<'_>,
2305                _target: &ConcreteVersion,
2306            ) -> bool {
2307                // A floor never auto-follows forward — leaving it unedited always keeps
2308                // resolving to the floor itself, never to a higher target.
2309                false
2310            }
2311        }
2312        impl DiagnosticMessages for FloorFormatter {}
2313        impl DiagnosticPolicy for FloorFormatter {}
2314        impl SourcePolicy for FloorFormatter {}
2315        impl OsvNaming for FloorFormatter {}
2316
2317        /// #1344 C1 regression: a floor-shaped requirement must NOT be suppressed just
2318        /// because `compile_bounded_requirement`'s raw matcher admits the fix target — the resolver
2319        /// keeps pinning to the floor, so an edit is still the only way to actually apply the
2320        /// fix. See `deps-nuget`'s real `bounded_requirement_already_resolves_to` override for the
2321        /// concrete regression this mirrors.
2322        #[test]
2323        fn test_floor_shaped_requirement_still_returns_planned_edit() {
2324            let d = dep("nuget.pkg", "1.0.0", range(0, 8, 0, 15));
2325            let dv = verified_dv("1.0.2");
2326
2327            // Sanity: the raw matcher alone would wrongly admit the fix, if the gate used it
2328            // directly instead of `bounded_requirement_already_resolves_to`.
2329            assert_eq!(
2330                FloorFormatter
2331                    .compile_requirement(&crate::VersionReq::new("1.0.0"))
2332                    .unwrap()
2333                    .matches(&ConcreteVersion::new("1.0.2")),
2334                Some(true)
2335            );
2336
2337            let planned =
2338                plan_vulnerability_fix(&d, d.version_range, "1.0.0", &dv, None, &FloorFormatter)
2339                    .expect("a floor-shaped requirement must not suppress the fix");
2340            assert_eq!(planned.edit.new_text, "1.0.2");
2341        }
2342
2343        #[test]
2344        fn test_plan_vulnerability_fix_ignores_requirement_is_unresolved() {
2345            let action_dep = MockDep {
2346                name: pkg("some-action"),
2347                version_req: VersionReq::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
2348                version_range: range(0, 9, 0, 49),
2349                name_range: crate::position::Range::default(),
2350            };
2351            let dv = verified_dv("1.2.0");
2352
2353            let planned = plan_vulnerability_fix(
2354                &action_dep,
2355                action_dep.version_range,
2356                "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2357                &dv,
2358                None,
2359                &ShaPinFormatter,
2360            );
2361
2362            assert_eq!(
2363                planned.expect("fix must still be planned").edit.new_text,
2364                "1.2.0 # v-resolved"
2365            );
2366        }
2367    }
2368}