Skip to main content

deps_core/lsp_helpers/
code_actions.rs

1use std::collections::HashSet;
2use tower_lsp_server::ls_types::{CodeAction, CodeActionKind, Position, Range, Uri, WorkspaceEdit};
3
4use crate::osv::{ScanOutcome, UpgradeStatus};
5use crate::{ConcreteVersion, Dependency, ParseResult, Registry, VersionReq};
6
7use super::{
8    DEPRECATED_DIAGNOSTIC_CODE, EcosystemFormatter, LineOffsetTable, UNSATISFIABLE_DIAGNOSTIC_CODE,
9    VersionData, is_safe_version_string, literal_span_matches, requirement_is_unsatisfiable,
10    single_file_edit, slice_for_range, strip_whitespace, warn_rejected_value,
11};
12
13/// The vulnerability-fix quickfix built by [`build_vulnerability_fix_action`],
14/// bundled with the native-namespace version it targets so callers can dedup
15/// display items and check the registry's yank flag against it without
16/// re-parsing the action's title.
17struct VulnerabilityFixAction {
18    /// `fix.version`, converted to this ecosystem's namespace via
19    /// [`EcosystemFormatter::osv_version_to_native`].
20    version_native: String,
21    /// The formatted edit text this action's own `TextEdit` writes — the exact
22    /// value `action.edit` carries, kept alongside it so callers (the REFACTOR-loop
23    /// dedup in [`generate_code_actions`]) can compare against it without
24    /// recomputing `format_version_replacing` and risking the copy silently
25    /// drifting from the actual edit if that computation ever changes.
26    new_text: String,
27    action: CodeAction,
28}
29
30/// Whether `dv.fix_target_status` clears `fix` (F) as an honest, presentable fix (#462
31/// FR-003).
32///
33/// `CandidateClean { version: F }` always clears it. A `CandidateVulnerable { version: F,
34/// advisory_ids }` result can *also* clear it — F may legitimately still be affected by
35/// advisories `recommended_fix()` never claimed to resolve in the first place (excluded via
36/// `upgrade_status`'s `still_applying` subtraction, or never had a known fix at all): that is
37/// #216's original honest "partial fix" contract, not a gap this verification introduces. It is
38/// suppressed the moment `advisory_ids` names either a *claimed* advisory (`fix`'s own
39/// `advisory_ids` — the fix doesn't do what its title says) or an advisory this dependency's
40/// `advisories` never recorded at all: a brand-new advisory only visible by live-checking F
41/// itself, since phase A only ever queries the dependency's *declared* version, not F (the #462
42/// repro: an advisory affecting some version strictly between declared and F, invisible until F
43/// is checked directly). Any other state ([`UpgradeStatus::NotChecked`] or a `version`
44/// mismatch) means F was never actually verified, so it is rejected too.
45///
46/// `advisory_ids` is itself capped at [`crate::osv::ADVISORY_DISPLAY_CAP`] the same way
47/// [`crate::osv::DependencyVulnerabilities::advisories`] is (#462 critic M1) — a truncated
48/// list can never be read as exhaustive, since the ids it dropped could just as easily be the
49/// claimed or unknown one that should have suppressed this fix. `!advisory_ids.is_complete()`
50/// is rejected unconditionally before the known/unclaimed check even runs, so a dependency with
51/// more affecting advisories than the cap never gets a false "verified clean" reading from a
52/// partial list.
53fn fix_target_is_verified(
54    dv: &crate::osv::DependencyVulnerabilities,
55    fix: &crate::osv::FixRecommendation,
56    version_native: &str,
57) -> bool {
58    match &dv.fix_target_status {
59        UpgradeStatus::CandidateClean { version } => version == version_native,
60        UpgradeStatus::CandidateVulnerable {
61            version,
62            advisory_ids,
63        } => {
64            if version != version_native || !advisory_ids.is_complete() {
65                return false;
66            }
67            let known_ids: HashSet<&str> = dv
68                .advisories
69                .items()
70                .iter()
71                .map(|a| a.id.as_str())
72                .collect();
73            advisory_ids
74                .items()
75                .iter()
76                .all(|id| known_ids.contains(id.as_str()) && !fix.advisory_ids.contains(id))
77        }
78        UpgradeStatus::NotChecked => false,
79    }
80}
81
82/// Builds the "fix vulnerability" quickfix for `dep`, if OSV data recommends
83/// one AND that recommendation's target version F has itself been verified
84/// against OSV (#462) — see [`fix_target_is_verified`].
85///
86/// Registry-independent by construction (FR-007, mirroring the rule already
87/// enforced in [`generate_diagnostics_from_cache`]): computed entirely from
88/// `versions.vulnerabilities` and `version_req` (the caller's already-fetched
89/// `dep.version_requirement()`), never from a registry fetch — a *registry*
90/// outage still never hides this action. It is not OSV-independent, though:
91/// F's verification is computed in `deps-lsp`'s phase B and handed in via
92/// `dv.fix_target_status`, so an OSV outage (or a code-action request that
93/// races ahead of phase B completing) leaves that unresolved and suppresses
94/// this action — the intended fail-safe degradation, not a bug. Callers must
95/// still reconcile the result against a successful registry fetch when one
96/// is available — see the yank check in [`generate_code_actions`].
97fn build_vulnerability_fix_action(
98    parse_result: &dyn ParseResult,
99    dep: &dyn Dependency,
100    uri: &Uri,
101    version_range: Range,
102    versions: VersionData<'_>,
103    version_req: &str,
104    formatter: &dyn EcosystemFormatter,
105) -> Option<VulnerabilityFixAction> {
106    let normalized_name = formatter.normalize_package_name(dep.name());
107    // #394 S2: prefer the version-qualified key so a fix action for one
108    // occurrence of a duplicated name is never built from another
109    // occurrence's OSV result. See `crate::osv::vulnerability_keys`.
110    let vuln_key = versions.ecosystem.and_then(|ecosystem| {
111        crate::osv::vulnerability_keys(parse_result, versions.resolved, formatter, ecosystem)
112            .remove(&dep.name_range())
113    });
114    let outcome = versions.vulnerabilities.and_then(|m| {
115        vuln_key
116            .as_deref()
117            .and_then(|key| m.get(key))
118            .or_else(|| m.get(&normalized_name))
119            .or_else(|| m.get(dep.name().as_str()))
120    })?;
121    let ScanOutcome::Vulnerable(dv) = outcome else {
122        return None;
123    };
124    let fix = dv.recommended_fix()?;
125    let version_native = formatter.osv_version_to_native(&fix.version);
126    if !is_safe_version_string(&version_native) {
127        warn_rejected_value(
128            "is_safe_version_string",
129            "vulnerability fix code action",
130            &version_native,
131        );
132        return None;
133    }
134
135    // #462: F must itself have been independently verified against OSV before it is
136    // offered as a fix — see `fix_target_is_verified`'s doc for the exact contract and why
137    // a `CandidateVulnerable` result doesn't always disqualify F.
138    if !fix_target_is_verified(dv, &fix, &version_native) {
139        return None;
140    }
141
142    // Computed before the N1 guard below against the *same* formatting the
143    // plain "update version" action uses (`format_version_replacing`), not
144    // the bare version: several ecosystems wrap or expand it (`deps-dart`'s
145    // `^`-prefix, a range), and `deps-pypi` rewrites it in place to preserve
146    // the manifest's existing pin style (`==1.0.1` -> `==1.0.2`) — the guard
147    // must compare the text that would actually be written.
148    let new_text = formatter.format_version_replacing_for(
149        dep,
150        &ConcreteVersion::new(version_native.as_str()),
151        version_req,
152    );
153
154    // N1: skip a no-op edit — the manifest already declares exactly the text
155    // this action would write, so applying it would rewrite the text to
156    // itself. Whitespace-insensitive, mirroring `literal_span_matches`:
157    // `version_req` can be a normalized requirement string with spacing the
158    // declared text and the freshly-formatted text don't agree on (e.g.
159    // pep508's `>=1.7, <2.0` vs. a formatter's `>=1.7,<2.0`), which would
160    // otherwise let a whitespace-only edit slip past this guard. Compares
161    // against `dep.version_literal()` rather than `version_req` when the
162    // ecosystem provides one, mirroring `generate_code_actions`'s literal-span
163    // guard — for `deps-swift`, `version_req` is a synthesized comparator
164    // (`"=2.61.0"`) that never equals the bare-literal formatted text
165    // (`"2.61.0"`) even when the edit genuinely is a no-op.
166    let literal_target = dep.version_literal().unwrap_or(version_req);
167    if strip_whitespace(literal_target) == strip_whitespace(&new_text) {
168        return None;
169    }
170
171    // S3: the scan target may have been the lockfile-resolved version, not
172    // the declared requirement — rewriting the manifest alone would then not
173    // clear the diagnostic until the lockfile is regenerated. Say so in the
174    // title rather than silently overclaiming.
175    let lockfile_hit = versions
176        .resolved
177        .get(normalized_name.as_str())
178        .or_else(|| versions.resolved.get(dep.name()))
179        .is_some();
180
181    // Names only the first (worst-severity, per `recommended_fix`'s sort)
182    // advisory id and summarizes the rest — `recommended_fix` can return an
183    // unbounded number of ids (up to `ADVISORY_DISPLAY_CAP`), and a title
184    // listing every one of them would overflow an editor's code-action menu.
185    let (first_id, rest_ids) = fix.advisory_ids.split_first()?;
186    let fixes = if rest_ids.is_empty() {
187        first_id.clone()
188    } else {
189        format!("{first_id} +{} more", rest_ids.len())
190    };
191    let title = if lockfile_hit {
192        format!("Update to {version_native} (fixes {fixes}; update lockfile to apply)")
193    } else {
194        format!("Update to {version_native} (fixes {fixes})")
195    };
196
197    let edits = single_file_edit(uri, version_range, new_text.clone());
198
199    Some(VulnerabilityFixAction {
200        version_native,
201        new_text,
202        action: CodeAction {
203            title,
204            kind: Some(CodeActionKind::QUICKFIX),
205            edit: Some(WorkspaceEdit {
206                changes: Some(edits),
207                ..Default::default()
208            }),
209            is_preferred: None,
210            // Stashes the resolved advisory ids, plus this action's own edit range, so the
211            // `deps-lsp` handler can bind this action to the matching client-supplied
212            // diagnostics (`CodeActionContext::diagnostics`) without deps-core needing to
213            // know about LSP request context — cleared by the handler once consumed. Shape
214            // shared with `build_unsatisfiable_fix_action`'s stashed payload: `bind_diagnostics`
215            // matches on `diagnostic_codes` regardless of which producer built the action.
216            data: Some(serde_json::json!({
217                "diagnostic_codes": fix.advisory_ids,
218                "diagnostic_range": version_range,
219            })),
220            ..Default::default()
221        },
222    })
223}
224
225/// The unsatisfiable-requirement quickfix built by [`build_unsatisfiable_fix_action`],
226/// bundled with the native-namespace target version it carries so callers can dedup
227/// display items and check the registry's yank flag against it, mirroring
228/// [`VulnerabilityFixAction`]'s shape.
229struct UnsatisfiableFixAction {
230    /// The cached `latest` version this action targets (see the function doc for why
231    /// this is the cached rather than the live value).
232    version_native: String,
233    /// The formatted edit text this action's own `TextEdit` writes, kept alongside it
234    /// for the same reason [`VulnerabilityFixAction::new_text`] is.
235    new_text: String,
236    action: CodeAction,
237}
238
239/// Builds the "fix unsatisfiable requirement" quickfix for `dep`, if its declared
240/// requirement currently matches no published version.
241///
242/// Mirrors [`build_vulnerability_fix_action`]'s shape: computed entirely from the cached
243/// `versions` snapshot and the dependency's declared requirement, before any registry
244/// fetch, so a registry outage never hides it (the same FR-007 rationale). Gated by
245/// [`requirement_is_unsatisfiable`] evaluated against the identical inputs
246/// [`generate_diagnostics_from_cache`] uses, so this action can never appear without —
247/// or be missing despite — the diagnostic it resolves.
248///
249/// Targets `versions.cached[..].latest`, the **cached** value, not a freshly fetched one:
250/// that is the exact value the diagnostic's "latest is X" message names, so the action's
251/// title and the diagnostic text agree on what "the latest" is. A display item further
252/// down in [`generate_code_actions`] built from the same call's *live* registry response
253/// can disagree with a stale cache, and that is accepted deliberately — see that
254/// function's doc comment.
255///
256/// The rewritten requirement is re-checked with the same predicate before the action is
257/// returned (`format_version_replacing` is overridden by `deps-pypi` and `deps-gradle` to
258/// preserve operator style, which can leave a rewritten range still unsatisfiable). This
259/// is best-effort, not a proof: [`requirement_is_unsatisfiable`] also returns `false` for
260/// a rewrite this ecosystem's matcher cannot evaluate at all (uncompilable or unresolved),
261/// so an unverifiable rewrite passes through unrejected. That is the safe direction — a
262/// rewrite this scan cannot judge is not evidence it is bad — but it means the guard holds
263/// only "for every rewrite the ecosystem can evaluate", not unconditionally.
264fn build_unsatisfiable_fix_action(
265    dep: &dyn Dependency,
266    uri: &Uri,
267    version_range: Range,
268    versions: VersionData<'_>,
269    version_req: &VersionReq,
270    formatter: &dyn EcosystemFormatter,
271) -> Option<UnsatisfiableFixAction> {
272    if !formatter.can_resolve_source(&dep.source()) {
273        return None;
274    }
275
276    let normalized_name = formatter.normalize_package_name(dep.name());
277    let package_versions = versions
278        .cached
279        .get(normalized_name.as_str())
280        .or_else(|| versions.cached.get(dep.name()))?;
281
282    if !requirement_is_unsatisfiable(formatter, version_req, &package_versions.available) {
283        return None;
284    }
285
286    let latest = package_versions.latest.clone();
287    if !is_safe_version_string(latest.as_str()) {
288        warn_rejected_value(
289            "is_safe_version_string",
290            "unsatisfiable-requirement fix code action",
291            latest.as_str(),
292        );
293        return None;
294    }
295    let new_text = formatter.format_version_replacing_for(dep, &latest, version_req.as_str());
296
297    // Mirrors `build_vulnerability_fix_action`'s N1 guard: compares against
298    // `dep.version_literal()` rather than `version_req` when the ecosystem provides one,
299    // so a synthesized comparator requirement doesn't mask a genuine no-op edit.
300    let literal_target = dep.version_literal().unwrap_or(version_req.as_str());
301    if strip_whitespace(literal_target) == strip_whitespace(&new_text) {
302        return None;
303    }
304
305    let verification_req = VersionReq::new(new_text.clone());
306    if requirement_is_unsatisfiable(formatter, &verification_req, &package_versions.available) {
307        return None;
308    }
309
310    let edits = single_file_edit(uri, version_range, new_text.clone());
311
312    Some(UnsatisfiableFixAction {
313        version_native: latest.to_string(),
314        new_text,
315        action: CodeAction {
316            title: format!("Fix unsatisfiable requirement: update to {latest}"),
317            kind: Some(CodeActionKind::QUICKFIX),
318            edit: Some(WorkspaceEdit {
319                changes: Some(edits),
320                ..Default::default()
321            }),
322            is_preferred: None,
323            // Same payload shape `build_vulnerability_fix_action` stashes — see that
324            // function's doc comment. This diagnostic has no per-instance id, so
325            // `diagnostic_codes` names the shared constant instead.
326            data: Some(serde_json::json!({
327                "diagnostic_codes": [UNSATISFIABLE_DIAGNOSTIC_CODE],
328                "diagnostic_range": version_range,
329            })),
330            ..Default::default()
331        },
332    })
333}
334
335/// Builds the "Replace with X" package-rename quickfix for `dep` (issue #205), if this
336/// ecosystem opts in via [`EcosystemFormatter::supports_package_rename`] and a
337/// registry-supplied replacement name is on record.
338///
339/// **Composer-only in Phase 1** — see `EcosystemFormatter::supports_package_rename`'s
340/// docs for why this must not be enabled for an ecosystem whose replacement name is
341/// regex-extracted free text (a typosquatting vector).
342///
343/// D7(a): guarded by the same literal-span discipline `generate_code_actions` applies to
344/// `version_range` — several parsers (Composer's `find_positions` included, when a
345/// legal escaped-solidus key like `"vendor\/package"` never matches the raw-text search)
346/// fall back to `Range::default()` for `name_range` on a lookup miss. Comparing the
347/// slice at `name_range` against `dep.name()` rejects that sentinel automatically: an
348/// empty (or wrong) slice never equals the declared name, so no edit is ever written at
349/// `(0,0)`.
350fn build_replacement_action(
351    dep: &dyn Dependency,
352    uri: &Uri,
353    version_range: Range,
354    versions: VersionData<'_>,
355    content: &str,
356    line_offsets: &LineOffsetTable,
357    formatter: &dyn EcosystemFormatter,
358) -> Option<CodeAction> {
359    if !formatter.supports_package_rename() {
360        return None;
361    }
362
363    let normalized_name = formatter.normalize_package_name(dep.name());
364    let replacement = versions
365        .outcomes
366        .and_then(|o| o.deprecation(&normalized_name))
367        .and_then(|dep_info| dep_info.replacement.as_deref())
368        .filter(|r| !r.is_empty())?;
369
370    if formatter.validate_package_name(replacement).is_err() {
371        return None;
372    }
373
374    let name_range = dep.name_range();
375    let name_slice = slice_for_range(content, line_offsets, name_range);
376    // I7: reuses `literal_span_matches` rather than a name-specific equality check purely
377    // for the sentinel-rejecting behavior its whitespace-insensitive comparison already
378    // gives (see D7(a)'s doc above). Its `[{slice}] == requirement` NuGet-bracket branch
379    // is inert here — a package name never contains `[`/`]` (rejected by every
380    // ecosystem's `validate_package_name`) — so it never changes this call's outcome.
381    if !literal_span_matches(name_slice, dep.name().as_str()) {
382        return None;
383    }
384
385    let edits = single_file_edit(uri, name_range, replacement.to_string());
386
387    Some(CodeAction {
388        title: format!("Replace with {replacement}"),
389        kind: Some(CodeActionKind::QUICKFIX),
390        edit: Some(WorkspaceEdit {
391            changes: Some(edits),
392            ..Default::default()
393        }),
394        is_preferred: None,
395        // Same binding mechanism `build_vulnerability_fix_action`/`build_unsatisfiable_fix_action`
396        // use: `diagnostic_range` names D4's deprecation-diagnostic range (`version_range`,
397        // not `name_range`) so `bind_diagnostics` attaches this action to the diagnostic the
398        // client's lightbulb gesture actually surfaces.
399        data: Some(serde_json::json!({
400            "diagnostic_codes": [DEPRECATED_DIAGNOSTIC_CODE],
401            "diagnostic_range": version_range,
402        })),
403        ..Default::default()
404    })
405}
406
407/// Generates the code actions offered for the dependency at `position`.
408///
409/// Finds the dependency whose declared version `position` falls on
410/// (`formatter.is_position_on_dependency`). Returns an empty `Vec`
411/// immediately if no dependency is at `position`, it has no `version_range`
412/// to edit, it has no declared (or an empty) `version_requirement`, or the
413/// **literal-span guard** rejects it — `content` sliced over `version_range`
414/// no longer holds the literal text (see `literal_span_matches`, compared
415/// against [`Dependency::version_literal`] when the ecosystem provides one,
416/// falling back to `version_requirement` otherwise — e.g. a Maven
417/// `${property}` reference or a Gradle DSL variable/alias). Writing a
418/// `TextEdit` at that range would corrupt the manifest instead of fixing it,
419/// so this mirrors the guard `collect_update_all_edits` already applies on
420/// the bulk-edit path, and gates every kind of action below since any of
421/// them could write there.
422///
423/// Otherwise returns up to three kinds of action, in this order:
424///
425/// 1. At most one `QUICKFIX` "fix vulnerability" action, if `versions`
426///    carries an OSV scan result flagging this dependency,
427///    [`crate::osv::DependencyVulnerabilities::recommended_fix`] has a
428///    claimable target F, and F's own `fix_target_status` (populated by
429///    `deps-lsp`'s phase B, #462) clears it as a verified fix — see the
430///    private `build_vulnerability_fix_action` helper just above. This action
431///    is computed entirely from `versions` and the dependency's declared
432///    requirement, deliberately *before* the `registry.get_versions` call
433///    below — a *registry* outage must never hide a known-vulnerable
434///    dependency's fix (FR-007), so this action is still returned even when
435///    the registry fetch that produces the plain list below fails. This does
436///    not extend to an *OSV* outage or unavailability: F's verification is an
437///    OSV-derived precondition (`fix_target_status`), not a registry one, so
438///    an OSV outage — or a code-action request racing ahead of phase B
439///    completing — degrades to omitting this action entirely rather than
440///    presenting an unverified F as verified (FR-004, fail-safe). When the
441///    registry fetch does succeed, a fix target the registry reports as
442///    yanked is dropped rather than offered.
443/// 2. At most one `QUICKFIX` "fix unsatisfiable requirement" action, computed the same
444///    registry-independent way (see the private `build_unsatisfiable_fix_action` helper
445///    just above) for the same FR-007 reason. If its rewritten text collides with the
446///    vulnerability fix's (both yank-filtered first), it is dropped in favor of the
447///    vulnerability fix, whose title is the more informative of the two.
448/// 3. Up to five plain `REFACTOR` "update to `<version>`" actions, one per
449///    non-yanked version [`crate::completion::prepare_version_display_items`]
450///    selects from the registry response. Each action's edit text
451///    comes from [`crate::lsp_helpers::PackageRendering::format_version_replacing`], which
452///    preserves the manifest's existing pin/operator style where an
453///    ecosystem overrides it (e.g. PyPI's `==1.0.1` stays `==1.0.2` rather
454///    than expanding to a `>=,<` range). Every entry's formatted edit text is
455///    checked against a running set seeded with the declared requirement and
456///    the two fix actions' own formatted text (whitespace-insensitive); an entry
457///    is skipped, and never added to the set, when its text is already
458///    present. This is the common case, not a rare edge case:
459///    [`crate::completion::prepare_version_display_items`] lists the top 5
460///    non-yanked registry versions newest-first, so whenever the declared
461///    version is already within 5 releases of latest, it is itself one of
462///    the display items being offered as an "update". The same set also
463///    catches two display items whose formatted text coincides — e.g. an
464///    ecosystem formatter that truncates precision (PyPI's
465///    `truncate_release_to_match`) can map several distinct registry
466///    versions to the same rewritten text — and a display item matching a
467///    fix action's target even when their *raw* versions differ (formatting
468///    can normalize two distinct inputs to the same text). Textual (not
469///    semantic) equality is deliberate: `formatter.is_requirement_up_to_date`
470///    answers "does `latest` already satisfy this requirement", which is
471///    true for e.g. `is_requirement_up_to_date("^1.0", "1.2.0")` and would
472///    wrongly suppress every explicit-bump action for a range-style
473///    requirement; it also can't detect a pinned no-op like `==1.0.0` ->
474///    `==1.0.0`, since it never compares the formatted edit text at all.
475///
476/// Every action above is built with `is_preferred: None`; exactly one is promoted to
477/// `Some(true)` in a single post-pass once all three kinds have been considered, in
478/// priority order: the vulnerability fix, then the unsatisfiable fix, then the REFACTOR
479/// item whose `item.is_latest` is set. LSP's `isPreferred` is a flat per-response boolean
480/// with no per-diagnostic scoping, so "at most one preferred action" must hold across all
481/// producers, not per producer — building every action with `None` and resolving the flag
482/// once here, rather than at each construction site, is what keeps that invariant
483/// structural (checkable with one `filter().count() <= 1` assertion) instead of something
484/// every future producer has to remember to uphold by hand. A vulnerability is silent and
485/// security-relevant; an unsatisfiable requirement is loud (the package manager already
486/// fails the build) but merely inconvenient; both outrank a routine "update to latest".
487/// This resolution runs on every return path below that can carry a fix action, including
488/// the registry-outage path, so an outage never silently drops `isPreferred` from an
489/// already-built fix.
490///
491/// Returns an empty `Vec` also when no fix action applies and the registry fetch fails.
492///
493/// No `# Examples` here: exercising this meaningfully needs a `Registry`
494/// impl plus `ParseResult`/`Dependency` mocks, which live as private test
495/// fixtures in the sibling `test_support` module rather than as public
496/// API — see the `generate_code_actions_*` tests here for realistic calls.
497pub async fn generate_code_actions<R: Registry + ?Sized>(
498    parse_result: &dyn ParseResult,
499    position: Position,
500    uri: &Uri,
501    versions: VersionData<'_>,
502    content: &str,
503    registry: &R,
504    formatter: &dyn EcosystemFormatter,
505) -> Vec<CodeAction> {
506    use crate::completion::prepare_version_display_items;
507
508    let deps = parse_result.dependencies();
509    let mut actions = Vec::with_capacity(deps.len().min(5) + 1);
510
511    let Some(dep) = deps
512        .into_iter()
513        .find(|d| formatter.is_position_on_dependency(*d, position))
514    else {
515        return actions;
516    };
517
518    let Some(version_range) = dep.version_range() else {
519        return actions;
520    };
521
522    let Some(version_req) = dep.version_requirement() else {
523        return actions;
524    };
525    if version_req.as_str().is_empty() {
526        // Defense-in-depth, mirroring `collect_update_all_edits`: an empty
527        // requirement would trivially satisfy the guard below.
528        return actions;
529    }
530
531    let line_offsets = LineOffsetTable::new(content);
532    let slice = slice_for_range(content, &line_offsets, version_range);
533    let literal_target = dep
534        .version_literal()
535        .unwrap_or_else(|| version_req.as_str());
536    if !literal_span_matches(slice, literal_target) {
537        // `version_range` no longer slices to the declared literal text (e.g. a Maven
538        // `${property}` or a Gradle DSL variable/alias) — writing a TextEdit there would
539        // corrupt the manifest instead of fixing it. Mirrors the guard
540        // `collect_update_all_edits` already applies on the bulk-edit path. Compares
541        // against `dep.version_literal()` rather than `version_req` when the ecosystem
542        // provides one (see that method's doc) — an ecosystem that synthesizes its
543        // requirement from a bare literal (e.g. `deps-swift`) would otherwise always fail
544        // this guard even though `version_range` correctly spans the literal.
545        return actions;
546    }
547
548    // Both fix actions are built before the registry fetch below so a registry outage
549    // never suppresses an OSV-derived fix (FR-007) or a known-unsatisfiable one.
550    let fix = build_vulnerability_fix_action(
551        parse_result,
552        dep,
553        uri,
554        version_range,
555        versions,
556        version_req.as_str(),
557        formatter,
558    );
559    let unsat_fix =
560        build_unsatisfiable_fix_action(dep, uri, version_range, versions, version_req, formatter);
561    // Registry-independent for the same FR-007 reason as the two fix actions above —
562    // `versions.outcomes`' deprecation channel is cache-derived, not a fresh registry call.
563    let replacement_action = build_replacement_action(
564        dep,
565        uri,
566        version_range,
567        versions,
568        content,
569        &line_offsets,
570        formatter,
571    );
572
573    // Gated on `can_resolve_source` (#248/FR-001): a Git/Path/unresolved-custom-registry
574    // dependency must never have its name looked up against this ecosystem's default
575    // registry client, which would silently check an unrelated or coincidentally-named
576    // package and offer bogus "update to X" actions for it. `FreshnessSettings::enabled:
577    // false` preserves this call's original `Registry::get_versions` behavior exactly (no
578    // publish-time enrichment) now that it is routed through the freshness-aware
579    // `get_versions_from`.
580    let dep_source = dep.source();
581    let registry_versions = if formatter.can_resolve_source(&dep_source) {
582        registry
583            .get_versions_from(
584                dep.name(),
585                &dep_source,
586                crate::freshness::FreshnessSettings {
587                    enabled: false,
588                    ..Default::default()
589                },
590            )
591            .await
592            .ok()
593    } else {
594        None
595    };
596
597    // A fix target that the registry reports as yanked is dropped entirely rather than
598    // offered — the surviving diagnostics carry the finding either way, and there is no
599    // comparator here to bound a search for an alternative target. On a registry outage
600    // (`registry_versions` is `None`) there is nothing to check a yank flag against, so
601    // both actions pass through unfiltered — the pre-existing vuln-fix behavior, now
602    // shared by the unsat fix too.
603    let is_yanked_target = |version_native: &str| {
604        registry_versions.as_ref().is_some_and(|versions_list| {
605            versions_list
606                .iter()
607                .find(|v| v.version_string() == version_native)
608                .is_some_and(|v| v.removal_status().blocks_resolution())
609        })
610    };
611    let fix = fix.filter(|f| !is_yanked_target(&f.version_native));
612    let unsat_fix = unsat_fix.filter(|f| !is_yanked_target(&f.version_native));
613
614    // Yank-filtering both actions before this collision check (not after) matters: PyPI's
615    // `truncate_release_to_match` can map a yanked version and a live one to identical
616    // rewritten text, and checking collision first would drop the unsat action for a text
617    // match against a vuln fix that the yank filter above was about to drop anyway,
618    // leaving neither action behind.
619    let unsat_fix = unsat_fix.filter(|u| {
620        fix.as_ref()
621            .is_none_or(|f| strip_whitespace(&f.new_text) != strip_whitespace(&u.new_text))
622    });
623
624    // Captured before each action's `.action` moves into `actions` below, so the dedup
625    // seeding and the `is_preferred` post-pass read back the exact text/index without
626    // recomputing or re-deriving them (see `VulnerabilityFixAction::new_text`'s doc comment).
627    let fix_new_text = fix.as_ref().map(|f| strip_whitespace(&f.new_text));
628    let unsat_new_text = unsat_fix.as_ref().map(|f| strip_whitespace(&f.new_text));
629
630    let mut vuln_idx = None;
631    if let Some(fix) = fix {
632        vuln_idx = Some(actions.len());
633        actions.push(fix.action);
634    }
635    let mut unsat_idx = None;
636    if let Some(unsat_fix) = unsat_fix {
637        unsat_idx = Some(actions.len());
638        actions.push(unsat_fix.action);
639    }
640    if let Some(replacement_action) = replacement_action {
641        actions.push(replacement_action);
642    }
643
644    // De-duplicates every REFACTOR action's formatted edit text against the declared
645    // literal text, both fix actions' edits (if present), and every REFACTOR action already
646    // emitted below, so no two actions in the response — nor a REFACTOR action and a fix
647    // action above — ever carry a byte-identical `WorkspaceEdit`. Seeding with the
648    // declared literal text subsumes the former N1 guard (an item whose formatted text
649    // equals the declared text is a no-op); checking formatted text rather than raw
650    // version also subsumes the former `item.version == fix_version_native` check, since
651    // `format_version_replacing` is deterministic in its inputs. Whitespace-insensitive,
652    // matching every other no-op guard in this crate (see `strip_whitespace`). Seeded with
653    // `literal_target` (not `version_req`) for the same reason the guard above compares
654    // against it: for an ecosystem synthesizing its requirement from a bare literal (e.g.
655    // `deps-swift`), `version_req` never equals the formatted edit text even when the edit
656    // genuinely is a no-op (`.exact("2.61.0")` declares `version_req` `"=2.61.0"`, but the
657    // manifest text — and any freshly-formatted "update to 2.61.0" text — is `"2.61.0"`).
658    let mut emitted_texts: HashSet<String> = HashSet::new();
659    emitted_texts.insert(strip_whitespace(literal_target));
660    if let Some(fix_text) = fix_new_text {
661        emitted_texts.insert(fix_text);
662    }
663    if let Some(unsat_text) = unsat_new_text {
664        emitted_texts.insert(unsat_text);
665    }
666
667    let mut latest_refactor_idx = None;
668    if let Some(registry_versions) = &registry_versions {
669        let display_items = prepare_version_display_items(registry_versions, dep.name());
670        for item in display_items {
671            if !is_safe_version_string(item.version.as_str()) {
672                warn_rejected_value(
673                    "is_safe_version_string",
674                    "update-to-version refactor code action",
675                    item.version.as_str(),
676                );
677                continue;
678            }
679            let new_text =
680                formatter.format_version_replacing_for(dep, &item.version, version_req.as_str());
681
682            if !emitted_texts.insert(strip_whitespace(&new_text)) {
683                continue;
684            }
685
686            let edits = single_file_edit(uri, version_range, new_text);
687
688            if item.is_latest {
689                latest_refactor_idx = Some(actions.len());
690            }
691            actions.push(CodeAction {
692                title: item.label,
693                kind: Some(CodeActionKind::REFACTOR),
694                edit: Some(WorkspaceEdit {
695                    changes: Some(edits),
696                    ..Default::default()
697                }),
698                // Resolved once below, for every producer at once.
699                is_preferred: None,
700                ..Default::default()
701            });
702        }
703    }
704
705    // Single post-pass resolving `isPreferred`: exactly one action, in priority order
706    // vuln fix -> unsat fix -> latest REFACTOR item. Runs unconditionally on every path
707    // through this function that can reach here, including the registry-outage path
708    // (`registry_versions.is_none()`), so an outage never drops `isPreferred` from an
709    // already-built fix action.
710    if let Some(i) = vuln_idx.or(unsat_idx).or(latest_refactor_idx) {
711        actions[i].is_preferred = Some(true);
712    }
713
714    actions
715}
716
717#[cfg(test)]
718mod tests {
719    use super::*;
720    use crate::lsp_helpers::test_support::*;
721    use crate::lsp_helpers::*;
722    use crate::{Dependency, PackageName, VersionReq};
723    use std::any::Any;
724    use std::collections::HashMap;
725    use std::sync::Arc;
726
727    #[tokio::test]
728    async fn test_generate_code_actions_combines_advisories_sharing_the_highest_fix() {
729        use crate::osv::{
730            Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
731        };
732        use std::collections::HashMap;
733
734        let (dep, version_range, content) = vulnerable_dep("1.0.0");
735        let parse_result = MockParseResult {
736            deps: vec![dep],
737            uri: crate::test_util::test_uri("/test/Cargo.toml"),
738        };
739
740        let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
741        vulnerabilities.insert(
742            "pkg".to_string(),
743            ScanOutcome::Vulnerable(DependencyVulnerabilities {
744                advisories: Capped::new(
745                    vec![
746                        std::sync::Arc::new(Advisory {
747                            id: "A1".to_string(),
748                            modified: "2023-01-01T00:00:00Z".to_string(),
749                            summary: None,
750                            aliases: vec![],
751                            severity: VulnSeverity::High,
752                            cvss_vector: None,
753                            fixed_versions: vec!["1.1.0".to_string()],
754                            url: String::new(),
755                        }),
756                        std::sync::Arc::new(Advisory {
757                            id: "A2".to_string(),
758                            modified: "2023-01-01T00:00:00Z".to_string(),
759                            summary: None,
760                            aliases: vec![],
761                            severity: VulnSeverity::Critical,
762                            cvss_vector: None,
763                            fixed_versions: vec!["1.2.0".to_string()],
764                            url: String::new(),
765                        }),
766                    ],
767                    2,
768                ),
769                fix_target_status: UpgradeStatus::CandidateClean {
770                    version: "1.2.0".to_string(),
771                },
772                upgrade_status: UpgradeStatus::NotChecked,
773            }),
774        );
775
776        let cached = HashMap::new();
777        let resolved = HashMap::new();
778        let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
779
780        let actions = generate_code_actions(
781            &parse_result,
782            version_range.start,
783            parse_result.uri(),
784            versions,
785            &content,
786            &MockRegistry,
787            &MockFormatter,
788        )
789        .await;
790
791        let titles = quickfix_titles(&actions);
792        assert_eq!(titles, vec!["Update to 1.2.0 (fixes A2 +1 more)"]);
793        assert_eq!(actions[0].kind, Some(CodeActionKind::QUICKFIX));
794        assert_eq!(actions[0].is_preferred, Some(true));
795        // The full id list still travels in `data` for the diagnostics
796        // binding, even though the title only names the first one.
797        assert_eq!(
798            actions[0].data,
799            Some(serde_json::json!({
800                "diagnostic_codes": ["A2", "A1"],
801                "diagnostic_range": version_range,
802            }))
803        );
804    }
805
806    #[tokio::test]
807    async fn test_generate_code_actions_fix_target_is_not_inflated_by_a_subtracted_advisory() {
808        // Critic S1 counterexample: A1 is fixed at a high version (3.0.0) but
809        // phase B reports it still applies at the checked candidate, so it is
810        // excluded from the claim. A2 is fixed at a much lower version
811        // (1.2.0) and is claimed. The recommended target must be 1.2.0 — the
812        // version that clears what is actually claimed — not 3.0.0, which
813        // would push the user across an unnecessary major-version boundary
814        // for a fix A1 that version does not even resolve.
815        //
816        // #462 critic S1: `fix_target_status` is deliberately `CandidateVulnerable{1.2.0,
817        // [A1]}`, not `CandidateClean` — the state a real live-check of F=1.2.0 would
818        // actually produce here, since A1 (known, fixed only at 3.0.0 > 1.2.0) still
819        // applies. Because A1 was already excluded from `claimed` (it is not in
820        // `fix.advisory_ids`), this must still be presented as a fix for A2 — the honest
821        // #216 partial-fix contract `fix_target_is_verified` preserves.
822        use crate::osv::{
823            Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
824        };
825        use std::collections::HashMap;
826
827        let (dep, version_range, content) = vulnerable_dep("1.0.0");
828        let parse_result = MockParseResult {
829            deps: vec![dep],
830            uri: crate::test_util::test_uri("/test/Cargo.toml"),
831        };
832
833        let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
834        vulnerabilities.insert(
835            "pkg".to_string(),
836            ScanOutcome::Vulnerable(DependencyVulnerabilities {
837                advisories: Capped::new(
838                    vec![
839                        std::sync::Arc::new(Advisory {
840                            id: "A1".to_string(),
841                            modified: "2023-01-01T00:00:00Z".to_string(),
842                            summary: None,
843                            aliases: vec![],
844                            severity: VulnSeverity::High,
845                            cvss_vector: None,
846                            fixed_versions: vec!["3.0.0".to_string()],
847                            url: String::new(),
848                        }),
849                        std::sync::Arc::new(Advisory {
850                            id: "A2".to_string(),
851                            modified: "2023-01-01T00:00:00Z".to_string(),
852                            summary: None,
853                            aliases: vec![],
854                            severity: VulnSeverity::Medium,
855                            cvss_vector: None,
856                            fixed_versions: vec!["1.2.0".to_string()],
857                            url: String::new(),
858                        }),
859                    ],
860                    2,
861                ),
862                fix_target_status: UpgradeStatus::CandidateVulnerable {
863                    version: "1.2.0".to_string(),
864                    advisory_ids: Capped::new(vec!["A1".to_string()], 1),
865                },
866                upgrade_status: UpgradeStatus::CandidateVulnerable {
867                    version: "3.0.0".to_string(),
868                    advisory_ids: Capped::new(vec!["A1".to_string()], 1),
869                },
870            }),
871        );
872
873        let cached = HashMap::new();
874        let resolved = HashMap::new();
875        let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
876
877        let actions = generate_code_actions(
878            &parse_result,
879            version_range.start,
880            parse_result.uri(),
881            versions,
882            &content,
883            &MockRegistry,
884            &MockFormatter,
885        )
886        .await;
887
888        let titles = quickfix_titles(&actions);
889        assert_eq!(titles, vec!["Update to 1.2.0 (fixes A2)"]);
890    }
891
892    #[tokio::test]
893    async fn test_generate_code_actions_fix_target_is_not_suppressed_by_an_open_ended_advisory() {
894        // #462 critic re-critique: the sibling S1 scenario the fix exists to preserve — a
895        // dependency with an *open-ended* advisory (A1, no known fix at all) must not lose its
896        // quickfix. `recommended_fix()` filters A1 out of `claimed` for having no
897        // `fixed_versions` (a *different* exclusion path than the still-applying-at-latest
898        // subtraction the `..._subtracted_advisory` test above exercises), so F is computed
899        // from A2 alone (1.2.0) and `fix.advisory_ids = ["A2"]`. The live check of F=1.2.0
900        // correctly reports A1 still applies (it was never fixed) — A1 is known and unclaimed,
901        // so this must still be presented as a fix for A2, the honest #216 partial-fix contract.
902        use crate::osv::{
903            Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
904        };
905        use std::collections::HashMap;
906
907        let (dep, version_range, content) = vulnerable_dep("1.0.0");
908        let parse_result = MockParseResult {
909            deps: vec![dep],
910            uri: crate::test_util::test_uri("/test/Cargo.toml"),
911        };
912
913        let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
914        vulnerabilities.insert(
915            "pkg".to_string(),
916            ScanOutcome::Vulnerable(DependencyVulnerabilities {
917                advisories: Capped::new(
918                    vec![
919                        std::sync::Arc::new(Advisory {
920                            id: "A1".to_string(),
921                            modified: "2023-01-01T00:00:00Z".to_string(),
922                            summary: None,
923                            aliases: vec![],
924                            severity: VulnSeverity::High,
925                            cvss_vector: None,
926                            fixed_versions: vec![],
927                            url: String::new(),
928                        }),
929                        std::sync::Arc::new(Advisory {
930                            id: "A2".to_string(),
931                            modified: "2023-01-01T00:00:00Z".to_string(),
932                            summary: None,
933                            aliases: vec![],
934                            severity: VulnSeverity::Medium,
935                            cvss_vector: None,
936                            fixed_versions: vec!["1.2.0".to_string()],
937                            url: String::new(),
938                        }),
939                    ],
940                    2,
941                ),
942                fix_target_status: UpgradeStatus::CandidateVulnerable {
943                    version: "1.2.0".to_string(),
944                    advisory_ids: Capped::new(vec!["A1".to_string()], 1),
945                },
946                upgrade_status: UpgradeStatus::NotChecked,
947            }),
948        );
949
950        let cached = HashMap::new();
951        let resolved = HashMap::new();
952        let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
953
954        let actions = generate_code_actions(
955            &parse_result,
956            version_range.start,
957            parse_result.uri(),
958            versions,
959            &content,
960            &MockRegistry,
961            &MockFormatter,
962        )
963        .await;
964
965        let titles = quickfix_titles(&actions);
966        assert_eq!(titles, vec!["Update to 1.2.0 (fixes A2)"]);
967    }
968
969    #[tokio::test]
970    async fn test_generate_code_actions_omits_fix_when_fix_target_status_reports_still_vulnerable()
971    {
972        // #462 critic C1 repro shape (smallvec 0.6.0 -> F=0.6.13, still affected by
973        // RUSTSEC-2021-0003, an advisory phase A's declared-version-only query never saw):
974        // `recommended_fix()` computes a claim (A1, fixed at 1.2.0) from the only advisory
975        // this dependency's `advisories` records, but the live verification of F reports a
976        // *different* id (A2) still applies — one this dependency's `advisories` never
977        // recorded at all. Unlike an already-known-and-excluded advisory (see the
978        // `..._subtracted_advisory` test above), a brand-new unknown advisory must always
979        // suppress the fix: it is not the honest #216 partial-fix case, it is exactly the
980        // gap #462 exists to close.
981        use crate::osv::{
982            Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
983        };
984        use std::collections::HashMap;
985
986        let (dep, version_range, content) = vulnerable_dep("1.0.0");
987        let parse_result = MockParseResult {
988            deps: vec![dep],
989            uri: crate::test_util::test_uri("/test/Cargo.toml"),
990        };
991
992        let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
993        vulnerabilities.insert(
994            "pkg".to_string(),
995            ScanOutcome::Vulnerable(DependencyVulnerabilities {
996                advisories: Capped::new(
997                    vec![std::sync::Arc::new(Advisory {
998                        id: "A1".to_string(),
999                        modified: "2023-01-01T00:00:00Z".to_string(),
1000                        summary: None,
1001                        aliases: vec![],
1002                        severity: VulnSeverity::High,
1003                        cvss_vector: None,
1004                        fixed_versions: vec!["1.2.0".to_string()],
1005                        url: String::new(),
1006                    })],
1007                    1,
1008                ),
1009                fix_target_status: UpgradeStatus::CandidateVulnerable {
1010                    version: "1.2.0".to_string(),
1011                    advisory_ids: Capped::new(vec!["A2".to_string()], 1),
1012                },
1013                upgrade_status: UpgradeStatus::NotChecked,
1014            }),
1015        );
1016
1017        let cached = HashMap::new();
1018        let resolved = HashMap::new();
1019        let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1020
1021        let actions = generate_code_actions(
1022            &parse_result,
1023            version_range.start,
1024            parse_result.uri(),
1025            versions,
1026            &content,
1027            &MockRegistry,
1028            &MockFormatter,
1029        )
1030        .await;
1031
1032        assert!(
1033            quickfix_titles(&actions).is_empty(),
1034            "a fix target OSV found still vulnerable must never be presented as a verified fix"
1035        );
1036    }
1037
1038    #[tokio::test]
1039    async fn test_generate_code_actions_omits_fix_when_fix_target_status_reports_a_claimed_advisory_still_applies()
1040     {
1041        // #462 FR-003, the other half of the S1 fix: unlike an already-excluded known
1042        // advisory, a live check reporting that a *claimed* advisory (one `fix.advisory_ids`
1043        // actually names) still applies to F means the fix doesn't do what its own title
1044        // says — this must suppress the action even though the id is known, distinguishing
1045        // it from the `..._subtracted_advisory` test's honest-partial-fix case.
1046        use crate::osv::{
1047            Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1048        };
1049        use std::collections::HashMap;
1050
1051        let (dep, version_range, content) = vulnerable_dep("1.0.0");
1052        let parse_result = MockParseResult {
1053            deps: vec![dep],
1054            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1055        };
1056
1057        let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1058        vulnerabilities.insert(
1059            "pkg".to_string(),
1060            ScanOutcome::Vulnerable(DependencyVulnerabilities {
1061                advisories: Capped::new(
1062                    vec![std::sync::Arc::new(Advisory {
1063                        id: "A1".to_string(),
1064                        modified: "2023-01-01T00:00:00Z".to_string(),
1065                        summary: None,
1066                        aliases: vec![],
1067                        severity: VulnSeverity::High,
1068                        cvss_vector: None,
1069                        fixed_versions: vec!["1.2.0".to_string()],
1070                        url: String::new(),
1071                    })],
1072                    1,
1073                ),
1074                // A1 is claimed (it's the only advisory, with a known fix, and nothing
1075                // excludes it), yet the live check of F=1.2.0 reports A1 itself still
1076                // applies — a claim the verification actually contradicts.
1077                fix_target_status: UpgradeStatus::CandidateVulnerable {
1078                    version: "1.2.0".to_string(),
1079                    advisory_ids: Capped::new(vec!["A1".to_string()], 1),
1080                },
1081                upgrade_status: UpgradeStatus::NotChecked,
1082            }),
1083        );
1084
1085        let cached = HashMap::new();
1086        let resolved = HashMap::new();
1087        let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1088
1089        let actions = generate_code_actions(
1090            &parse_result,
1091            version_range.start,
1092            parse_result.uri(),
1093            versions,
1094            &content,
1095            &MockRegistry,
1096            &MockFormatter,
1097        )
1098        .await;
1099
1100        assert!(
1101            quickfix_titles(&actions).is_empty(),
1102            "a fix must never claim to resolve an advisory the live check found it doesn't"
1103        );
1104    }
1105
1106    #[tokio::test]
1107    async fn test_generate_code_actions_omits_fix_when_fix_target_status_advisory_ids_are_truncated()
1108     {
1109        // #462 critic M1: `check_candidates` caps `advisory_ids` at `ADVISORY_DISPLAY_CAP` the
1110        // same way `DependencyVulnerabilities::advisories` is capped, so a `CandidateVulnerable`
1111        // whose `advisory_ids` is shorter than `total_known` is NOT the complete list of
1112        // advisories still affecting F — one of the truncated-away ids could be the claimed or
1113        // unknown one that should suppress this fix. Here the single reported id (A2) is known
1114        // and unclaimed — which the pre-M1-fix gate would have accepted — but `total_known: 2`
1115        // proves a second, unreported advisory exists, so this must still be rejected rather
1116        // than trusting a partial list as if it were exhaustive.
1117        use crate::osv::{
1118            Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1119        };
1120        use std::collections::HashMap;
1121
1122        let (dep, version_range, content) = vulnerable_dep("1.0.0");
1123        let parse_result = MockParseResult {
1124            deps: vec![dep],
1125            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1126        };
1127
1128        let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1129        vulnerabilities.insert(
1130            "pkg".to_string(),
1131            ScanOutcome::Vulnerable(DependencyVulnerabilities {
1132                advisories: Capped::new(
1133                    vec![
1134                        std::sync::Arc::new(Advisory {
1135                            id: "A1".to_string(),
1136                            modified: "2023-01-01T00:00:00Z".to_string(),
1137                            summary: None,
1138                            aliases: vec![],
1139                            severity: VulnSeverity::High,
1140                            cvss_vector: None,
1141                            fixed_versions: vec!["1.2.0".to_string()],
1142                            url: String::new(),
1143                        }),
1144                        std::sync::Arc::new(Advisory {
1145                            id: "A2".to_string(),
1146                            modified: "2023-01-01T00:00:00Z".to_string(),
1147                            summary: None,
1148                            aliases: vec![],
1149                            severity: VulnSeverity::Medium,
1150                            cvss_vector: None,
1151                            fixed_versions: vec![],
1152                            url: String::new(),
1153                        }),
1154                    ],
1155                    2,
1156                ),
1157                // Only A2 is reported (known, unclaimed — A1 is the sole claimed advisory),
1158                // but `total_known: 2` says the live check actually found 2 advisories still
1159                // affecting F; the second one was truncated out of `advisory_ids` and could be
1160                // anything, including the still-applying A1 itself.
1161                fix_target_status: UpgradeStatus::CandidateVulnerable {
1162                    version: "1.2.0".to_string(),
1163                    advisory_ids: Capped::new(vec!["A2".to_string()], 2),
1164                },
1165                upgrade_status: UpgradeStatus::NotChecked,
1166            }),
1167        );
1168
1169        let cached = HashMap::new();
1170        let resolved = HashMap::new();
1171        let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1172
1173        let actions = generate_code_actions(
1174            &parse_result,
1175            version_range.start,
1176            parse_result.uri(),
1177            versions,
1178            &content,
1179            &MockRegistry,
1180            &MockFormatter,
1181        )
1182        .await;
1183
1184        assert!(
1185            quickfix_titles(&actions).is_empty(),
1186            "a truncated advisory_ids list must never be trusted as exhaustive"
1187        );
1188    }
1189
1190    #[tokio::test]
1191    async fn test_generate_code_actions_omits_fix_when_fix_target_status_is_unresolved() {
1192        // #462 FR-004/NFR-002: `fix_target_status: NotChecked` covers both "verification never
1193        // ran yet" and "verification timed out" — either way, an unverified F must never be
1194        // offered as a fix, the fail-safe default (no "unverified" qualifier, just omission).
1195        use crate::osv::{
1196            Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1197        };
1198        use std::collections::HashMap;
1199
1200        let (dep, version_range, content) = vulnerable_dep("1.0.0");
1201        let parse_result = MockParseResult {
1202            deps: vec![dep],
1203            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1204        };
1205
1206        let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1207        vulnerabilities.insert(
1208            "pkg".to_string(),
1209            ScanOutcome::Vulnerable(DependencyVulnerabilities {
1210                advisories: Capped::new(
1211                    vec![std::sync::Arc::new(Advisory {
1212                        id: "A1".to_string(),
1213                        modified: "2023-01-01T00:00:00Z".to_string(),
1214                        summary: None,
1215                        aliases: vec![],
1216                        severity: VulnSeverity::High,
1217                        cvss_vector: None,
1218                        fixed_versions: vec!["1.2.0".to_string()],
1219                        url: String::new(),
1220                    })],
1221                    1,
1222                ),
1223                fix_target_status: UpgradeStatus::NotChecked,
1224                upgrade_status: UpgradeStatus::NotChecked,
1225            }),
1226        );
1227
1228        let cached = HashMap::new();
1229        let resolved = HashMap::new();
1230        let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1231
1232        let actions = generate_code_actions(
1233            &parse_result,
1234            version_range.start,
1235            parse_result.uri(),
1236            versions,
1237            &content,
1238            &MockRegistry,
1239            &MockFormatter,
1240        )
1241        .await;
1242
1243        assert!(
1244            quickfix_titles(&actions).is_empty(),
1245            "an unverified fix target must never be presented as a fix, timed out or not yet checked"
1246        );
1247    }
1248
1249    #[tokio::test]
1250    async fn test_generate_code_actions_drops_yanked_fix_target() {
1251        use crate::osv::{
1252            Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1253        };
1254        use std::collections::HashMap;
1255
1256        let (dep, version_range, content) = vulnerable_dep("1.0.0");
1257        let parse_result = MockParseResult {
1258            deps: vec![dep],
1259            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1260        };
1261
1262        let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1263        vulnerabilities.insert(
1264            "pkg".to_string(),
1265            ScanOutcome::Vulnerable(DependencyVulnerabilities {
1266                advisories: Capped::new(
1267                    vec![std::sync::Arc::new(Advisory {
1268                        id: "A1".to_string(),
1269                        modified: "2023-01-01T00:00:00Z".to_string(),
1270                        summary: None,
1271                        aliases: vec![],
1272                        severity: VulnSeverity::High,
1273                        cvss_vector: None,
1274                        fixed_versions: vec!["2.0.0".to_string()],
1275                        url: String::new(),
1276                    })],
1277                    1,
1278                ),
1279                fix_target_status: UpgradeStatus::CandidateClean {
1280                    version: "2.0.0".to_string(),
1281                },
1282                upgrade_status: UpgradeStatus::NotChecked,
1283            }),
1284        );
1285
1286        let cached = HashMap::new();
1287        let resolved = HashMap::new();
1288        let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1289        let registry = FixedVersionRegistry {
1290            versions: vec![("2.0.0", true), ("1.5.0", false)],
1291        };
1292
1293        let actions = generate_code_actions(
1294            &parse_result,
1295            version_range.start,
1296            parse_result.uri(),
1297            versions,
1298            &content,
1299            &registry,
1300            &MockFormatter,
1301        )
1302        .await;
1303
1304        assert!(quickfix_titles(&actions).is_empty());
1305        assert!(
1306            actions
1307                .iter()
1308                .any(|a| a.kind == Some(CodeActionKind::REFACTOR))
1309        );
1310    }
1311
1312    #[tokio::test]
1313    async fn test_generate_code_actions_no_op_edit_is_skipped() {
1314        use crate::osv::{
1315            Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1316        };
1317        use std::collections::HashMap;
1318
1319        // Manifest already declares exactly the fixed version.
1320        let (dep, version_range, content) = vulnerable_dep("1.2.0");
1321        let parse_result = MockParseResult {
1322            deps: vec![dep],
1323            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1324        };
1325
1326        let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1327        vulnerabilities.insert(
1328            "pkg".to_string(),
1329            ScanOutcome::Vulnerable(DependencyVulnerabilities {
1330                advisories: Capped::new(
1331                    vec![std::sync::Arc::new(Advisory {
1332                        id: "A1".to_string(),
1333                        modified: "2023-01-01T00:00:00Z".to_string(),
1334                        summary: None,
1335                        aliases: vec![],
1336                        severity: VulnSeverity::High,
1337                        cvss_vector: None,
1338                        fixed_versions: vec!["1.2.0".to_string()],
1339                        url: String::new(),
1340                    })],
1341                    1,
1342                ),
1343                fix_target_status: UpgradeStatus::NotChecked,
1344                upgrade_status: UpgradeStatus::NotChecked,
1345            }),
1346        );
1347
1348        let cached = HashMap::new();
1349        let resolved = HashMap::new();
1350        let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1351
1352        let actions = generate_code_actions(
1353            &parse_result,
1354            version_range.start,
1355            parse_result.uri(),
1356            versions,
1357            &content,
1358            &MockRegistry,
1359            &IdentityFormatter,
1360        )
1361        .await;
1362
1363        assert!(quickfix_titles(&actions).is_empty());
1364    }
1365
1366    #[tokio::test]
1367    async fn test_generate_code_actions_no_op_guard_compares_formatted_text_not_bare_version() {
1368        // Critic S3: the manifest already declares "^1.2.0" — exactly what
1369        // `CaretWrappingFormatter::format_version_for_text_edit` produces for
1370        // the fixed version "1.2.0" (mirroring `deps-dart`'s real `^{v}`
1371        // wrap). A guard comparing the bare version ("1.2.0" != "^1.2.0")
1372        // would miss this and offer a no-op edit; the guard must compare
1373        // against the formatted text instead.
1374        use crate::osv::{
1375            Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1376        };
1377        use std::collections::HashMap;
1378
1379        let (dep, version_range, content) = vulnerable_dep("^1.2.0");
1380        let parse_result = MockParseResult {
1381            deps: vec![dep],
1382            uri: crate::test_util::test_uri("/test/pubspec.yaml"),
1383        };
1384
1385        let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1386        vulnerabilities.insert(
1387            "pkg".to_string(),
1388            ScanOutcome::Vulnerable(DependencyVulnerabilities {
1389                advisories: Capped::new(
1390                    vec![std::sync::Arc::new(Advisory {
1391                        id: "A1".to_string(),
1392                        modified: "2023-01-01T00:00:00Z".to_string(),
1393                        summary: None,
1394                        aliases: vec![],
1395                        severity: VulnSeverity::High,
1396                        cvss_vector: None,
1397                        fixed_versions: vec!["1.2.0".to_string()],
1398                        url: String::new(),
1399                    })],
1400                    1,
1401                ),
1402                fix_target_status: UpgradeStatus::NotChecked,
1403                upgrade_status: UpgradeStatus::NotChecked,
1404            }),
1405        );
1406
1407        let cached = HashMap::new();
1408        let resolved = HashMap::new();
1409        let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1410
1411        let actions = generate_code_actions(
1412            &parse_result,
1413            version_range.start,
1414            parse_result.uri(),
1415            versions,
1416            &content,
1417            &MockRegistry,
1418            &CaretWrappingFormatter,
1419        )
1420        .await;
1421
1422        assert!(quickfix_titles(&actions).is_empty());
1423    }
1424
1425    #[tokio::test]
1426    async fn test_generate_code_actions_refactor_loop_skips_no_op_entry_but_keeps_real_update() {
1427        // Regression for #238: no OSV vulnerabilities are present, isolating the plain
1428        // REFACTOR loop's own no-op guard from `build_vulnerability_fix_action`'s
1429        // separate N1 guard (the two prior "no_op" tests above only exercise the latter,
1430        // since `MockRegistry` returns no versions and the REFACTOR loop body never
1431        // runs). The registry lists the already-declared version among the top-5
1432        // display items — the common case per `prepare_version_display_items`, not an
1433        // edge case — plus one genuinely newer version.
1434        let (dep, version_range, content) = vulnerable_dep("1.2.0");
1435        let parse_result = MockParseResult {
1436            deps: vec![dep],
1437            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1438        };
1439
1440        let cached = HashMap::new();
1441        let resolved = HashMap::new();
1442        let versions = VersionData::new(&cached, &resolved);
1443        let registry = FixedVersionRegistry {
1444            versions: vec![("1.2.0", false), ("1.1.0", false)],
1445        };
1446
1447        let actions = generate_code_actions(
1448            &parse_result,
1449            version_range.start,
1450            parse_result.uri(),
1451            versions,
1452            &content,
1453            &registry,
1454            &IdentityFormatter,
1455        )
1456        .await;
1457
1458        let titles = refactor_titles(&actions);
1459        assert!(
1460            !titles.iter().any(|t| t.starts_with("1.2.0")),
1461            "the already-declared version must not be offered as an update: {titles:?}"
1462        );
1463        assert!(
1464            titles.contains(&"1.1.0"),
1465            "a genuinely different version must still be offered: {titles:?}"
1466        );
1467    }
1468
1469    #[tokio::test]
1470    async fn test_generate_code_actions_refactor_loop_no_op_guard_ignores_whitespace() {
1471        // Whitespace-only divergence between the declared requirement and the
1472        // formatter's edit text must still be treated as a no-op, mirroring
1473        // `build_vulnerability_fix_action`'s N1 guard and `literal_span_matches`'s
1474        // `test_guard_accepts_whitespace_only_difference`.
1475        let (dep, version_range, content) = vulnerable_dep("1.2.0");
1476        let parse_result = MockParseResult {
1477            deps: vec![dep],
1478            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1479        };
1480
1481        let cached = HashMap::new();
1482        let resolved = HashMap::new();
1483        let versions = VersionData::new(&cached, &resolved);
1484        let registry = FixedVersionRegistry {
1485            versions: vec![("1.2.0", false)],
1486        };
1487
1488        let actions = generate_code_actions(
1489            &parse_result,
1490            version_range.start,
1491            parse_result.uri(),
1492            versions,
1493            &content,
1494            &registry,
1495            &TrailingSpaceFormatter,
1496        )
1497        .await;
1498
1499        assert!(
1500            refactor_titles(&actions).is_empty(),
1501            "a whitespace-only edit-text divergence must still be skipped as a no-op"
1502        );
1503    }
1504
1505    #[tokio::test]
1506    async fn test_generate_code_actions_refactor_loop_skips_unsafe_version_string() {
1507        // Regression for #302: a registry version containing manifest-structural
1508        // characters must never be offered as a REFACTOR quickfix, since its raw
1509        // text would otherwise be written verbatim into the `TextEdit`.
1510        let (dep, version_range, content) = vulnerable_dep("1.0.0");
1511        let parse_result = MockParseResult {
1512            deps: vec![dep],
1513            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1514        };
1515
1516        let cached = HashMap::new();
1517        let resolved = HashMap::new();
1518        let versions = VersionData::new(&cached, &resolved);
1519        let registry = FixedVersionRegistry {
1520            versions: vec![("1.1.0\", \"evil\": \"true", false), ("1.1.0", false)],
1521        };
1522
1523        let actions = generate_code_actions(
1524            &parse_result,
1525            version_range.start,
1526            parse_result.uri(),
1527            versions,
1528            &content,
1529            &registry,
1530            &IdentityFormatter,
1531        )
1532        .await;
1533
1534        let titles = refactor_titles(&actions);
1535        assert!(
1536            !titles.iter().any(|t| t.contains("evil")),
1537            "an unsafe version string must never be offered as an update: {titles:?}"
1538        );
1539        assert!(
1540            titles.contains(&"1.1.0"),
1541            "a safe version must still be offered: {titles:?}"
1542        );
1543    }
1544
1545    #[tokio::test]
1546    async fn test_build_vulnerability_fix_action_skips_unsafe_fix_version() {
1547        // Regression for #302: an OSV advisory's `fixed_versions` entry is
1548        // external, untrusted data — a manifest-structural character in it must
1549        // never reach a `TextEdit` via the vulnerability quickfix.
1550        use crate::osv::{
1551            Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1552        };
1553        use std::collections::HashMap;
1554
1555        let (dep, version_range, content) = vulnerable_dep("1.0.0");
1556        let parse_result = MockParseResult {
1557            deps: vec![dep],
1558            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1559        };
1560
1561        let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1562        vulnerabilities.insert(
1563            "pkg".to_string(),
1564            ScanOutcome::Vulnerable(DependencyVulnerabilities {
1565                advisories: Capped::new(
1566                    vec![std::sync::Arc::new(Advisory {
1567                        id: "A1".to_string(),
1568                        modified: "2023-01-01T00:00:00Z".to_string(),
1569                        summary: None,
1570                        aliases: vec![],
1571                        severity: VulnSeverity::High,
1572                        cvss_vector: None,
1573                        fixed_versions: vec!["1.2.0\", \"evil\": \"true".to_string()],
1574                        url: String::new(),
1575                    })],
1576                    1,
1577                ),
1578                fix_target_status: UpgradeStatus::NotChecked,
1579                upgrade_status: UpgradeStatus::NotChecked,
1580            }),
1581        );
1582
1583        let cached = HashMap::new();
1584        let resolved = HashMap::new();
1585        let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1586
1587        let actions = generate_code_actions(
1588            &parse_result,
1589            version_range.start,
1590            parse_result.uri(),
1591            versions,
1592            &content,
1593            &MockRegistry,
1594            &IdentityFormatter,
1595        )
1596        .await;
1597
1598        assert!(
1599            quickfix_titles(&actions).is_empty(),
1600            "an unsafe fix version must never produce a vulnerability-fix quickfix"
1601        );
1602    }
1603
1604    #[tokio::test]
1605    async fn test_generate_code_actions_vulnerability_fix_not_offered_on_patched_duplicate_occurrence()
1606     {
1607        // #394 S2 (critic addendum, security-relevant): the vulnerability
1608        // quickfix *mutates the manifest*, so offering it on the wrong
1609        // occurrence of a duplicated name is worse than a cosmetic bug.
1610        // `log4j-core` appears twice with different pins — one vulnerable,
1611        // one already patched. The quickfix must appear only at the
1612        // vulnerable occurrence's position, never at the patched one's,
1613        // regardless of which occurrence's OSV result happened to be
1614        // inserted into the shared map last.
1615        use crate::osv::{
1616            Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1617        };
1618        use tower_lsp_server::ls_types::{Position, Range};
1619
1620        let vulnerable_line = "log4j-core = \"=2.14.1\"";
1621        let patched_line = "log4j-core = \"=2.17.1\"";
1622        let content = format!("{vulnerable_line}\n{patched_line}\n");
1623
1624        let name_start = 0u32;
1625        let name_end = "log4j-core".len() as u32;
1626        let version_start = "log4j-core = \"".len() as u32;
1627
1628        let vulnerable_dep = MockDep {
1629            name: pkg("log4j-core"),
1630            version_req: VersionReq::new("=2.14.1"),
1631            version_range: Range::new(
1632                Position::new(0, version_start),
1633                Position::new(0, version_start + "=2.14.1".len() as u32),
1634            ),
1635            name_range: Range::new(Position::new(0, name_start), Position::new(0, name_end)),
1636        };
1637        let patched_dep = MockDep {
1638            name: pkg("log4j-core"),
1639            version_req: VersionReq::new("=2.17.1"),
1640            version_range: Range::new(
1641                Position::new(1, version_start),
1642                Position::new(1, version_start + "=2.17.1".len() as u32),
1643            ),
1644            name_range: Range::new(Position::new(1, name_start), Position::new(1, name_end)),
1645        };
1646        let parse_result = MockParseResult {
1647            deps: vec![vulnerable_dep, patched_dep],
1648            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1649        };
1650
1651        let cached = HashMap::new();
1652        let resolved = HashMap::new();
1653
1654        let keys = crate::osv::vulnerability_keys(
1655            &parse_result,
1656            &resolved,
1657            &IdentityFormatter,
1658            crate::EcosystemId::Cargo,
1659        );
1660        let deps = parse_result.dependencies();
1661        let vulnerable_key = keys.get(&deps[0].name_range()).unwrap().clone();
1662        let patched_key = keys.get(&deps[1].name_range()).unwrap().clone();
1663        assert_ne!(vulnerable_key, patched_key);
1664
1665        let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1666        vulnerabilities.insert(
1667            vulnerable_key,
1668            ScanOutcome::Vulnerable(DependencyVulnerabilities {
1669                advisories: Capped::new(
1670                    vec![std::sync::Arc::new(Advisory {
1671                        id: "GHSA-log4j".to_string(),
1672                        modified: "2023-01-01T00:00:00Z".to_string(),
1673                        summary: None,
1674                        aliases: vec![],
1675                        severity: VulnSeverity::Critical,
1676                        cvss_vector: None,
1677                        fixed_versions: vec!["2.17.1".to_string()],
1678                        url: String::new(),
1679                    })],
1680                    1,
1681                ),
1682                fix_target_status: UpgradeStatus::CandidateClean {
1683                    version: "2.17.1".to_string(),
1684                },
1685                upgrade_status: UpgradeStatus::NotChecked,
1686            }),
1687        );
1688        vulnerabilities.insert(patched_key, ScanOutcome::Clean);
1689
1690        let versions = VersionData::new(&cached, &resolved)
1691            .with_vulnerabilities(&vulnerabilities)
1692            .with_ecosystem(crate::EcosystemId::Cargo);
1693
1694        let actions_on_vulnerable = generate_code_actions(
1695            &parse_result,
1696            Position::new(0, version_start),
1697            parse_result.uri(),
1698            versions,
1699            &content,
1700            &MockRegistry,
1701            &IdentityFormatter,
1702        )
1703        .await;
1704        assert!(
1705            !quickfix_titles(&actions_on_vulnerable).is_empty(),
1706            "the vulnerable occurrence must get a fix quickfix"
1707        );
1708
1709        let actions_on_patched = generate_code_actions(
1710            &parse_result,
1711            Position::new(1, version_start),
1712            parse_result.uri(),
1713            versions,
1714            &content,
1715            &MockRegistry,
1716            &IdentityFormatter,
1717        )
1718        .await;
1719        assert!(
1720            quickfix_titles(&actions_on_patched).is_empty(),
1721            "the already-patched occurrence must NOT get a fix quickfix, \
1722             even though it shares a name with the vulnerable one: {:?}",
1723            quickfix_titles(&actions_on_patched)
1724        );
1725    }
1726
1727    #[tokio::test]
1728    async fn test_generate_code_actions_refactor_loop_dedups_item_matching_fix_text_by_different_raw_version()
1729     {
1730        // Regression for #242 (gap 1): the old guard compared `item.version` against
1731        // `fix.version_native` verbatim, so a display item whose *formatted* text
1732        // matched the fix action's edit but whose *raw* version differed slipped
1733        // through undeduped. Here the fix targets "1.2.5" (formatted "==1.2") and the
1734        // registry also offers "1.2.9" — a different raw version that formats to the
1735        // same "==1.2" text — which must be skipped.
1736        use crate::osv::{
1737            Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1738        };
1739        use std::collections::HashMap;
1740
1741        let (dep, version_range, content) = vulnerable_dep("==1.0.0");
1742        let parse_result = MockParseResult {
1743            deps: vec![dep],
1744            uri: crate::test_util::test_uri("/test/requirements.txt"),
1745        };
1746
1747        let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1748        vulnerabilities.insert(
1749            "pkg".to_string(),
1750            ScanOutcome::Vulnerable(DependencyVulnerabilities {
1751                advisories: Capped::new(
1752                    vec![std::sync::Arc::new(Advisory {
1753                        id: "A1".to_string(),
1754                        modified: "2023-01-01T00:00:00Z".to_string(),
1755                        summary: None,
1756                        aliases: vec![],
1757                        severity: VulnSeverity::High,
1758                        cvss_vector: None,
1759                        fixed_versions: vec!["1.2.5".to_string()],
1760                        url: String::new(),
1761                    })],
1762                    1,
1763                ),
1764                fix_target_status: UpgradeStatus::CandidateClean {
1765                    version: "1.2.5".to_string(),
1766                },
1767                upgrade_status: UpgradeStatus::NotChecked,
1768            }),
1769        );
1770
1771        let cached = HashMap::new();
1772        let resolved = HashMap::new();
1773        let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1774        let registry = FixedVersionRegistry {
1775            versions: vec![("1.2.9", false), ("1.1.0", false)],
1776        };
1777
1778        let actions = generate_code_actions(
1779            &parse_result,
1780            version_range.start,
1781            parse_result.uri(),
1782            versions,
1783            &content,
1784            &registry,
1785            &TruncatingFormatter,
1786        )
1787        .await;
1788
1789        assert_eq!(quickfix_titles(&actions).len(), 1);
1790
1791        let titles = refactor_titles(&actions);
1792        assert!(
1793            !titles.iter().any(|t| t.starts_with("1.2.9")),
1794            "an item whose formatted text matches the fix action's text must be \
1795             skipped even though its raw version differs from the fix's: {titles:?}"
1796        );
1797        assert!(titles.iter().any(|t| t.starts_with("1.1.0")));
1798
1799        for action in actions
1800            .iter()
1801            .filter(|a| a.kind == Some(CodeActionKind::REFACTOR))
1802        {
1803            let edit_text = &action.edit.as_ref().unwrap().changes.as_ref().unwrap()
1804                [parse_result.uri()][0]
1805                .new_text;
1806            for other in actions.iter() {
1807                if std::ptr::eq(action, other) {
1808                    continue;
1809                }
1810                let other_text = &other.edit.as_ref().unwrap().changes.as_ref().unwrap()
1811                    [parse_result.uri()][0]
1812                    .new_text;
1813                assert_ne!(
1814                    edit_text, other_text,
1815                    "no two actions may carry a byte-identical edit"
1816                );
1817            }
1818        }
1819    }
1820
1821    #[tokio::test]
1822    async fn test_generate_code_actions_refactor_loop_dedups_item_matching_another_items_text() {
1823        // Regression for #242 (gap 2): two display items whose formatted text
1824        // coincides (e.g. PyPI's release-segment truncation) must not both be
1825        // offered as REFACTOR actions, even with no fix action in play at all.
1826        // Registry-native order is newest-first, so "1.1.9" is `is_latest`; both
1827        // "1.1.9" and "1.1.5" truncate to "==1.1" and must collapse into one action.
1828        let (dep, version_range, content) = vulnerable_dep("==1.0.*");
1829        let parse_result = MockParseResult {
1830            deps: vec![dep],
1831            uri: crate::test_util::test_uri("/test/requirements.txt"),
1832        };
1833
1834        let cached = HashMap::new();
1835        let resolved = HashMap::new();
1836        let versions = VersionData::new(&cached, &resolved);
1837        let registry = FixedVersionRegistry {
1838            versions: vec![("1.1.9", false), ("1.1.5", false), ("1.1.0", false)],
1839        };
1840
1841        let actions = generate_code_actions(
1842            &parse_result,
1843            version_range.start,
1844            parse_result.uri(),
1845            versions,
1846            &content,
1847            &registry,
1848            &TruncatingFormatter,
1849        )
1850        .await;
1851
1852        assert!(quickfix_titles(&actions).is_empty());
1853
1854        let titles = refactor_titles(&actions);
1855        assert_eq!(
1856            titles,
1857            vec!["1.1.9 (latest)"],
1858            "identical-text items after the first must be deduped: {titles:?}"
1859        );
1860    }
1861
1862    #[tokio::test]
1863    async fn test_generate_code_actions_lockfile_hit_gets_title_suffix() {
1864        use crate::osv::{
1865            Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1866        };
1867        use std::collections::HashMap;
1868
1869        let (dep, version_range, content) = vulnerable_dep("^1.0");
1870        let parse_result = MockParseResult {
1871            deps: vec![dep],
1872            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1873        };
1874
1875        let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1876        vulnerabilities.insert(
1877            "pkg".to_string(),
1878            ScanOutcome::Vulnerable(DependencyVulnerabilities {
1879                advisories: Capped::new(
1880                    vec![std::sync::Arc::new(Advisory {
1881                        id: "A1".to_string(),
1882                        modified: "2023-01-01T00:00:00Z".to_string(),
1883                        summary: None,
1884                        aliases: vec![],
1885                        severity: VulnSeverity::High,
1886                        cvss_vector: None,
1887                        fixed_versions: vec!["1.0.2".to_string()],
1888                        url: String::new(),
1889                    })],
1890                    1,
1891                ),
1892                fix_target_status: UpgradeStatus::CandidateClean {
1893                    version: "1.0.2".to_string(),
1894                },
1895                upgrade_status: UpgradeStatus::NotChecked,
1896            }),
1897        );
1898
1899        let cached = HashMap::new();
1900        let mut resolved = HashMap::new();
1901        resolved.insert(pkg("pkg"), "1.0.1".into());
1902        let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1903
1904        let actions = generate_code_actions(
1905            &parse_result,
1906            version_range.start,
1907            parse_result.uri(),
1908            versions,
1909            &content,
1910            &MockRegistry,
1911            &MockFormatter,
1912        )
1913        .await;
1914
1915        let titles = quickfix_titles(&actions);
1916        assert_eq!(
1917            titles,
1918            vec!["Update to 1.0.2 (fixes A1; update lockfile to apply)"]
1919        );
1920    }
1921
1922    #[tokio::test]
1923    async fn test_generate_code_actions_fix_action_survives_registry_error() {
1924        // FR-007 / registry-independence: a registry outage must never
1925        // suppress an OSV-derived fix. The fix action is computed before the
1926        // `registry.get_versions` call, but this test exercises the early
1927        // return on `Err` specifically, which no prior test reached.
1928        use crate::osv::{
1929            Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1930        };
1931        use std::collections::HashMap;
1932
1933        let (dep, version_range, content) = vulnerable_dep("1.0.0");
1934        let parse_result = MockParseResult {
1935            deps: vec![dep],
1936            uri: crate::test_util::test_uri("/test/Cargo.toml"),
1937        };
1938
1939        let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
1940        vulnerabilities.insert(
1941            "pkg".to_string(),
1942            ScanOutcome::Vulnerable(DependencyVulnerabilities {
1943                advisories: Capped::new(
1944                    vec![std::sync::Arc::new(Advisory {
1945                        id: "A1".to_string(),
1946                        modified: "2023-01-01T00:00:00Z".to_string(),
1947                        summary: None,
1948                        aliases: vec![],
1949                        severity: VulnSeverity::High,
1950                        cvss_vector: None,
1951                        fixed_versions: vec!["1.2.0".to_string()],
1952                        url: String::new(),
1953                    })],
1954                    1,
1955                ),
1956                fix_target_status: UpgradeStatus::CandidateClean {
1957                    version: "1.2.0".to_string(),
1958                },
1959                upgrade_status: UpgradeStatus::NotChecked,
1960            }),
1961        );
1962
1963        let cached = HashMap::new();
1964        let resolved = HashMap::new();
1965        let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
1966
1967        let actions = generate_code_actions(
1968            &parse_result,
1969            version_range.start,
1970            parse_result.uri(),
1971            versions,
1972            &content,
1973            &ErrorRegistry,
1974            &MockFormatter,
1975        )
1976        .await;
1977
1978        let titles = quickfix_titles(&actions);
1979        assert_eq!(titles, vec!["Update to 1.2.0 (fixes A1)"]);
1980        // No plain "update to X" items either, since the registry fetch that
1981        // would produce them failed.
1982        assert_eq!(actions.len(), 1);
1983        // S1: the single-exit restructure must not drop `isPreferred` from an
1984        // already-built fix action on the registry-outage path.
1985        assert_eq!(actions[0].is_preferred, Some(true));
1986    }
1987
1988    #[tokio::test]
1989    async fn test_generate_code_actions_coexistence_dedups_fix_version_and_demotes_preferred() {
1990        // Exercises the branch where the registry fetch succeeds *and*
1991        // returns the fix's own target version alongside other non-yanked
1992        // versions: the display item for that exact version must not be
1993        // duplicated, and no plain item may claim `is_preferred` once a fix
1994        // action exists.
1995        use crate::osv::{
1996            Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
1997        };
1998        use std::collections::HashMap;
1999
2000        let (dep, version_range, content) = vulnerable_dep("1.0.0");
2001        let parse_result = MockParseResult {
2002            deps: vec![dep],
2003            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2004        };
2005
2006        let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
2007        vulnerabilities.insert(
2008            "pkg".to_string(),
2009            ScanOutcome::Vulnerable(DependencyVulnerabilities {
2010                advisories: Capped::new(
2011                    vec![std::sync::Arc::new(Advisory {
2012                        id: "A1".to_string(),
2013                        modified: "2023-01-01T00:00:00Z".to_string(),
2014                        summary: None,
2015                        aliases: vec![],
2016                        severity: VulnSeverity::High,
2017                        cvss_vector: None,
2018                        fixed_versions: vec!["1.2.0".to_string()],
2019                        url: String::new(),
2020                    })],
2021                    1,
2022                ),
2023                fix_target_status: UpgradeStatus::CandidateClean {
2024                    version: "1.2.0".to_string(),
2025                },
2026                upgrade_status: UpgradeStatus::NotChecked,
2027            }),
2028        );
2029
2030        let cached = HashMap::new();
2031        let resolved = HashMap::new();
2032        let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
2033        // Registry-native order is descending (index 0 = latest); the fix's
2034        // own target (1.2.0) is present and not yanked, alongside others.
2035        let registry = FixedVersionRegistry {
2036            versions: vec![("1.2.0", false), ("1.1.0", false), ("1.0.0", false)],
2037        };
2038
2039        let actions = generate_code_actions(
2040            &parse_result,
2041            version_range.start,
2042            parse_result.uri(),
2043            versions,
2044            &content,
2045            &registry,
2046            &MockFormatter,
2047        )
2048        .await;
2049
2050        assert_eq!(quickfix_titles(&actions).len(), 1);
2051
2052        let refactor_titles: Vec<&str> = actions
2053            .iter()
2054            .filter(|a| a.kind == Some(CodeActionKind::REFACTOR))
2055            .map(|a| a.title.as_str())
2056            .collect();
2057        assert!(
2058            !refactor_titles.iter().any(|t| t.starts_with("1.2.0")),
2059            "the display item duplicating the fix's own target must be skipped: {refactor_titles:?}"
2060        );
2061        assert!(refactor_titles.iter().any(|t| t.starts_with("1.1.0")));
2062
2063        assert!(
2064            actions
2065                .iter()
2066                .filter(|a| a.kind == Some(CodeActionKind::REFACTOR))
2067                .all(|a| a.is_preferred.is_none()),
2068            "only the fix action may be preferred once it exists"
2069        );
2070    }
2071
2072    #[tokio::test]
2073    async fn test_generate_code_actions_latest_refactor_is_preferred_when_no_fix_exists() {
2074        // Critic C1 / review "Important": the most common production path — no
2075        // vulnerability, no unsatisfiable requirement, just a satisfiable
2076        // dependency with newer versions available — must still mark the
2077        // `item.is_latest` REFACTOR action as the editor's preferred quickfix.
2078        // This moved from a construction-site expression to the shared
2079        // `is_preferred` post-pass indexed by `latest_refactor_idx`; a wrong
2080        // index or a broken `.or()` chain would silently strip `isPreferred`
2081        // from every ordinary "update to latest" action across all 11
2082        // ecosystems with a fully green suite otherwise.
2083        let (dep, version_range, content) = vulnerable_dep("1.0.0");
2084        let parse_result = MockParseResult {
2085            deps: vec![dep],
2086            uri: crate::test_util::test_uri("/test/Cargo.toml"),
2087        };
2088
2089        let cached = HashMap::new();
2090        let resolved = HashMap::new();
2091        let versions = VersionData::new(&cached, &resolved);
2092        let registry = FixedVersionRegistry {
2093            versions: vec![("2.0.0", false), ("1.5.0", false)],
2094        };
2095
2096        let actions = generate_code_actions(
2097            &parse_result,
2098            version_range.start,
2099            parse_result.uri(),
2100            versions,
2101            &content,
2102            &registry,
2103            &MockFormatter,
2104        )
2105        .await;
2106
2107        assert!(
2108            quickfix_titles(&actions).is_empty(),
2109            "no vuln/unsat fix should exist: {actions:?}"
2110        );
2111        let refactor_titles = refactor_titles(&actions);
2112        assert_eq!(refactor_titles.len(), 2, "{refactor_titles:?}");
2113
2114        let preferred: Vec<&str> = actions
2115            .iter()
2116            .filter(|a| a.is_preferred == Some(true))
2117            .map(|a| a.title.as_str())
2118            .collect();
2119        assert_eq!(
2120            preferred.len(),
2121            1,
2122            "exactly one action must be preferred: {actions:?}"
2123        );
2124        assert!(
2125            preferred[0].starts_with("2.0.0"),
2126            "the newest (item.is_latest) REFACTOR action must be preferred: {preferred:?}"
2127        );
2128        assert!(
2129            actions
2130                .iter()
2131                .filter(|a| !a.title.starts_with("2.0.0"))
2132                .all(|a| a.is_preferred.is_none()),
2133            "every other action must be None, not Some(false): {actions:?}"
2134        );
2135    }
2136
2137    #[tokio::test]
2138    async fn test_generate_code_actions_fix_uses_ecosystem_format_version_replacing_override() {
2139        // Critic S3: `format_version_replacing` is overridden in exactly one
2140        // place workspace-wide (`deps-pypi`); no test anywhere proved the
2141        // vulnerability-fix action's `TextEdit` actually goes through such
2142        // an override rather than the default delegation to
2143        // `format_version_for_text_edit` — the same bug class the original
2144        // #216 critique caught (a guard/edit comparing the wrong string,
2145        // silently bypassed per-ecosystem).
2146        use crate::osv::{
2147            Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
2148        };
2149        use std::collections::HashMap;
2150
2151        let (dep, version_range, content) = vulnerable_dep("==1.0.0");
2152        let uri = crate::test_util::test_uri("/test/requirements.txt");
2153        let parse_result = MockParseResult {
2154            deps: vec![dep],
2155            uri: uri.clone(),
2156        };
2157
2158        let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
2159        vulnerabilities.insert(
2160            "pkg".to_string(),
2161            ScanOutcome::Vulnerable(DependencyVulnerabilities {
2162                advisories: Capped::new(
2163                    vec![std::sync::Arc::new(Advisory {
2164                        id: "A1".to_string(),
2165                        modified: "2023-01-01T00:00:00Z".to_string(),
2166                        summary: None,
2167                        aliases: vec![],
2168                        severity: VulnSeverity::High,
2169                        cvss_vector: None,
2170                        fixed_versions: vec!["1.0.2".to_string()],
2171                        url: String::new(),
2172                    })],
2173                    1,
2174                ),
2175                fix_target_status: UpgradeStatus::CandidateClean {
2176                    version: "1.0.2".to_string(),
2177                },
2178                upgrade_status: UpgradeStatus::NotChecked,
2179            }),
2180        );
2181
2182        let cached = HashMap::new();
2183        let resolved = HashMap::new();
2184        let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
2185
2186        let actions = generate_code_actions(
2187            &parse_result,
2188            version_range.start,
2189            parse_result.uri(),
2190            versions,
2191            &content,
2192            &MockRegistry,
2193            &PinPreservingFormatter,
2194        )
2195        .await;
2196
2197        let quickfix = actions
2198            .iter()
2199            .find(|a| a.kind == Some(CodeActionKind::QUICKFIX))
2200            .expect("a vulnerability-fix quickfix should be offered");
2201        let new_text = quickfix
2202            .edit
2203            .as_ref()
2204            .and_then(|e| e.changes.as_ref())
2205            .and_then(|c| c.get(&uri))
2206            .and_then(|edits| edits.first())
2207            .map(|e| e.new_text.as_str())
2208            .expect("quickfix should carry a TextEdit for the document uri");
2209
2210        assert_eq!(
2211            new_text, "==1.0.2",
2212            "the fix action's TextEdit must go through format_version_replacing's \
2213             pin-preserving override, not the default format_version_for_text_edit delegation"
2214        );
2215    }
2216
2217    /// Tests for the `literal_span_matches` guard on `generate_code_actions`
2218    /// (§6.3): a dependency whose `version_range` no longer slices to its
2219    /// declared requirement must yield no code action, mirroring the guard
2220    /// `collect_update_all_edits` already applies.
2221    mod code_actions_guard_tests {
2222        use super::*;
2223        use std::collections::HashMap;
2224        use tower_lsp_server::ls_types::{Position, Range};
2225
2226        struct CaDep {
2227            name: PackageName,
2228            version_req: Option<VersionReq>,
2229            version_range: Option<Range>,
2230        }
2231
2232        impl Dependency for CaDep {
2233            fn name(&self) -> &PackageName {
2234                &self.name
2235            }
2236            fn name_range(&self) -> Range {
2237                Range::default()
2238            }
2239            fn version_requirement(&self) -> Option<&VersionReq> {
2240                self.version_req.as_ref()
2241            }
2242            fn version_range(&self) -> Option<Range> {
2243                self.version_range
2244            }
2245            fn source(&self) -> crate::parser::DependencySource {
2246                crate::parser::DependencySource::Registry
2247            }
2248            fn as_any(&self) -> &dyn Any {
2249                self
2250            }
2251        }
2252
2253        struct CaParseResult {
2254            deps: Vec<CaDep>,
2255            uri: Uri,
2256        }
2257
2258        impl ParseResult for CaParseResult {
2259            fn dependencies(&self) -> Vec<&dyn Dependency> {
2260                self.deps.iter().map(|d| d as &dyn Dependency).collect()
2261            }
2262            fn workspace_root(&self) -> Option<&std::path::Path> {
2263                None
2264            }
2265            fn uri(&self) -> &Uri {
2266                &self.uri
2267            }
2268            fn as_any(&self) -> &dyn Any {
2269                self
2270            }
2271        }
2272
2273        /// Mirrors `deps-swift`'s `SwiftDependency`: `version_req` is a synthesized
2274        /// comparator string, `version_range` spans only the bare literal it was
2275        /// synthesized from, and `version_literal` (unlike [`CaDep`], which relies on
2276        /// the trait's default `None`) carries that literal so the guard compares
2277        /// against it instead of `version_req` (#367).
2278        struct CaLiteralDep {
2279            name: PackageName,
2280            version_req: Option<VersionReq>,
2281            version_range: Option<Range>,
2282            version_literal: Option<String>,
2283        }
2284
2285        impl Dependency for CaLiteralDep {
2286            fn name(&self) -> &PackageName {
2287                &self.name
2288            }
2289            fn name_range(&self) -> Range {
2290                Range::default()
2291            }
2292            fn version_requirement(&self) -> Option<&VersionReq> {
2293                self.version_req.as_ref()
2294            }
2295            fn version_range(&self) -> Option<Range> {
2296                self.version_range
2297            }
2298            fn source(&self) -> crate::parser::DependencySource {
2299                crate::parser::DependencySource::Registry
2300            }
2301            fn version_literal(&self) -> Option<&str> {
2302                self.version_literal.as_deref()
2303            }
2304            fn as_any(&self) -> &dyn Any {
2305                self
2306            }
2307        }
2308
2309        struct CaLiteralParseResult {
2310            deps: Vec<CaLiteralDep>,
2311            uri: Uri,
2312        }
2313
2314        impl ParseResult for CaLiteralParseResult {
2315            fn dependencies(&self) -> Vec<&dyn Dependency> {
2316                self.deps.iter().map(|d| d as &dyn Dependency).collect()
2317            }
2318            fn workspace_root(&self) -> Option<&std::path::Path> {
2319                None
2320            }
2321            fn uri(&self) -> &Uri {
2322                &self.uri
2323            }
2324            fn as_any(&self) -> &dyn Any {
2325                self
2326            }
2327        }
2328
2329        struct CaVersion {
2330            version: ConcreteVersion,
2331            yanked: bool,
2332        }
2333
2334        crate::impl_version!(CaVersion {
2335            version: version,
2336            status: |v: &CaVersion| crate::RemovalStatus::from_yanked(v.yanked),
2337        });
2338
2339        struct CaRegistry;
2340
2341        impl crate::Registry for CaRegistry {
2342            fn get_versions<'a>(
2343                &'a self,
2344                _name: &'a PackageName,
2345            ) -> crate::ecosystem::BoxFuture<'a, crate::error::Result<Vec<Box<dyn crate::Version>>>>
2346            {
2347                Box::pin(async move {
2348                    Ok(vec![Box::new(CaVersion {
2349                        version: "2.0.0".into(),
2350                        yanked: false,
2351                    }) as Box<dyn crate::Version>])
2352                })
2353            }
2354
2355            fn get_latest_matching<'a>(
2356                &'a self,
2357                _name: &'a PackageName,
2358                _req: &'a VersionReq,
2359            ) -> crate::ecosystem::BoxFuture<
2360                'a,
2361                crate::error::Result<Option<Box<dyn crate::Version>>>,
2362            > {
2363                Box::pin(async move { Ok(None) })
2364            }
2365
2366            fn search<'a>(
2367                &'a self,
2368                _query: &'a str,
2369                _limit: usize,
2370            ) -> crate::ecosystem::BoxFuture<'a, crate::error::Result<Vec<Box<dyn crate::Metadata>>>>
2371            {
2372                Box::pin(async move { Ok(Vec::new()) })
2373            }
2374
2375            fn as_any(&self) -> &dyn Any {
2376                self
2377            }
2378        }
2379
2380        fn range(sl: u32, sc: u32, el: u32, ec: u32) -> Range {
2381            Range::new(Position::new(sl, sc), Position::new(el, ec))
2382        }
2383
2384        #[tokio::test]
2385        async fn test_guard_rejects_span_that_does_not_match_requirement() {
2386            // content has "1.0.0" at 0..5, but the dependency claims its
2387            // version_range covers 6..11 (out of bounds / wrong slice) —
2388            // simulate via a version_range that slices to different text.
2389            let content = "1.0.0 extra";
2390            let dep = CaDep {
2391                name: pkg("serde"),
2392                version_req: Some(VersionReq::new("1.0.0")),
2393                version_range: Some(range(0, 6, 0, 11)), // slices to "extra"
2394            };
2395            let pr = CaParseResult {
2396                deps: vec![dep],
2397                uri: crate::test_util::test_uri("/test/Cargo.toml"),
2398            };
2399            // Must fall inside `version_range` (6..11) for
2400            // `is_position_on_dependency`'s default impl to select this
2401            // dependency at all — the point of this test is the guard past
2402            // that selection, not the selection itself.
2403            let position = Position::new(0, 7);
2404            let cached = HashMap::new();
2405            let resolved = HashMap::new();
2406            let versions = VersionData::new(&cached, &resolved);
2407
2408            let actions = generate_code_actions(
2409                &pr,
2410                position,
2411                pr.uri(),
2412                versions,
2413                content,
2414                &CaRegistry,
2415                &MockFormatter,
2416            )
2417            .await;
2418
2419            assert!(actions.is_empty());
2420        }
2421
2422        #[tokio::test]
2423        async fn test_guard_rejects_span_even_with_a_pending_vulnerability_fix() {
2424            // Critic S2: the guard must gate the vulnerability-fix quickfix
2425            // too, not just the plain "update version" action — a future
2426            // refactor moving `build_vulnerability_fix_action` above the
2427            // guard would reintroduce manifest corruption on a rejected
2428            // span (e.g. a Maven `${property}` reference) at P0 severity.
2429            // Every other test in this module uses an empty `VersionData`,
2430            // which would pass even if the guard only gated the plain
2431            // action; this one carries a real OSV hit so a regression that
2432            // reorders the two checks fails here.
2433            use crate::osv::{
2434                Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
2435            };
2436
2437            let content = "1.0.0 extra";
2438            let dep = CaDep {
2439                name: pkg("serde"),
2440                version_req: Some(VersionReq::new("1.0.0")),
2441                version_range: Some(range(0, 6, 0, 11)), // slices to "extra"
2442            };
2443            let pr = CaParseResult {
2444                deps: vec![dep],
2445                uri: crate::test_util::test_uri("/test/Cargo.toml"),
2446            };
2447            let position = Position::new(0, 7);
2448
2449            let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
2450            vulnerabilities.insert(
2451                "serde".to_string(),
2452                ScanOutcome::Vulnerable(DependencyVulnerabilities {
2453                    advisories: Capped::new(
2454                        vec![std::sync::Arc::new(Advisory {
2455                            id: "A1".to_string(),
2456                            modified: "2023-01-01T00:00:00Z".to_string(),
2457                            summary: None,
2458                            aliases: vec![],
2459                            severity: VulnSeverity::High,
2460                            cvss_vector: None,
2461                            fixed_versions: vec!["2.0.0".to_string()],
2462                            url: String::new(),
2463                        })],
2464                        1,
2465                    ),
2466                    fix_target_status: UpgradeStatus::NotChecked,
2467                    upgrade_status: UpgradeStatus::NotChecked,
2468                }),
2469            );
2470            let cached = HashMap::new();
2471            let resolved = HashMap::new();
2472            let versions =
2473                VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
2474
2475            let actions = generate_code_actions(
2476                &pr,
2477                position,
2478                pr.uri(),
2479                versions,
2480                content,
2481                &CaRegistry,
2482                &MockFormatter,
2483            )
2484            .await;
2485
2486            assert!(quickfix_titles(&actions).is_empty());
2487            assert!(actions.is_empty());
2488        }
2489
2490        #[tokio::test]
2491        async fn test_guard_accepts_matching_span() {
2492            let content = "1.0.0";
2493            let dep = CaDep {
2494                name: pkg("serde"),
2495                version_req: Some(VersionReq::new("1.0.0")),
2496                version_range: Some(range(0, 0, 0, 5)),
2497            };
2498            let pr = CaParseResult {
2499                deps: vec![dep],
2500                uri: crate::test_util::test_uri("/test/Cargo.toml"),
2501            };
2502            let position = Position::new(0, 0);
2503            let cached = HashMap::new();
2504            let resolved = HashMap::new();
2505            let versions = VersionData::new(&cached, &resolved);
2506
2507            let actions = generate_code_actions(
2508                &pr,
2509                position,
2510                pr.uri(),
2511                versions,
2512                content,
2513                &CaRegistry,
2514                &MockFormatter,
2515            )
2516            .await;
2517
2518            assert!(!actions.is_empty());
2519        }
2520
2521        #[tokio::test]
2522        async fn test_guard_rejects_empty_requirement() {
2523            let content = "1.0.0";
2524            let dep = CaDep {
2525                name: pkg("serde"),
2526                version_req: Some(VersionReq::new("")),
2527                version_range: Some(range(0, 0, 0, 5)),
2528            };
2529            let pr = CaParseResult {
2530                deps: vec![dep],
2531                uri: crate::test_util::test_uri("/test/Cargo.toml"),
2532            };
2533            let position = Position::new(0, 0);
2534            let cached = HashMap::new();
2535            let resolved = HashMap::new();
2536            let versions = VersionData::new(&cached, &resolved);
2537
2538            let actions = generate_code_actions(
2539                &pr,
2540                position,
2541                pr.uri(),
2542                versions,
2543                content,
2544                &CaRegistry,
2545                &MockFormatter,
2546            )
2547            .await;
2548
2549            assert!(actions.is_empty());
2550        }
2551
2552        #[tokio::test]
2553        async fn test_guard_rejects_synthesized_requirement_with_no_literal_override() {
2554            // Reproduces #367: a synthesized comparator requirement (mirroring
2555            // `deps-swift`'s `.upToNextMajor(from: "4.77.0")` -> `">=4.77.0, <5.0.0"`)
2556            // whose `version_range` spans only the bare literal `4.77.0`. Without a
2557            // `version_literal` override the guard compares the slice against the full
2558            // comparator string and can never match, so no action is ever produced —
2559            // the exact bug this issue reports.
2560            let content = "4.77.0";
2561            let dep = CaDep {
2562                name: pkg("vapor"),
2563                version_req: Some(VersionReq::new(">=4.77.0, <5.0.0")),
2564                version_range: Some(range(0, 0, 0, 6)),
2565            };
2566            let pr = CaParseResult {
2567                deps: vec![dep],
2568                uri: crate::test_util::test_uri("/test/Package.swift"),
2569            };
2570            let position = Position::new(0, 0);
2571            let cached = HashMap::new();
2572            let resolved = HashMap::new();
2573            let versions = VersionData::new(&cached, &resolved);
2574
2575            let actions = generate_code_actions(
2576                &pr,
2577                position,
2578                pr.uri(),
2579                versions,
2580                content,
2581                &CaRegistry,
2582                &MockFormatter,
2583            )
2584            .await;
2585
2586            assert!(actions.is_empty());
2587        }
2588
2589        #[tokio::test]
2590        async fn test_guard_accepts_synthesized_requirement_via_version_literal_override() {
2591            // Fix for #367: same synthesized-requirement / bare-literal shape as
2592            // `test_guard_rejects_synthesized_requirement_with_no_literal_override`, but
2593            // `version_literal` now carries the bare literal the requirement was
2594            // synthesized from — the guard must compare against that instead of
2595            // `version_req` and accept the span.
2596            let content = "4.77.0";
2597            let dep = CaLiteralDep {
2598                name: pkg("vapor"),
2599                version_req: Some(VersionReq::new(">=4.77.0, <5.0.0")),
2600                version_range: Some(range(0, 0, 0, 6)),
2601                version_literal: Some("4.77.0".to_string()),
2602            };
2603            let pr = CaLiteralParseResult {
2604                deps: vec![dep],
2605                uri: crate::test_util::test_uri("/test/Package.swift"),
2606            };
2607            let position = Position::new(0, 0);
2608            let cached = HashMap::new();
2609            let resolved = HashMap::new();
2610            let versions = VersionData::new(&cached, &resolved);
2611
2612            let actions = generate_code_actions(
2613                &pr,
2614                position,
2615                pr.uri(),
2616                versions,
2617                content,
2618                &CaRegistry,
2619                &MockFormatter,
2620            )
2621            .await;
2622
2623            assert!(!actions.is_empty());
2624        }
2625    }
2626
2627    /// Coverage for [`build_unsatisfiable_fix_action`] and its wiring into
2628    /// `generate_code_actions` (plan §1.2-§1.4): each guard, the yank filter, the
2629    /// vuln/unsat collision drop, and the `is_preferred` post-pass across producers.
2630    mod unsatisfiable_fix_action_tests {
2631        use super::*;
2632        use std::collections::HashMap;
2633
2634        /// Same exact-match `compile_requirement` as [`ExactMatchFormatter`], but
2635        /// `format_version_replacing` always returns a fixed text that stays
2636        /// unsatisfiable against any `available` list not literally containing it —
2637        /// simulating a pypi/gradle-style override that preserves operator style
2638        /// into a still-broken range (plan §1.2.5 / critic M4).
2639        struct NonFixingFormatter;
2640
2641        impl PackageNaming for NonFixingFormatter {}
2642
2643        impl PackageRendering for NonFixingFormatter {
2644            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
2645                version.to_string()
2646            }
2647
2648            fn package_url(&self, name: &PackageName) -> String {
2649                format!("https://example.com/{name}")
2650            }
2651
2652            fn format_version_replacing(
2653                &self,
2654                _version: &ConcreteVersion,
2655                _current: &str,
2656            ) -> String {
2657                "still-bad".to_string()
2658            }
2659        }
2660
2661        impl RequirementResolution for NonFixingFormatter {
2662            fn compile_requirement(
2663                &self,
2664                requirement: &VersionReq,
2665            ) -> Option<Box<dyn RequirementMatcher>> {
2666                Some(Box::new(ExactMatcher(requirement.as_str().to_string())))
2667            }
2668        }
2669
2670        impl DiagnosticMessages for NonFixingFormatter {}
2671
2672        impl DiagnosticPolicy for NonFixingFormatter {}
2673
2674        impl SourcePolicy for NonFixingFormatter {}
2675
2676        impl OsvNaming for NonFixingFormatter {}
2677
2678        /// Same exact-match `compile_requirement` as [`ExactMatchFormatter`], but
2679        /// `format_version_replacing` always returns the same fixed text regardless of
2680        /// its input — mirroring PyPI's `truncate_release_to_match`, which can map
2681        /// distinct registry versions to byte-identical rewritten text (M7 / plan
2682        /// §1.4). Used to construct a vuln-fix target and an unsat-fix target that are
2683        /// two different, independently-yankable versions whose formatted edits
2684        /// nonetheless collide.
2685        struct CollidingTextFormatter;
2686
2687        impl PackageNaming for CollidingTextFormatter {}
2688
2689        impl PackageRendering for CollidingTextFormatter {
2690            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
2691                version.to_string()
2692            }
2693
2694            fn package_url(&self, name: &PackageName) -> String {
2695                format!("https://example.com/{name}")
2696            }
2697
2698            fn format_version_replacing(
2699                &self,
2700                _version: &ConcreteVersion,
2701                _current: &str,
2702            ) -> String {
2703                "9.9.9".to_string()
2704            }
2705        }
2706
2707        impl RequirementResolution for CollidingTextFormatter {
2708            fn compile_requirement(
2709                &self,
2710                requirement: &VersionReq,
2711            ) -> Option<Box<dyn RequirementMatcher>> {
2712                Some(Box::new(ExactMatcher(requirement.as_str().to_string())))
2713            }
2714        }
2715
2716        impl DiagnosticMessages for CollidingTextFormatter {}
2717
2718        impl DiagnosticPolicy for CollidingTextFormatter {}
2719
2720        impl SourcePolicy for CollidingTextFormatter {}
2721
2722        impl OsvNaming for CollidingTextFormatter {}
2723
2724        /// Same exact-match `compile_requirement` as [`ExactMatchFormatter`], but with a
2725        /// non-identity `normalize_package_name`, for the M1 lookup-fallback test.
2726        struct NormalizingExactFormatter;
2727
2728        impl PackageNaming for NormalizingExactFormatter {
2729            fn normalize_package_name(&self, name: &PackageName) -> String {
2730                format!("normalized-{name}")
2731            }
2732        }
2733
2734        impl PackageRendering for NormalizingExactFormatter {
2735            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
2736                version.to_string()
2737            }
2738
2739            fn package_url(&self, name: &PackageName) -> String {
2740                format!("https://example.com/{name}")
2741            }
2742        }
2743
2744        impl RequirementResolution for NormalizingExactFormatter {
2745            fn compile_requirement(
2746                &self,
2747                requirement: &VersionReq,
2748            ) -> Option<Box<dyn RequirementMatcher>> {
2749                Some(Box::new(ExactMatcher(requirement.as_str().to_string())))
2750            }
2751        }
2752
2753        impl DiagnosticMessages for NormalizingExactFormatter {}
2754
2755        impl DiagnosticPolicy for NormalizingExactFormatter {}
2756
2757        impl SourcePolicy for NormalizingExactFormatter {}
2758
2759        impl OsvNaming for NormalizingExactFormatter {}
2760
2761        struct UnsatDep {
2762            name: PackageName,
2763            version_req: VersionReq,
2764            version_range: Range,
2765            source: crate::parser::DependencySource,
2766        }
2767
2768        impl Dependency for UnsatDep {
2769            fn name(&self) -> &PackageName {
2770                &self.name
2771            }
2772            fn name_range(&self) -> Range {
2773                Range::default()
2774            }
2775            fn version_requirement(&self) -> Option<&VersionReq> {
2776                Some(&self.version_req)
2777            }
2778            fn version_range(&self) -> Option<Range> {
2779                Some(self.version_range)
2780            }
2781            fn source(&self) -> crate::parser::DependencySource {
2782                self.source.clone()
2783            }
2784            fn as_any(&self) -> &dyn Any {
2785                self
2786            }
2787        }
2788
2789        /// `MockParseResult` only stores `MockDep`s; wraps a single `UnsatDep` instead.
2790        struct UnsatParseResult {
2791            dep: UnsatDep,
2792            uri: Uri,
2793        }
2794
2795        impl ParseResult for UnsatParseResult {
2796            fn dependencies(&self) -> Vec<&dyn Dependency> {
2797                vec![&self.dep]
2798            }
2799            fn workspace_root(&self) -> Option<&std::path::Path> {
2800                None
2801            }
2802            fn uri(&self) -> &Uri {
2803                &self.uri
2804            }
2805            fn as_any(&self) -> &dyn Any {
2806                self
2807            }
2808        }
2809
2810        fn cached_versions_keyed(
2811            key: &str,
2812            latest: &str,
2813            available: &[&str],
2814        ) -> HashMap<PackageName, PackageVersions> {
2815            let mut m = HashMap::new();
2816            m.insert(
2817                pkg(key),
2818                PackageVersions {
2819                    latest: latest.into(),
2820                    available: Arc::from(
2821                        available
2822                            .iter()
2823                            .map(|s| ConcreteVersion::new(*s))
2824                            .collect::<Vec<_>>(),
2825                    ),
2826                    yanked: Arc::from(Vec::new()),
2827                    published_at: None,
2828                },
2829            );
2830            m
2831        }
2832
2833        fn cached_versions(
2834            latest: &str,
2835            available: &[&str],
2836        ) -> HashMap<PackageName, PackageVersions> {
2837            cached_versions_keyed("pkg", latest, available)
2838        }
2839
2840        #[tokio::test]
2841        async fn test_unsat_fix_emitted_for_unsatisfiable_requirement() {
2842            let (dep, version_range, content) = vulnerable_dep("1.0.0");
2843            let parse_result = MockParseResult {
2844                deps: vec![dep],
2845                uri: crate::test_util::test_uri("/test/Cargo.toml"),
2846            };
2847
2848            let cached = cached_versions("9.9.9", &["9.9.9"]);
2849            let resolved = HashMap::new();
2850            let versions = VersionData::new(&cached, &resolved);
2851
2852            let actions = generate_code_actions(
2853                &parse_result,
2854                version_range.start,
2855                parse_result.uri(),
2856                versions,
2857                &content,
2858                &MockRegistry,
2859                &ExactMatchFormatter,
2860            )
2861            .await;
2862
2863            assert_eq!(
2864                quickfix_titles(&actions),
2865                vec!["Fix unsatisfiable requirement: update to 9.9.9"]
2866            );
2867            assert_eq!(actions[0].is_preferred, Some(true));
2868        }
2869
2870        #[tokio::test]
2871        async fn test_unsat_fix_absent_when_requirement_is_satisfied() {
2872            let (dep, version_range, content) = vulnerable_dep("9.9.9");
2873            let parse_result = MockParseResult {
2874                deps: vec![dep],
2875                uri: crate::test_util::test_uri("/test/Cargo.toml"),
2876            };
2877
2878            let cached = cached_versions("9.9.9", &["9.9.9"]);
2879            let resolved = HashMap::new();
2880            let versions = VersionData::new(&cached, &resolved);
2881
2882            let actions = generate_code_actions(
2883                &parse_result,
2884                version_range.start,
2885                parse_result.uri(),
2886                versions,
2887                &content,
2888                &MockRegistry,
2889                &ExactMatchFormatter,
2890            )
2891            .await;
2892
2893            assert!(quickfix_titles(&actions).is_empty());
2894        }
2895
2896        #[tokio::test]
2897        async fn test_unsat_fix_absent_for_unsafe_or_empty_latest() {
2898            // Regression for #302 (5th guarded call site, added alongside #304):
2899            // `build_unsatisfiable_fix_action`'s cached `latest` is exactly as untrusted
2900            // as `collect_update_all_edits`'s — a manifest-structural character or an
2901            // empty string must never reach `format_version_replacing` here either.
2902            for unsafe_latest in ["9.9.9\", \"evil\": \"true", "", "   "] {
2903                let (dep, version_range, content) = vulnerable_dep("1.0.0");
2904                let parse_result = MockParseResult {
2905                    deps: vec![dep],
2906                    uri: crate::test_util::test_uri("/test/Cargo.toml"),
2907                };
2908
2909                let cached = cached_versions(unsafe_latest, &[unsafe_latest]);
2910                let resolved = HashMap::new();
2911                let versions = VersionData::new(&cached, &resolved);
2912
2913                let actions = generate_code_actions(
2914                    &parse_result,
2915                    version_range.start,
2916                    parse_result.uri(),
2917                    versions,
2918                    &content,
2919                    &MockRegistry,
2920                    &ExactMatchFormatter,
2921                )
2922                .await;
2923
2924                assert!(
2925                    quickfix_titles(&actions).is_empty(),
2926                    "expected no unsatisfiable-fix quickfix for latest {unsafe_latest:?}"
2927                );
2928            }
2929        }
2930
2931        #[tokio::test]
2932        async fn test_unsat_fix_absent_for_non_resolvable_source() {
2933            // Mirrors the diagnostic's own `is_version_resolvable` call-site guard
2934            // (#248): a path/git/SDK/workspace dependency's cache entry may be
2935            // coincidental, so no fix action should be offered for it either.
2936            let content = "1.0.0";
2937            let dep = UnsatDep {
2938                name: pkg("pkg"),
2939                version_req: VersionReq::new("1.0.0"),
2940                version_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2941                source: crate::parser::DependencySource::Path {
2942                    path: "../local".into(),
2943                },
2944            };
2945            let pr = UnsatParseResult {
2946                dep,
2947                uri: crate::test_util::test_uri("/test/Cargo.toml"),
2948            };
2949
2950            let cached = cached_versions("9.9.9", &["9.9.9"]);
2951            let resolved = HashMap::new();
2952            let versions = VersionData::new(&cached, &resolved);
2953
2954            let actions = generate_code_actions(
2955                &pr,
2956                Position::new(0, 0),
2957                pr.uri(),
2958                versions,
2959                content,
2960                &MockRegistry,
2961                &ExactMatchFormatter,
2962            )
2963            .await;
2964
2965            assert!(quickfix_titles(&actions).is_empty());
2966        }
2967
2968        #[tokio::test]
2969        async fn test_unsat_fix_no_op_guard_skips_when_rewrite_equals_declared_text() {
2970            // `latest` already equals the declared requirement text (whitespace
2971            // aside) — nothing for the action to fix.
2972            let (dep, version_range, content) = vulnerable_dep("9.9.9");
2973            let parse_result = MockParseResult {
2974                deps: vec![dep],
2975                uri: crate::test_util::test_uri("/test/Cargo.toml"),
2976            };
2977            // Requirement "9.9.9" itself is unsatisfiable only if it isn't in
2978            // `available`; make it unsatisfiable via a *different* available set
2979            // but formatted rewrite identical to the declared text.
2980            let cached = cached_versions("9.9.9", &["1.0.0"]);
2981            let resolved = HashMap::new();
2982            let versions = VersionData::new(&cached, &resolved);
2983
2984            let actions = generate_code_actions(
2985                &parse_result,
2986                version_range.start,
2987                parse_result.uri(),
2988                versions,
2989                &content,
2990                &MockRegistry,
2991                &ExactMatchFormatter,
2992            )
2993            .await;
2994
2995            // "9.9.9" -> "9.9.9" is a no-op rewrite, so the action must be skipped
2996            // even though the requirement is genuinely unsatisfiable.
2997            assert!(quickfix_titles(&actions).is_empty());
2998        }
2999
3000        #[tokio::test]
3001        async fn test_unsat_fix_verification_guard_rejects_rewrite_that_stays_unsatisfiable() {
3002            // Critic M4: `format_version_replacing` can preserve operator style into
3003            // a rewrite that is itself still unsatisfiable (pypi/gradle-style). The
3004            // action must not be offered in that case.
3005            let (dep, version_range, content) = vulnerable_dep("1.0.0");
3006            let parse_result = MockParseResult {
3007                deps: vec![dep],
3008                uri: crate::test_util::test_uri("/test/Cargo.toml"),
3009            };
3010
3011            let cached = cached_versions("9.9.9", &["9.9.9"]);
3012            let resolved = HashMap::new();
3013            let versions = VersionData::new(&cached, &resolved);
3014
3015            let actions = generate_code_actions(
3016                &parse_result,
3017                version_range.start,
3018                parse_result.uri(),
3019                versions,
3020                &content,
3021                &MockRegistry,
3022                &NonFixingFormatter,
3023            )
3024            .await;
3025
3026            assert!(quickfix_titles(&actions).is_empty());
3027        }
3028
3029        #[tokio::test]
3030        async fn test_unsat_fix_resolves_cache_entry_via_raw_name_fallback() {
3031            // Critic M1: mirrors the diagnostic's `.get(normalized).or_else(|| .get(raw))`
3032            // lookup — an ecosystem whose `normalize_package_name` is not the identity
3033            // must still resolve a cache entry keyed by the raw declared name.
3034            let (dep, version_range, content) = vulnerable_dep("1.0.0");
3035            let parse_result = MockParseResult {
3036                deps: vec![dep],
3037                uri: crate::test_util::test_uri("/test/Cargo.toml"),
3038            };
3039
3040            // Keyed by the raw name "pkg", not "normalized-pkg".
3041            let cached = cached_versions_keyed("pkg", "9.9.9", &["9.9.9"]);
3042            let resolved = HashMap::new();
3043            let versions = VersionData::new(&cached, &resolved);
3044
3045            let actions = generate_code_actions(
3046                &parse_result,
3047                version_range.start,
3048                parse_result.uri(),
3049                versions,
3050                &content,
3051                &MockRegistry,
3052                &NormalizingExactFormatter,
3053            )
3054            .await;
3055
3056            assert_eq!(
3057                quickfix_titles(&actions),
3058                vec!["Fix unsatisfiable requirement: update to 9.9.9"]
3059            );
3060        }
3061
3062        #[tokio::test]
3063        async fn test_unsat_fix_dropped_when_target_is_yanked() {
3064            let (dep, version_range, content) = vulnerable_dep("1.0.0");
3065            let parse_result = MockParseResult {
3066                deps: vec![dep],
3067                uri: crate::test_util::test_uri("/test/Cargo.toml"),
3068            };
3069
3070            let cached = cached_versions("9.9.9", &["9.9.9"]);
3071            let resolved = HashMap::new();
3072            let versions = VersionData::new(&cached, &resolved);
3073            let registry = FixedVersionRegistry {
3074                versions: vec![("9.9.9", true)],
3075            };
3076
3077            let actions = generate_code_actions(
3078                &parse_result,
3079                version_range.start,
3080                parse_result.uri(),
3081                versions,
3082                &content,
3083                &registry,
3084                &ExactMatchFormatter,
3085            )
3086            .await;
3087
3088            assert!(quickfix_titles(&actions).is_empty());
3089        }
3090
3091        #[tokio::test]
3092        async fn test_unsat_fix_survives_registry_outage_and_is_preferred() {
3093            // S1: the single-exit restructure must not drop `isPreferred` on the
3094            // outage path when only the unsat fix (no vuln fix) is present.
3095            let (dep, version_range, content) = vulnerable_dep("1.0.0");
3096            let parse_result = MockParseResult {
3097                deps: vec![dep],
3098                uri: crate::test_util::test_uri("/test/Cargo.toml"),
3099            };
3100
3101            let cached = cached_versions("9.9.9", &["9.9.9"]);
3102            let resolved = HashMap::new();
3103            let versions = VersionData::new(&cached, &resolved);
3104
3105            let actions = generate_code_actions(
3106                &parse_result,
3107                version_range.start,
3108                parse_result.uri(),
3109                versions,
3110                &content,
3111                &ErrorRegistry,
3112                &ExactMatchFormatter,
3113            )
3114            .await;
3115
3116            assert_eq!(
3117                quickfix_titles(&actions),
3118                vec!["Fix unsatisfiable requirement: update to 9.9.9"]
3119            );
3120            assert_eq!(actions[0].is_preferred, Some(true));
3121        }
3122
3123        #[tokio::test]
3124        async fn test_vuln_and_unsat_fix_coexist_with_vuln_preferred() {
3125            use crate::osv::{
3126                Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
3127            };
3128
3129            let (dep, version_range, content) = vulnerable_dep("1.0.0");
3130            let parse_result = MockParseResult {
3131                deps: vec![dep],
3132                uri: crate::test_util::test_uri("/test/Cargo.toml"),
3133            };
3134
3135            let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
3136            vulnerabilities.insert(
3137                "pkg".to_string(),
3138                ScanOutcome::Vulnerable(DependencyVulnerabilities {
3139                    advisories: Capped::new(
3140                        vec![std::sync::Arc::new(Advisory {
3141                            id: "A1".to_string(),
3142                            modified: "2023-01-01T00:00:00Z".to_string(),
3143                            summary: None,
3144                            aliases: vec![],
3145                            severity: VulnSeverity::High,
3146                            cvss_vector: None,
3147                            fixed_versions: vec!["5.5.5".to_string()],
3148                            url: String::new(),
3149                        })],
3150                        1,
3151                    ),
3152                    fix_target_status: UpgradeStatus::CandidateClean {
3153                        version: "5.5.5".to_string(),
3154                    },
3155                    upgrade_status: UpgradeStatus::NotChecked,
3156                }),
3157            );
3158            // "9.9.9" (unsat target) differs from "5.5.5" (vuln target), so both
3159            // survive the text-collision drop.
3160            let cached = cached_versions("9.9.9", &["9.9.9", "5.5.5"]);
3161            let resolved = HashMap::new();
3162            let versions =
3163                VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
3164
3165            let actions = generate_code_actions(
3166                &parse_result,
3167                version_range.start,
3168                parse_result.uri(),
3169                versions,
3170                &content,
3171                &MockRegistry,
3172                &ExactMatchFormatter,
3173            )
3174            .await;
3175
3176            let titles = quickfix_titles(&actions);
3177            assert_eq!(titles.len(), 2, "expected both fixes: {titles:?}");
3178            assert!(titles.iter().any(|t| t.starts_with("Update to 5.5.5")));
3179            assert!(
3180                titles
3181                    .iter()
3182                    .any(|t| t.starts_with("Fix unsatisfiable requirement"))
3183            );
3184
3185            let vuln_action = actions
3186                .iter()
3187                .find(|a| a.title.starts_with("Update to 5.5.5"))
3188                .unwrap();
3189            let unsat_action = actions
3190                .iter()
3191                .find(|a| a.title.starts_with("Fix unsatisfiable"))
3192                .unwrap();
3193            assert_eq!(vuln_action.is_preferred, Some(true));
3194            assert_eq!(unsat_action.is_preferred, None);
3195            assert_eq!(
3196                actions
3197                    .iter()
3198                    .filter(|a| a.is_preferred == Some(true))
3199                    .count(),
3200                1
3201            );
3202        }
3203
3204        #[tokio::test]
3205        async fn test_unsat_fix_dropped_when_it_collides_with_vuln_fix_text() {
3206            // Plan §1.4: when both fixes would write byte-identical text, the vuln
3207            // fix (richer title) wins and the unsat fix is dropped.
3208            use crate::osv::{
3209                Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
3210            };
3211
3212            let (dep, version_range, content) = vulnerable_dep("1.0.0");
3213            let parse_result = MockParseResult {
3214                deps: vec![dep],
3215                uri: crate::test_util::test_uri("/test/Cargo.toml"),
3216            };
3217
3218            let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
3219            vulnerabilities.insert(
3220                "pkg".to_string(),
3221                ScanOutcome::Vulnerable(DependencyVulnerabilities {
3222                    advisories: Capped::new(
3223                        vec![std::sync::Arc::new(Advisory {
3224                            id: "A1".to_string(),
3225                            modified: "2023-01-01T00:00:00Z".to_string(),
3226                            summary: None,
3227                            aliases: vec![],
3228                            severity: VulnSeverity::High,
3229                            cvss_vector: None,
3230                            // Same target as the unsat fix's cached `latest` below.
3231                            fixed_versions: vec!["9.9.9".to_string()],
3232                            url: String::new(),
3233                        })],
3234                        1,
3235                    ),
3236                    fix_target_status: UpgradeStatus::CandidateClean {
3237                        version: "9.9.9".to_string(),
3238                    },
3239                    upgrade_status: UpgradeStatus::NotChecked,
3240                }),
3241            );
3242            let cached = cached_versions("9.9.9", &["9.9.9"]);
3243            let resolved = HashMap::new();
3244            let versions =
3245                VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
3246
3247            let actions = generate_code_actions(
3248                &parse_result,
3249                version_range.start,
3250                parse_result.uri(),
3251                versions,
3252                &content,
3253                &MockRegistry,
3254                &ExactMatchFormatter,
3255            )
3256            .await;
3257
3258            let titles = quickfix_titles(&actions);
3259            assert_eq!(titles.len(), 1, "unsat fix must be dropped: {titles:?}");
3260            assert!(titles[0].starts_with("Update to 9.9.9"));
3261        }
3262
3263        #[tokio::test]
3264        async fn test_yank_filter_runs_before_collision_check_so_neither_fix_is_lost() {
3265            // Critic M7: the vuln fix targets a *yanked* version and the unsat fix
3266            // targets a *different, live* version, but both format to identical
3267            // text (mirroring PyPI's `truncate_release_to_match`). Yank-filtering
3268            // both actions before the collision check must run first: it drops the
3269            // yanked vuln fix and leaves the live unsat fix as the sole survivor.
3270            // The wrong order (collision-first) would drop the unsat action for
3271            // "colliding" with a vuln fix that the yank filter was about to drop
3272            // anyway, leaving the user with neither action.
3273            use crate::osv::{
3274                Advisory, Capped, DependencyVulnerabilities, UpgradeStatus, VulnSeverity,
3275            };
3276
3277            let (dep, version_range, content) = vulnerable_dep("1.0.0");
3278            let parse_result = MockParseResult {
3279                deps: vec![dep],
3280                uri: crate::test_util::test_uri("/test/Cargo.toml"),
3281            };
3282
3283            let mut vulnerabilities = crate::osv::VulnerabilityMap::new();
3284            vulnerabilities.insert(
3285                "pkg".to_string(),
3286                ScanOutcome::Vulnerable(DependencyVulnerabilities {
3287                    advisories: Capped::new(
3288                        vec![std::sync::Arc::new(Advisory {
3289                            id: "A1".to_string(),
3290                            modified: "2023-01-01T00:00:00Z".to_string(),
3291                            summary: None,
3292                            aliases: vec![],
3293                            severity: VulnSeverity::High,
3294                            cvss_vector: None,
3295                            // Different raw version than the unsat fix's cached
3296                            // `latest` ("9.9.9") below, but `CollidingTextFormatter`
3297                            // rewrites both to the same "9.9.9" text.
3298                            fixed_versions: vec!["9.9.5".to_string()],
3299                            url: String::new(),
3300                        })],
3301                        1,
3302                    ),
3303                    fix_target_status: UpgradeStatus::CandidateClean {
3304                        version: "9.9.5".to_string(),
3305                    },
3306                    upgrade_status: UpgradeStatus::NotChecked,
3307                }),
3308            );
3309            // Unsat fix's own gate/verification data (distinct from the registry
3310            // below): unsatisfiable against "9.9.9", and the rewritten "9.9.9"
3311            // re-verifies as satisfiable since it's literally in `available`.
3312            let cached = cached_versions("9.9.9", &["9.9.9"]);
3313            let resolved = HashMap::new();
3314            let versions =
3315                VersionData::new(&cached, &resolved).with_vulnerabilities(&vulnerabilities);
3316            // Registry-reported yank status: the vuln fix's target is yanked, the
3317            // unsat fix's target is live.
3318            let registry = FixedVersionRegistry {
3319                versions: vec![("9.9.5", true), ("9.9.9", false)],
3320            };
3321
3322            let actions = generate_code_actions(
3323                &parse_result,
3324                version_range.start,
3325                parse_result.uri(),
3326                versions,
3327                &content,
3328                &registry,
3329                &CollidingTextFormatter,
3330            )
3331            .await;
3332
3333            let titles = quickfix_titles(&actions);
3334            assert_eq!(
3335                titles,
3336                vec!["Fix unsatisfiable requirement: update to 9.9.9"],
3337                "vuln fix must be dropped for being yanked, unsat fix must survive: {titles:?}"
3338            );
3339        }
3340
3341        #[tokio::test]
3342        async fn test_unsat_fix_dedups_refactor_item_writing_the_same_text() {
3343            let (dep, version_range, content) = vulnerable_dep("1.0.0");
3344            let parse_result = MockParseResult {
3345                deps: vec![dep],
3346                uri: crate::test_util::test_uri("/test/Cargo.toml"),
3347            };
3348
3349            let cached = cached_versions("9.9.9", &["9.9.9"]);
3350            let resolved = HashMap::new();
3351            let versions = VersionData::new(&cached, &resolved);
3352            // The registry's own "9.9.9" entry would otherwise become a REFACTOR
3353            // "update to 9.9.9" display item, byte-identical to the unsat fix's edit.
3354            let registry = FixedVersionRegistry {
3355                versions: vec![("9.9.9", false)],
3356            };
3357
3358            let actions = generate_code_actions(
3359                &parse_result,
3360                version_range.start,
3361                parse_result.uri(),
3362                versions,
3363                &content,
3364                &registry,
3365                &ExactMatchFormatter,
3366            )
3367            .await;
3368
3369            assert_eq!(quickfix_titles(&actions).len(), 1);
3370            assert!(
3371                refactor_titles(&actions).is_empty(),
3372                "the duplicate REFACTOR item must be suppressed by the dedup set: {actions:?}"
3373            );
3374        }
3375    }
3376}