Skip to main content

deps_cli/update/
mod.rs

1//! `deps-cli update`: default-mode planning and plan application.
2//!
3//! Also hosts the shared plan/outcome types both planners (this module's [`plan_updates`]
4//! and [`security`]'s `plan_security_updates`) produce (spec 068, #1329).
5
6pub mod ignore;
7pub mod security;
8
9use deps_core::ConcreteVersion;
10use deps_core::PackageName;
11use deps_core::VersionReq;
12use deps_core::edit::{
13    EditSpan, ManifestEdit, ManifestReparse, UnplannableReason, UpdateCandidate, UpdateKind,
14    apply_edits, classify_update, collect_update_candidates, dedup_overlapping_edits,
15};
16use deps_core::lsp_helpers::{
17    CooldownDisposition, EcosystemFormatter, FallbackEditVerdict, LatestVerdict, PackageVersions,
18    cooldown_disposition, fallback_edit_excludes_newer, latest_verdict,
19};
20use std::collections::HashMap;
21
22use crate::analyze::ManifestAnalysis;
23use ignore::IgnoreRules;
24
25/// A dependency's "current" version for `deps-cli update`'s report.
26///
27/// Replaces the historical three-way `String`/`""` convention (issue #1593) with an
28/// exhaustive state, so an unresolved current version can no longer be silently confused with
29/// a resolved empty-string one.
30///
31/// # Examples
32///
33/// ```
34/// use deps_cli::update::CurrentVersion;
35/// use deps_core::ConcreteVersion;
36/// use deps_core::edit::UpdateKind;
37///
38/// let current = CurrentVersion::Resolved(ConcreteVersion::from("1.0.0"));
39/// assert_eq!(
40///     current.update_kind_to(&ConcreteVersion::from("2.0.0")),
41///     UpdateKind::Major
42/// );
43/// assert_eq!(
44///     CurrentVersion::Unknown.update_kind_to(&ConcreteVersion::from("2.0.0")),
45///     UpdateKind::Unknown
46/// );
47/// ```
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum CurrentVersion {
50    /// A lockfile/registry-resolved in-use version.
51    Resolved(ConcreteVersion),
52    /// No in-use version could be resolved; falls back to the dependency's declared
53    /// requirement text (`--security-only` mode only — see `security`'s `security_current`).
54    Declared(VersionReq),
55    /// Neither a resolved in-use version nor a declared requirement was available.
56    Unknown,
57}
58
59impl From<Option<ConcreteVersion>> for CurrentVersion {
60    /// `Some` maps to [`Self::Resolved`], `None` to [`Self::Unknown`] — the default planner's
61    /// only two possible states (it never produces [`Self::Declared`]; only `--security-only`
62    /// does, via `security_current`).
63    fn from(value: Option<ConcreteVersion>) -> Self {
64        match value {
65            Some(version) => Self::Resolved(version),
66            None => Self::Unknown,
67        }
68    }
69}
70
71impl CurrentVersion {
72    /// Classifies the update from this current version to `target`.
73    ///
74    /// [`Self::Declared`] and [`Self::Unknown`] both classify as [`UpdateKind::Unknown`] —
75    /// behavior-identical to pre-#1593, though for two different reasons per variant: the
76    /// default planner (the only caller that ever classifies `current`) never produced
77    /// [`Self::Declared`] to begin with, and `--security-only` (the only caller that could)
78    /// never called [`classify_update`] on `current` at all before this refactor. Neither
79    /// caller's classification behavior changes; this method exists so a *future* caller
80    /// cannot classify a [`Self::Declared`] requirement range as if it were a resolved version.
81    ///
82    /// # Examples
83    ///
84    /// ```
85    /// use deps_cli::update::CurrentVersion;
86    /// use deps_core::{ConcreteVersion, VersionReq};
87    /// use deps_core::edit::UpdateKind;
88    ///
89    /// let declared = CurrentVersion::Declared(VersionReq::new("^1.0"));
90    /// assert_eq!(
91    ///     declared.update_kind_to(&ConcreteVersion::from("2.0.0")),
92    ///     UpdateKind::Unknown
93    /// );
94    /// ```
95    #[must_use]
96    pub fn update_kind_to(&self, target: &ConcreteVersion) -> UpdateKind {
97        match self {
98            Self::Resolved(version) => classify_update(version.as_str(), target.as_str()),
99            Self::Declared(_) | Self::Unknown => UpdateKind::Unknown,
100        }
101    }
102
103    /// Renders this version for the `--format json`/`table` wire boundary — [`Self::Unknown`]
104    /// renders as an empty string, matching this field's pre-#1593 `""` convention.
105    ///
106    /// Deliberately not [`std::fmt::Display`] (issue #1593 critic M3): a `Display` impl invites
107    /// a caller to reach for `.to_string().is_empty()` as an `Unknown` check, reintroducing the
108    /// same stringly-typed comparison this type exists to remove. This method is the one
109    /// sanctioned render boundary, used by `format::json`/`format::table` only.
110    pub(crate) fn render_text(&self) -> String {
111        match self {
112            Self::Resolved(version) => version.to_string(),
113            Self::Declared(req) => req.as_str().to_string(),
114            Self::Unknown => String::new(),
115        }
116    }
117}
118
119/// One completed `update` run's per-dependency plan.
120#[derive(Debug, Clone, Default)]
121pub struct UpdatePlan {
122    /// One entry per candidate dependency this planner considered.
123    pub items: Vec<PlannedUpdateItem>,
124}
125
126impl UpdatePlan {
127    /// Every item's [`ManifestEdit`], for items whose [`Outcome`] is [`Outcome::Applied`] —
128    /// what [`apply_plan`] writes.
129    fn applied_edits(&self) -> Vec<ManifestEdit> {
130        self.items
131            .iter()
132            .filter_map(|item| match &item.outcome {
133                Outcome::Applied { edit, .. } => Some(edit.clone()),
134                _ => None,
135            })
136            .collect()
137    }
138}
139
140/// One dependency's disposition in an [`UpdatePlan`].
141#[derive(Debug, Clone)]
142pub struct PlannedUpdateItem {
143    /// The dependency's declared (raw) name.
144    pub name: String,
145    /// The version this dependency is currently pinned to, its declared requirement text when
146    /// no concrete in-use version could be resolved, or [`CurrentVersion::Unknown`] when
147    /// neither is available.
148    pub current: CurrentVersion,
149    /// This item's disposition — the target version considered (even when no edit was
150    /// written) lives inside [`Self::outcome`]'s own variant (#1615: folding it into `Outcome`
151    /// makes a target only representable where the variant actually carries one, rather than
152    /// as a second, independently settable item-level field). Read it via [`Self::target`].
153    pub outcome: Outcome,
154    /// OSV advisory ids this item resolves. Populated in `--security-only` mode, and also in
155    /// default mode for a cooldown-fallback decision that names a `Flagged` `latest`/fallback
156    /// verdict (spec 075 FR-011/FR-012) — empty for an `Unverified` verdict, which carries no
157    /// advisory list to report.
158    pub advisory_ids: Vec<String>,
159    /// Whether a matching `[update].ignore` rule exists but was overridden (FR-008,
160    /// `--security-only` mode only — the rule never applies in default mode, since a match
161    /// there is reported via [`Outcome::Skipped`] with reason [`SkipReason::IgnoreRule`]
162    /// instead).
163    pub ignore_rule_overridden: bool,
164    /// A newer version excluded from this item's [`Self::target`] by an active GOSSIP cooldown
165    /// finding (issue #1521 item 1) — mirrors `check`'s identical
166    /// [`deps_core::lsp_helpers::PackageVersions::gossip_excluded_version`] attribution
167    /// (`crate::report::to_finding`). Populated only for [`Outcome::Applied`] items in default
168    /// mode; always `None` under `--security-only`, whose fix target never reads a
169    /// GOSSIP-filtered `latest` at all.
170    pub gossip_excluded_version: Option<deps_core::ConcreteVersion>,
171    /// Spec 075 FR-013: attribution for a cooldown-fallback decision — one field so
172    /// [`CooldownFallbackNote::AppliedInsteadOf`] and [`CooldownFallbackNote::Blocked`] can
173    /// never both be set for the same item. `None` when no fallback candidate was ever
174    /// consulted for this occurrence (`CooldownDisposition::NotEvaluated`/`Cleared`, or
175    /// `Blocked { fallback: None }`).
176    pub cooldown_fallback: Option<CooldownFallbackNote>,
177}
178
179/// Why [`PlannedUpdateItem::cooldown_fallback`] is set (spec 075 FR-013) — mirrors
180/// [`PlannedUpdateItem::gossip_excluded_version`]'s existing attribution-field shape.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub enum CooldownFallbackNote {
183    /// The fallback candidate was written; names the fresher, cooldown-blocked `latest` this
184    /// occurrence targeted instead.
185    AppliedInsteadOf(deps_core::ConcreteVersion),
186    /// A fallback candidate existed but was itself OSV-`Flagged`/`Unverified` (FR-011) and was
187    /// never written; names the blocked fallback version.
188    Blocked {
189        /// The blocked fallback candidate's version.
190        version: deps_core::ConcreteVersion,
191    },
192}
193
194/// A dependency's disposition within an [`UpdatePlan`].
195///
196/// # The invalid state this makes unrepresentable (#1349, #1615)
197///
198/// Before this type carried [`ManifestEdit`] directly, [`PlannedUpdateItem`] stored `outcome`
199/// and `edit: Option<ManifestEdit>` as two independent fields the caller had to keep in sync by
200/// convention (#1349) — that combination made `apply_plan` report success (`Ok(())`) without
201/// writing anything. `edit` was folded into [`Self::Applied`] to close that gap, but the
202/// dependency's target version stayed a second, independently settable field on
203/// [`PlannedUpdateItem`] itself, which reopened the same class of bug: an `Applied` item with no
204/// target. #1615 folds the target into each variant that actually carries one instead, so
205/// [`Self::Applied`] with no target fails to compile:
206///
207/// ```compile_fail
208/// use deps_cli::update::Outcome;
209/// use deps_core::edit::ManifestEdit;
210/// use deps_core::position::{Position, Range};
211///
212/// let outcome = Outcome::Applied {
213///     edit: ManifestEdit {
214///         range: Range::new(Position::new(0, 9), Position::new(0, 14)),
215///         new_text: "1.2.0".to_string(),
216///     },
217/// };
218/// ```
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub enum Outcome {
221    /// A fix plan existed and its edit was written (or would be, under `--dry-run`).
222    /// Contributes to exit 0. Carries the edit and its target version — #1349/#1615: this
223    /// makes "applied, but no edit/target to write" unrepresentable, where separate
224    /// `PlannedUpdateItem` fields previously let them drift out of sync (`apply_plan` would
225    /// report success without writing anything).
226    Applied {
227        /// The edit [`apply_plan`] writes.
228        edit: ManifestEdit,
229        /// The version this edit moves the dependency to.
230        target: ConcreteVersion,
231    },
232    /// Excluded from this run, for [`SkipReason`].
233    Skipped {
234        /// Why this candidate was skipped.
235        reason: SkipReason,
236        /// The target version considered for this occurrence, when one was known — `None` when
237        /// no concrete target exists (an unplannable latest, an ignore-rule skip of an
238        /// OSV-blocked fallback, or — under `--security-only` — a not-requested item).
239        target: Option<ConcreteVersion>,
240    },
241    /// (`--security-only` only) The dependency is `Vulnerable`, but its declared requirement
242    /// already admits the fix target, so no requirement-level edit exists — needs #1116.
243    /// Contributes to exit 1.
244    RequiresLockfileUpdate {
245        /// The already-admitted fix target.
246        target: ConcreteVersion,
247    },
248    /// (`--security-only` only) No verified fix could be written, for [`UnfixableReason`].
249    /// Contributes to exit 1.
250    Unfixable(UnfixableReason),
251}
252
253/// Why a default-mode candidate was skipped.
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum SkipReason {
256    /// A matching `[update].ignore` rule excluded this dependency (FR-006).
257    IgnoreRule,
258    /// `--package` was given and this dependency was not named (FR-005).
259    NotRequested,
260    /// The dependency is outdated but could not be safely rewritten — the registry's cached
261    /// `latest` value failed the safety gate, the declared span is not the literal
262    /// requirement text (e.g. a Maven `${property}` reference or a Gradle DSL variable/
263    /// version-catalog alias), or the formatter has no single unambiguous rewrite for it.
264    /// See [`deps_core::edit::UnplannableReason`] for which of the three applies.
265    NotSafelyEditable(deps_core::edit::UnplannableReason),
266    /// The edit overlapped another item's edit and was dropped by the apply-time dedup pass
267    /// (see [`dedup_applied_items`]) — a report never claims `applied` for something that was
268    /// not actually written (critic finding M2).
269    OverlapsAnotherEdit,
270    /// Spec 075 FR-014: nothing available clears the freshness cooldown for this occurrence —
271    /// either no fallback candidate exists at all (no lockfile-resolved in-use version to floor
272    /// the search, spec 075 OQ1's documented no-floor limitation; or the newest
273    /// cooldown-cleared candidate failed an ecosystem-safety/in-use-floor/requirement-floor
274    /// guard, FR-001/FR-002/FR-003), or a candidate exists but the fallback view shows it
275    /// already satisfies the declared requirement (FR-009, so there is nothing to rewrite), or
276    /// it was itself blocked for a non-OSV structural reason (FR-007's decision table). A
277    /// fallback candidate blocked by its own OSV verdict is reported separately as
278    /// [`Self::NotSafelyEditable`] (exit 1, FR-011) — never demoted to this routine, exit-0
279    /// pause. Since the engine (`deps-engine`) now computes a fallback candidate whenever one
280    /// exists, this is no longer the unconditional starvation risk it was before spec 075: a
281    /// package publishing faster than the cooldown window can still resolve via its fallback,
282    /// provided one clears every guard.
283    WithinFreshnessCooldown,
284}
285
286/// Why a `--security-only` candidate could not be fixed.
287///
288/// The three variants that reject a specific, known fix target (#1614) carry it inline, so
289/// `--security-only` output can report the rejected version instead of nothing; the two that
290/// mean no fix target was ever established carry none.
291#[derive(Debug, Clone, PartialEq, Eq)]
292pub enum UnfixableReason {
293    /// No independently-verified fix target exists: no advisory has a claimable fix, the fix
294    /// target failed the safety gate, or `deps_core::edit`'s internal fix-target verification
295    /// could not confirm it — see [`deps_core::edit::VulnFixSkip`] (FR-010).
296    NoVerifiedFix,
297    /// The dependency's registry fetch failed/timed out, or produced no `PackageVersions`
298    /// entry at all — the FR-011 two-signal, load-bearing rule.
299    FetchFailedOrAbsent,
300    /// The fix target is present in the registry's yanked list with a status that
301    /// [`deps_core::RemovalStatus::blocks_resolution`] (FR-012).
302    Yanked {
303        /// The yanked fix target that was rejected.
304        target: ConcreteVersion,
305    },
306    /// The declared requirement has a shape the formatter has no single unambiguous rewrite
307    /// for (e.g. a compound comma-separated Cargo requirement, #1566) *and* a requirement
308    /// matcher exists that has already confirmed the declared requirement does not admit the
309    /// fix target — distinct from [`Outcome::RequiresLockfileUpdate`], which means the
310    /// requirement already admits the fix and nothing needs rewriting at all. Conflating the
311    /// two would tell the operator to regenerate the lock file for a dependency that is still
312    /// vulnerable (#1566 S1).
313    UnsupportedRequirementShape {
314        /// The confirmed-excluded fix target.
315        target: ConcreteVersion,
316    },
317    /// The declared requirement's raw text exceeds `deps_core::lsp_helpers::MAX_REQUIREMENT_LEN`
318    /// (`deps_core::lsp_helpers::requirement_is_oversized`, #1472's CWE-400 defense-in-depth
319    /// bound) — a size-based fail-closed guard applied *before* a requirement matcher is ever
320    /// compiled, never itself a confirmed exclusion. Distinct from
321    /// [`Self::UnsupportedRequirementShape`] (#1578 S1): that variant means a matcher actually
322    /// ran and confirmed the declared requirement excludes the fix target, while this one means
323    /// the matcher never ran at all, so an oversized requirement that would in fact have
324    /// admitted the fix is still reported here rather than as
325    /// [`Outcome::RequiresLockfileUpdate`] — conflating the two would let a false "confirmed
326    /// excluded" claim reach the operator for a case that was never actually checked.
327    OversizedRequirement {
328        /// The fix target the oversized requirement was never checked against.
329        target: ConcreteVersion,
330    },
331}
332
333impl Outcome {
334    /// The FR-021 wire token (`--format json`'s `outcome` field, and the table's `[..]`
335    /// prefix): one of exactly `applied` / `skipped` / `requires-lockfile-update` /
336    /// `unfixable` — a [`SkipReason`]/[`UnfixableReason`]'s own detail is carried in
337    /// [`PlannedUpdateItem::reason`] instead, not folded into this token.
338    #[must_use]
339    pub const fn wire_token(&self) -> &'static str {
340        match self {
341            Self::Applied { .. } => "applied",
342            Self::Skipped { .. } => "skipped",
343            Self::RequiresLockfileUpdate { .. } => "requires-lockfile-update",
344            Self::Unfixable(_) => "unfixable",
345        }
346    }
347
348    /// This outcome's target version, when its variant carries one — the sole read path for a
349    /// dependency's considered/applied target (#1615), used by [`PlannedUpdateItem::target`] and
350    /// every formatter.
351    #[must_use]
352    pub const fn target(&self) -> Option<&ConcreteVersion> {
353        match self {
354            Self::Applied { target, .. } | Self::RequiresLockfileUpdate { target } => Some(target),
355            Self::Skipped { target, .. } => target.as_ref(),
356            Self::Unfixable(
357                UnfixableReason::Yanked { target }
358                | UnfixableReason::UnsupportedRequirementShape { target }
359                | UnfixableReason::OversizedRequirement { target },
360            ) => Some(target),
361            Self::Unfixable(
362                UnfixableReason::NoVerifiedFix | UnfixableReason::FetchFailedOrAbsent,
363            ) => None,
364        }
365    }
366}
367
368impl PlannedUpdateItem {
369    /// Builds a `PlannedUpdateItem` from its already-computed fields.
370    ///
371    /// Replaces the near-identical hand-rolled struct literals `security.rs`'s four
372    /// terminal-outcome builders and this module's own `resolve_occurrence` `build` closure
373    /// previously each constructed independently (issue #1551 finding 5) — a field change
374    /// affecting those two call sites now only requires touching this constructor. Fields stay
375    /// `pub` and other construction sites remain plain struct literals (e.g. `deps-cli`'s
376    /// `exit.rs`, this module's own tests, and the doctest above) — this constructor does not
377    /// make the type `#[non_exhaustive]` or migrate every existing literal to it.
378    #[must_use]
379    pub fn new(
380        name: String,
381        current: CurrentVersion,
382        outcome: Outcome,
383        advisory_ids: Vec<String>,
384        ignore_rule_overridden: bool,
385        gossip_excluded_version: Option<deps_core::ConcreteVersion>,
386        cooldown_fallback: Option<CooldownFallbackNote>,
387    ) -> Self {
388        Self {
389            name,
390            current,
391            outcome,
392            advisory_ids,
393            ignore_rule_overridden,
394            gossip_excluded_version,
395            cooldown_fallback,
396        }
397    }
398
399    /// This item's considered/applied target version — delegates to [`Outcome::target`], the
400    /// sole read path since #1615 folded the target into [`Self::outcome`]'s own variant.
401    #[must_use]
402    pub const fn target(&self) -> Option<&ConcreteVersion> {
403        self.outcome.target()
404    }
405
406    /// A one-line human-readable reason for [`Self::outcome`] (FR-021's `reason` field).
407    #[must_use]
408    pub fn reason(&self) -> String {
409        let base = match &self.outcome {
410            Outcome::Applied { .. } => "update applied",
411            Outcome::Skipped {
412                reason: SkipReason::IgnoreRule,
413                ..
414            } => "matched an [update].ignore rule",
415            Outcome::Skipped {
416                reason: SkipReason::NotRequested,
417                ..
418            } => "not named by --package",
419            Outcome::Skipped {
420                reason:
421                    SkipReason::NotSafelyEditable(
422                        deps_core::edit::UnplannableReason::UnsafeLatestVersion,
423                    ),
424                ..
425            } => "the registry-reported latest version failed a safety check",
426            Outcome::Skipped {
427                reason:
428                    SkipReason::NotSafelyEditable(deps_core::edit::UnplannableReason::NonLiteralSpan),
429                ..
430            } => {
431                "the declared version is not a plain literal (e.g. a property reference or variable) and cannot be safely rewritten"
432            }
433            Outcome::Skipped {
434                reason:
435                    SkipReason::NotSafelyEditable(deps_core::edit::UnplannableReason::NoOpRewrite),
436                ..
437            } => "no single unambiguous rewrite exists for this dependency's requirement syntax",
438            // Fix-cycle item 6/M1 minor: `NotSafelyEditable(LatestFlaggedByOsv|LatestUnverified)`
439            // is reused for a blocked FALLBACK candidate (row 10, FR-011) as well as a blocked
440            // `latest` — `UpdateCandidate` carries no marker distinguishing which view produced
441            // it, so the base text branches on `cooldown_fallback` instead of naming "latest"
442            // unconditionally (which previously read as self-contradictory once the `Blocked`
443            // attribution suffix below named the fallback candidate specifically).
444            Outcome::Skipped {
445                reason:
446                    SkipReason::NotSafelyEditable(
447                        deps_core::edit::UnplannableReason::LatestFlaggedByOsv,
448                    ),
449                ..
450            } => {
451                if matches!(
452                    self.cooldown_fallback,
453                    Some(CooldownFallbackNote::Blocked { .. })
454                ) {
455                    "the freshness-cooldown fallback candidate is flagged by OSV.dev — refusing to write it"
456                } else {
457                    "the registry's latest version is flagged by OSV.dev — refusing to write it"
458                }
459            }
460            Outcome::Skipped {
461                reason:
462                    SkipReason::NotSafelyEditable(deps_core::edit::UnplannableReason::LatestUnverified),
463                ..
464            } => {
465                if matches!(
466                    self.cooldown_fallback,
467                    Some(CooldownFallbackNote::Blocked { .. })
468                ) {
469                    "the freshness-cooldown fallback candidate could not be verified against OSV.dev — refusing to write it"
470                } else {
471                    "the registry's latest version could not be verified against OSV.dev — refusing to write it"
472                }
473            }
474            Outcome::Skipped {
475                reason: SkipReason::OverlapsAnotherEdit,
476                ..
477            } => "this edit's span overlapped another item's and was dropped",
478            Outcome::Skipped {
479                reason: SkipReason::WithinFreshnessCooldown,
480                ..
481            } => "the selected update target was published within the freshness cooldown window",
482            Outcome::RequiresLockfileUpdate { .. } => {
483                "declared requirement already admits the fix target; regenerate the lock file (see #1116)"
484            }
485            Outcome::Unfixable(UnfixableReason::NoVerifiedFix) => {
486                "no independently-verified fix target is available"
487            }
488            Outcome::Unfixable(UnfixableReason::FetchFailedOrAbsent) => {
489                "registry fetch for this dependency failed or returned no data"
490            }
491            Outcome::Unfixable(UnfixableReason::Yanked { .. }) => "the fix target is yanked",
492            Outcome::Unfixable(UnfixableReason::UnsupportedRequirementShape { .. }) => {
493                "the declared requirement's syntax has no safe single-value rewrite and does not already admit the fix target — manual edit required"
494            }
495            Outcome::Unfixable(UnfixableReason::OversizedRequirement { .. }) => {
496                "the declared requirement is too large to safely evaluate; treating as unfixable — manual edit required"
497            }
498        };
499        let mut reason = base.to_string();
500        if self.ignore_rule_overridden {
501            reason.push_str(" (a matching [update].ignore rule was overridden by --security-only)");
502        }
503        // Issue #1521 item 1: mirrors `crate::report::to_finding`'s identical GOSSIP-cooldown
504        // message attribution for `check`.
505        if self.gossip_excluded_version.is_some() {
506            reason.push_str(
507                " (a newer version was excluded from this pick by an active GOSSIP cooldown finding)",
508            );
509        }
510        // Spec 075 FR-013: attributes a cooldown-fallback decision on top of the base reason —
511        // `AppliedInsteadOf` names the fresher version this item bypassed; `Blocked` names the
512        // specific candidate the base text above already describes as blocked (see the
513        // `NotSafelyEditable` match arms' own fallback-aware wording).
514        match &self.cooldown_fallback {
515            Some(CooldownFallbackNote::AppliedInsteadOf(latest)) => {
516                reason.push_str(&format!(
517                    " (targeted a cooldown-cleared fallback instead of {latest}, which is still \
518                     within its freshness cooldown window)"
519                ));
520            }
521            Some(CooldownFallbackNote::Blocked { version }) => {
522                reason.push_str(&format!(" (candidate: {version})"));
523            }
524            None => {}
525        }
526        reason
527    }
528}
529
530/// `--package` narrowing input, matched after `formatter.normalize_package_name` on both
531/// sides (FR-005).
532#[must_use]
533pub fn is_requested(
534    package_filter: &[String],
535    normalized_name: &str,
536    formatter: &dyn EcosystemFormatter,
537) -> bool {
538    package_filter.is_empty()
539        || package_filter.iter().any(|name| {
540            formatter.normalize_package_name(&PackageName::new(name.clone())) == normalized_name
541        })
542}
543
544/// The cached registry data for `normalized_name` (or, failing that, `raw_name`) — the shared
545/// lookup [`gossip_excluded_version`] and the unified planner both need (code-review finding:
546/// previously each ran this same two-step `HashMap` lookup independently).
547fn cached_package_versions<'a>(
548    analysis: &'a ManifestAnalysis,
549    normalized_name: &str,
550    raw_name: &str,
551) -> Option<&'a deps_core::lsp_helpers::PackageVersions> {
552    analysis
553        .cached_versions
554        .get(normalized_name)
555        .or_else(|| analysis.cached_versions.get(raw_name))
556}
557
558/// The version [`deps_core::lsp_helpers::PackageVersions::gossip_excluded_version`] recorded
559/// for `normalized_name` (or, failing that, `raw_name`), when the registry fetch's spec 074
560/// GOSSIP-cooldown filter held one back from being `latest` (issue #1521 item 1) — mirrors
561/// `crate::report::to_finding`'s identical lookup for `check`'s own attribution.
562fn gossip_excluded_version(
563    analysis: &ManifestAnalysis,
564    normalized_name: &str,
565    raw_name: &str,
566) -> Option<deps_core::ConcreteVersion> {
567    cached_package_versions(analysis, normalized_name, raw_name)
568        .and_then(|v| v.gossip_excluded_version.clone())
569}
570
571/// Spec 075 FR-007/FR-008/FR-010: the fallback-substituted view [`plan_updates`] feeds through
572/// [`collect_update_candidates`] alongside the real `latest` view — only `latest` swapped for
573/// each dependency's stored cooldown-fallback candidate, when one exists.
574///
575/// Reuses [`ManifestAnalysis::cooldown_fallback_view`] when `analyze_manifest` already built it
576/// for its own FR-010 OSV round, rather than recomputing an identical view from scratch (issue
577/// #1551 finding 3) — that field already carries the same "something actually changed" gate
578/// `analyze_manifest`'s own OSV round applies (NFR-004: zero extra work when nothing is
579/// cooldown-blocked with a usable fallback). When the field was never populated (a caller that
580/// built [`ManifestAnalysis`] directly, e.g. this module's own tests), the identical gate is
581/// applied here instead of computing the view unconditionally — `None` either way means
582/// `plan_updates` skips [`collect_update_candidates`]'s full-manifest pass over the fallback
583/// view entirely, since [`resolve_occurrence`] only ever consults it when a dependency's own
584/// [`cooldown_disposition`] is `Blocked { fallback: Some(_), .. }`.
585///
586/// The reuse path (impl-critic m3) does *not* re-apply `freshness`/`now` to the already-built
587/// view — it relies on the caller passing the same values `analyze_manifest` used to build it,
588/// the same invariant [`ManifestAnalysis::now`]'s own doc already requires of every
589/// `plan_updates` caller. A caller that violates it only affects this reuse path, never the
590/// recompute-and-gate fallback below, which always uses its own `freshness`/`now` arguments.
591fn resolve_cooldown_fallback_view(
592    analysis: &ManifestAnalysis,
593    freshness: deps_core::FreshnessSettings,
594    now: deps_core::PublishTime,
595) -> Option<HashMap<PackageName, PackageVersions>> {
596    if let Some(view) = analysis.cooldown_fallback_view.as_ref() {
597        return Some(view.clone());
598    }
599    let view = crate::analyze::cooldown_fallback_view(
600        &analysis.cached_versions,
601        Some(&analysis.gossip_findings),
602        freshness,
603        now,
604    );
605    view.iter()
606        .any(|(name, v)| {
607            analysis
608                .cached_versions
609                .get(name)
610                .is_none_or(|c| c.latest != v.latest)
611        })
612        .then_some(view)
613}
614
615/// Default-mode planner: every dependency [`deps_core::edit::collect_update_candidates`]
616/// considers, narrowed by `--package` and `[update].ignore` (FR-003, FR-005, FR-006, FR-007).
617///
618/// An outdated dependency `collect_update_candidates` could not safely rewrite (a
619/// [`deps_core::edit::UpdateCandidate::Unplannable`] — an unsafe registry value, a
620/// non-literal span, or a formatter with no unambiguous rewrite) is still reported here as
621/// [`Outcome::Skipped`] with reason [`SkipReason::NotSafelyEditable`] rather than silently
622/// vanishing from the plan (spec 068 S4) — a `--package <NAME>` run naming exactly that
623/// dependency must not exit 0 with no signal.
624///
625/// `[update].ignore` rules are honored only when `ignore_rules` was actually built from an
626/// explicit `--config <path>` (FR-007) — callers pass [`IgnoreRules::empty`] otherwise, never
627/// an auto-discovered config's rules.
628///
629/// # Examples
630///
631/// ```
632/// use deps_cli::analyze::ManifestAnalysis;
633/// use deps_cli::update::ignore::IgnoreRules;
634/// use deps_cli::update::{Outcome, plan_updates};
635/// use deps_core::lsp_helpers::{
636///     DiagnosticMessages, DiagnosticPolicy, DependencyOutcomes, OsvNaming, PackageNaming,
637///     PackageRendering, PackageVersions, RequirementResolution, SourcePolicy,
638/// };
639/// use deps_core::parser::DependencySource;
640/// use deps_core::position::{Position, Range};
641/// use deps_core::{ConcreteVersion, Dependency, EcosystemId, ParseResult, PackageName, VersionReq};
642/// use std::any::Any;
643/// use std::collections::{HashMap, HashSet};
644///
645/// struct MockFormatter;
646/// impl PackageNaming for MockFormatter {}
647/// impl PackageRendering for MockFormatter {
648///     fn format_version_for_text_edit(&self, v: &ConcreteVersion) -> String { v.to_string() }
649///     fn package_url(&self, name: &PackageName) -> String { name.as_str().to_string() }
650/// }
651/// impl RequirementResolution for MockFormatter {}
652/// impl DiagnosticMessages for MockFormatter {}
653/// impl DiagnosticPolicy for MockFormatter {}
654/// impl SourcePolicy for MockFormatter {}
655/// impl OsvNaming for MockFormatter {}
656///
657/// struct MockDep { name: PackageName, version_req: VersionReq, version_range: Range }
658/// impl Dependency for MockDep {
659///     fn name(&self) -> &PackageName { &self.name }
660///     fn name_range(&self) -> Range { Range::default() }
661///     fn version_requirement(&self) -> Option<&VersionReq> { Some(&self.version_req) }
662///     fn version_range(&self) -> Option<Range> { Some(self.version_range) }
663///     fn source(&self) -> DependencySource { DependencySource::Registry }
664///     fn as_any(&self) -> &dyn Any { self }
665/// }
666///
667/// struct MockParseResult { deps: Vec<MockDep>, uri: url::Url }
668/// impl ParseResult for MockParseResult {
669///     fn dependencies(&self) -> Vec<&dyn Dependency> {
670///         self.deps.iter().map(|d| d as &dyn Dependency).collect()
671///     }
672///     fn workspace_root(&self) -> Option<&std::path::Path> { None }
673///     fn uri(&self) -> &url::Url { &self.uri }
674///     fn as_any(&self) -> &dyn Any { self }
675/// }
676///
677/// let content = r#"serde = "1.0.0""#;
678/// let mut cached_versions = HashMap::new();
679/// cached_versions.insert(PackageName::new("serde"), PackageVersions::latest_only("1.2.0"));
680///
681/// let analysis = ManifestAnalysis {
682///     parse_result: Box::new(MockParseResult {
683///         deps: vec![MockDep {
684///             name: PackageName::new("serde"),
685///             version_req: VersionReq::new("1.0.0"),
686///             version_range: Range::new(Position::new(0, 9), Position::new(0, 14)),
687///         }],
688///         uri: deps_core::test_util::test_uri("/test/Cargo.toml"),
689///     }),
690///     uri: deps_core::test_util::test_uri("/test/Cargo.toml"),
691///     now: deps_core::PublishTime::now(),
692///     ecosystem_id: EcosystemId::Cargo,
693///     cached_versions,
694///     resolved_versions: HashMap::new(),
695///     resolved_version_candidates: HashMap::new(),
696///     outcomes: DependencyOutcomes::new(),
697///     vulnerabilities: None,
698///     latest_status: None,
699///     fallback_status: None,
700///     cooldown_fallback_view: None,
701///     gossip_findings: HashMap::new(),
702///     licenses: HashMap::new(),
703///     license_policy: deps_core::licenses::LicensePolicy::default(),
704///     license_source: deps_core::LicenseSource::default(),
705///     network: deps_core::NetworkMode::Online,
706///     fetch_failed: HashSet::new(),
707///     registry_unreachable: false,
708///     license_fetch_incomplete: false,
709/// };
710///
711/// // Freshness is disabled by default, so this fixture never reaches the fallback-edit guard
712/// // — the closure is never actually called.
713/// let reparse = |_content: &str| -> Option<Box<dyn ParseResult>> { None };
714///
715/// let plan = plan_updates(
716///     &analysis,
717///     content,
718///     &MockFormatter,
719///     &reparse,
720///     &[],
721///     &IgnoreRules::empty(),
722///     deps_core::FreshnessSettings::default(),
723///     deps_core::PublishTime::now(),
724/// );
725///
726/// assert_eq!(plan.items.len(), 1);
727/// assert!(matches!(plan.items[0].outcome, Outcome::Applied { .. }));
728/// assert_eq!(plan.items[0].target(), Some(&ConcreteVersion::from("1.2.0")));
729/// ```
730#[must_use]
731#[expect(
732    clippy::too_many_arguments,
733    reason = "spec 076 T004 adds `reparse` (FR-024) to the existing FR-007 planner surface; \
734              grouping into a struct would only move, not reduce, churn (mirrors \
735              resolve_occurrence's own identical rationale)"
736)]
737pub fn plan_updates(
738    analysis: &ManifestAnalysis,
739    content: &str,
740    formatter: &dyn EcosystemFormatter,
741    reparse: &dyn ManifestReparse,
742    package_filter: &[String],
743    ignore_rules: &IgnoreRules,
744    freshness: deps_core::FreshnessSettings,
745    now: deps_core::PublishTime,
746) -> UpdatePlan {
747    let latest_candidates = collect_update_candidates(
748        analysis.parse_result.as_ref(),
749        content,
750        analysis.version_data(),
751        formatter,
752    );
753
754    // Spec 075 FR-007/FR-008: a fallback view of the registry data (only `latest` swapped for
755    // each dependency's stored cooldown-fallback candidate, when one exists) fed through the
756    // exact same planner — so a fallback candidate is verified/planned identically to `latest`,
757    // never through a separate code path. Only occurrences already present in
758    // `latest_candidates` (i.e. `Outdated` against real `latest`) ever look this up (FR-008).
759    // `resolve_cooldown_fallback_view` reuses `analysis.cooldown_fallback_view` when
760    // `analyze_manifest` already built it (issue #1551 finding 3), and gates the
761    // `collect_update_candidates` pass below on the same "something actually changed" check
762    // either way — `None` means no dependency is cooldown-blocked with a usable fallback.
763    let fallback_view = resolve_cooldown_fallback_view(analysis, freshness, now);
764    // Issue #1561 item 1 (defense-in-depth, library-API callers only — `deps-cli`'s own
765    // `analyze_manifest` always populates both together): an empty map, never a bare `None`,
766    // stands in for a missing `fallback_status` whenever `latest_status` IS populated — a bare
767    // `None` here would make `latest_verdict` treat every fallback candidate as
768    // `NotApplicable` ("no check needed"), silently bypassing OSV verification instead of
769    // failing closed to `Unverified` the way a genuinely never-checked candidate does.
770    let empty_fallback_status = deps_core::osv::LatestStatusMap::new();
771    let fallback_candidates = match fallback_view.as_ref() {
772        Some(view) => {
773            let mut fallback_version_data =
774                deps_core::VersionData::new(view, &analysis.resolved_versions)
775                    .with_resolved_version_candidates(&analysis.resolved_version_candidates)
776                    .with_ecosystem(analysis.ecosystem_id);
777            fallback_version_data = match analysis.fallback_status.as_ref() {
778                Some(fallback_status) => fallback_version_data.with_latest_status(fallback_status),
779                None if analysis.latest_status.is_some() => {
780                    fallback_version_data.with_latest_status(&empty_fallback_status)
781                }
782                None => fallback_version_data,
783            };
784            collect_update_candidates(
785                analysis.parse_result.as_ref(),
786                content,
787                fallback_version_data,
788                formatter,
789            )
790        }
791        None => Vec::new(),
792    };
793
794    // Base identity every dependency carries unconditionally — `(normalized_name, name_range)`
795    // — grouped (not collapsed) so a genuine collision (two dependencies sharing both, e.g.
796    // Gradle/Composer's degraded-position parsing) stays visible instead of one silently
797    // shadowing the other. `occurrence_key` resolves each occurrence's `version_range` via
798    // `Dependency::version_range()` uniformly, rather than from whichever `UpdateCandidate`
799    // variant (`Planned`'s `edit.range`, or nothing at all for `Unplannable`) a given view
800    // happened to produce — but only when the group has exactly one member: code review traced
801    // a path where the earlier single-dep-per-pair map let a collision's downgrade guard
802    // (`fallback_satisfies_requirement`) silently run against the WRONG occurrence's declared
803    // requirement. `None` on a detected collision is deliberate — every lookup keyed on it
804    // (the downgrade guard, OSV advisory attribution) then fails closed instead of guessing.
805    let mut deps_by_name_range: HashMap<
806        (String, deps_core::position::Range),
807        Vec<&dyn deps_core::Dependency>,
808    > = HashMap::new();
809    for dep in analysis.parse_result.dependencies() {
810        deps_by_name_range
811            .entry((
812                formatter.normalize_package_name(dep.name()),
813                dep.name_range(),
814            ))
815            .or_default()
816            .push(dep);
817    }
818    let occurrence_key = |normalized_name: &str, name_range: deps_core::position::Range| {
819        let version_range = match deps_by_name_range
820            .get(&(normalized_name.to_string(), name_range))
821            .map(Vec::as_slice)
822        {
823            Some([dep]) => dep.version_range(),
824            _ => None,
825        };
826        (normalized_name.to_string(), name_range, version_range)
827    };
828
829    // Same collision guard as `dep_by_key` below: a colliding occurrence's fallback candidate is
830    // excluded rather than collapsed with the other's, so both occurrences fall through to the
831    // same "fallback absent" (row 6) handling instead of one silently stealing the other's
832    // `SkipReason`/attribution in the `Unplannable` branch (impl-critic, rows 8/10/11).
833    let mut fallback_by_key: HashMap<OccurrenceKey, UpdateCandidate> = fallback_candidates
834        .into_iter()
835        .filter(|c| {
836            let (normalized_name, name_range) = candidate_identity(c);
837            deps_by_name_range
838                .get(&(normalized_name, name_range))
839                .is_some_and(|deps| deps.len() == 1)
840        })
841        .map(|c| {
842            let (normalized_name, name_range) = candidate_identity(&c);
843            (occurrence_key(&normalized_name, name_range), c)
844        })
845        .collect();
846
847    let dep_by_key: HashMap<OccurrenceKey, &dyn deps_core::Dependency> = deps_by_name_range
848        .iter()
849        .filter_map(
850            |(&(ref normalized_name, name_range), deps)| match deps.as_slice() {
851                [dep] => Some((occurrence_key(normalized_name, name_range), *dep)),
852                _ => None,
853            },
854        )
855        .collect();
856
857    let vuln_keys = deps_core::osv::vulnerability_keys(
858        analysis.parse_result.as_ref(),
859        &analysis.resolved_versions,
860        Some(&analysis.resolved_version_candidates),
861        formatter,
862        analysis.ecosystem_id,
863    );
864
865    let mut items: Vec<PlannedUpdateItem> = latest_candidates
866        .into_iter()
867        .map(|latest_candidate| {
868            let (normalized_name, name_range) = candidate_identity(&latest_candidate);
869            let key = occurrence_key(&normalized_name, name_range);
870            resolve_occurrence(
871                latest_candidate,
872                key,
873                &mut fallback_by_key,
874                &dep_by_key,
875                analysis,
876                content,
877                formatter,
878                reparse,
879                package_filter,
880                ignore_rules,
881                freshness,
882                &vuln_keys,
883                now,
884            )
885        })
886        .collect();
887
888    // FR-015: the overlap-collapsing pass runs AFTER per-occurrence view selection, over the
889    // chosen `PlannedUpdate`s only — never over both views' raw candidates. This also makes
890    // `SkipReason::OverlapsAnotherEdit` reachable from this planner for the first time (it
891    // previously only ever came from `security::plan_security_updates`'s own items, since this
892    // planner's pre-#1543 pre-classification dedup dropped an overlapping candidate silently,
893    // with no `PlannedUpdateItem` at all).
894    dedup_applied_items(&mut items);
895
896    UpdatePlan { items }
897}
898
899/// Spec 075 FR-007: one manifest occurrence's join key between the latest and fallback views —
900/// `(normalized_name, name_range, version_range)` rather than `name_range` alone (fix-cycle
901/// item 6/M2 minor, strengthened per code review): some formatters' degraded parsing paths
902/// (Gradle's `find_name_range`, Composer's AST-degraded path) can return `Range::default()` for
903/// more than one occurrence in the same manifest — a bare `name_range` collision would hand one
904/// occurrence another package's fallback edit, and for two occurrences of the *same* package
905/// name, that could approve a downgrade against the WRONG occurrence's declared requirement
906/// (FR-003's guard would run with a `&dyn Dependency` borrowed from a different span).
907///
908/// `version_range` is always resolved from [`deps_core::Dependency::version_range`] itself (via
909/// [`plan_updates`]'s `occurrence_key` closure), never from a specific [`UpdateCandidate`]
910/// variant's own field — an occurrence that is `Planned` in one view and `Unplannable` in the
911/// other (rows 7/10, this feature's own main additions) must still resolve to the identical key
912/// in both, or the cross-view join breaks. This discriminates two occurrences of the same
913/// package whenever their version text sits at different positions — true in every realistic
914/// case, since that is where the difference between two declarations actually lives even when
915/// both degrade to the same synthetic name range. The residual gap this cannot close is two
916/// occurrences of one package whose version text ALSO sits at the same (degraded) position;
917/// closing that fully needs the deeper per-occurrence identity plan.md's `OccurrenceCandidate`
918/// design implies.
919type OccurrenceKey = (
920    String,
921    deps_core::position::Range,
922    Option<deps_core::position::Range>,
923);
924
925/// [`UpdateCandidate`]'s `(normalized_name, name_range)` — the two fields every variant carries
926/// unconditionally, fed into [`plan_updates`]'s `occurrence_key` closure to resolve the full
927/// [`OccurrenceKey`] (including `version_range`) uniformly regardless of Planned/Unplannable.
928fn candidate_identity(candidate: &UpdateCandidate) -> (String, deps_core::position::Range) {
929    match candidate {
930        UpdateCandidate::Planned(p) => (p.normalized_name.clone(), p.name_range),
931        UpdateCandidate::Unplannable {
932            normalized_name,
933            name_range,
934            ..
935        } => (normalized_name.clone(), *name_range),
936    }
937}
938
939/// `(current, target)` for a `WithinFreshnessCooldown`/`NotRequested` item built directly from
940/// the latest view — a `Planned` candidate carries both; an `Unplannable` one carries neither
941/// (matches this planner's pre-#1543 convention for an item with no concrete write target).
942fn latest_current_target(candidate: &UpdateCandidate) -> (CurrentVersion, Option<ConcreteVersion>) {
943    match candidate {
944        UpdateCandidate::Planned(p) => (
945            CurrentVersion::from(p.current.clone()),
946            Some(p.target.clone()),
947        ),
948        UpdateCandidate::Unplannable { .. } => (CurrentVersion::Unknown, None),
949    }
950}
951
952/// Spec 075 FR-004 rows 1/2/3/5/8 (§6): resolves an occurrence purely from the latest view —
953/// `disposition` is `NotEvaluated`/`Cleared`, or `Blocked` with no usable fallback and `latest`
954/// is itself OSV-unplannable ("never demoted" by a cooldown skip). Mirrors this planner's
955/// pre-#1543 `planned`/`unplannable` loop bodies exactly, minus the `is_requested` check (the
956/// caller already ran it once for both views) and minus any cooldown check (the caller's
957/// disposition match already decided this occurrence reaches this function at all).
958fn resolve_from_latest(
959    latest_candidate: UpdateCandidate,
960    ignore_rules: &IgnoreRules,
961) -> (CurrentVersion, Outcome, Vec<String>) {
962    match latest_candidate {
963        UpdateCandidate::Planned(p) => {
964            let target = p.target.clone();
965            let current = CurrentVersion::from(p.current);
966            let kind = current.update_kind_to(&p.target);
967            if let Some(reason) = ignore_rules.skip_reason(&p.normalized_name, kind) {
968                (
969                    current,
970                    Outcome::Skipped {
971                        reason,
972                        target: Some(target),
973                    },
974                    Vec::new(),
975                )
976            } else {
977                (
978                    current,
979                    Outcome::Applied {
980                        edit: p.edit,
981                        target,
982                    },
983                    Vec::new(),
984                )
985            }
986        }
987        UpdateCandidate::Unplannable {
988            normalized_name,
989            reason,
990            ..
991        } => {
992            let outcome = if let Some(rule_reason) =
993                ignore_rules.skip_reason(&normalized_name, UpdateKind::Unknown)
994            {
995                Outcome::Skipped {
996                    reason: rule_reason,
997                    target: None,
998                }
999            } else {
1000                Outcome::Skipped {
1001                    reason: SkipReason::NotSafelyEditable(reason),
1002                    target: None,
1003                }
1004            };
1005            (CurrentVersion::Unknown, outcome, Vec::new())
1006        }
1007    }
1008}
1009
1010/// Spec 075 FR-011/FR-012: `version`'s OSV verdict advisory ids, looked up against `status` —
1011/// empty unless the verdict is `Flagged` (an `Unverified` verdict has no advisory list; the two
1012/// call sites below never reach this helper for a `Verified`/`NotApplicable` version).
1013fn osv_advisory_ids(
1014    status: Option<&deps_core::osv::LatestStatusMap>,
1015    dep: Option<&dyn deps_core::Dependency>,
1016    vuln_keys: &deps_core::osv::VulnKeys,
1017    normalized_name: &str,
1018    version: &str,
1019    formatter: &dyn EcosystemFormatter,
1020) -> Vec<String> {
1021    let Some(dep) = dep else {
1022        return Vec::new();
1023    };
1024    match latest_verdict(
1025        status,
1026        dep,
1027        Some(vuln_keys),
1028        normalized_name,
1029        version,
1030        formatter,
1031    ) {
1032        LatestVerdict::Flagged { advisory_ids, .. } => advisory_ids,
1033        LatestVerdict::Unverified | LatestVerdict::Verified | LatestVerdict::NotApplicable => {
1034            Vec::new()
1035        }
1036    }
1037}
1038
1039/// Spec 075 FR-007: the unified per-occurrence planner pipeline. Builds `latest_candidate`'s
1040/// identity once, resolves this occurrence's [`CooldownDisposition`], and — only when it is
1041/// `Blocked` — consults `fallback_by_key` (removed so each fallback occurrence is used at most
1042/// once) to pick between `latest` and the fallback candidate per spec 075 §6's decision table,
1043/// before running `is_requested`/`ignore_rules` exactly once against the SELECTED target.
1044#[expect(
1045    clippy::too_many_arguments,
1046    reason = "every parameter is either the two views' shared lookup data or a planning \
1047              knob already threaded through plan_updates; grouping into a struct would only \
1048              move, not reduce, churn (mirrors deps-engine::classify::fetch's identical call)"
1049)]
1050fn resolve_occurrence(
1051    latest_candidate: UpdateCandidate,
1052    key: OccurrenceKey,
1053    fallback_by_key: &mut HashMap<OccurrenceKey, UpdateCandidate>,
1054    dep_by_key: &HashMap<OccurrenceKey, &dyn deps_core::Dependency>,
1055    analysis: &ManifestAnalysis,
1056    content: &str,
1057    formatter: &dyn EcosystemFormatter,
1058    reparse: &dyn ManifestReparse,
1059    package_filter: &[String],
1060    ignore_rules: &IgnoreRules,
1061    freshness: deps_core::FreshnessSettings,
1062    vuln_keys: &deps_core::osv::VulnKeys,
1063    now: deps_core::PublishTime,
1064) -> PlannedUpdateItem {
1065    let (name, normalized_name) = match &latest_candidate {
1066        UpdateCandidate::Planned(p) => (p.name.clone(), p.normalized_name.clone()),
1067        UpdateCandidate::Unplannable {
1068            name,
1069            normalized_name,
1070            ..
1071        } => (name.clone(), normalized_name.clone()),
1072    };
1073    let gossip_excluded = gossip_excluded_version(analysis, &normalized_name, &name);
1074    let fallback_candidate = fallback_by_key.remove(&key);
1075
1076    let build = |current: CurrentVersion,
1077                 outcome: Outcome,
1078                 advisory_ids: Vec<String>,
1079                 cooldown_fallback: Option<CooldownFallbackNote>| {
1080        PlannedUpdateItem::new(
1081            name.clone(),
1082            current,
1083            outcome,
1084            advisory_ids,
1085            false,
1086            gossip_excluded.clone(),
1087            cooldown_fallback,
1088        )
1089    };
1090
1091    if !is_requested(package_filter, &normalized_name, formatter) {
1092        let (current, target) = latest_current_target(&latest_candidate);
1093        return build(
1094            current,
1095            Outcome::Skipped {
1096                reason: SkipReason::NotRequested,
1097                target,
1098            },
1099            Vec::new(),
1100            None,
1101        );
1102    }
1103
1104    let package_versions = cached_package_versions(analysis, &normalized_name, &name);
1105    // Security M1: the RAW name — matches `gossip_findings`/`apply_outdated_rule`'s own keying;
1106    // the normalized name silently hid GOSSIP data for case-changing ecosystems (#1529).
1107    let gossip_name = PackageName::new(name.clone());
1108    let disposition = package_versions.map_or(CooldownDisposition::NotEvaluated, |versions| {
1109        cooldown_disposition(
1110            versions,
1111            &gossip_name,
1112            freshness,
1113            Some(&analysis.gossip_findings),
1114            now,
1115        )
1116    });
1117    let latest_is_osv_unplannable = matches!(
1118        &latest_candidate,
1119        UpdateCandidate::Unplannable {
1120            reason: UnplannableReason::LatestFlaggedByOsv | UnplannableReason::LatestUnverified,
1121            ..
1122        }
1123    );
1124    // Fix-cycle item 6 (DRY): "never demote a flagged/unverified latest" is the shared shape
1125    // behind rows 5/6/8/FR-003-reject — one closure instead of four hand-rolled copies.
1126    let never_demoted = |latest_candidate: UpdateCandidate| {
1127        let (current, outcome, advisory_ids) = resolve_from_latest(latest_candidate, ignore_rules);
1128        build(current, outcome, advisory_ids, None)
1129    };
1130    // DRY (code review finding): the routine "cooldown pause, targeting whatever `latest`
1131    // itself carries" shape recurs across rows 4/6/(fb.target-mismatch)/(requirement rejected)/11
1132    // — one closure instead of five hand-rolled copies.
1133    let cooldown_skip_from_latest = |latest_candidate: &UpdateCandidate| {
1134        let (current, target) = latest_current_target(latest_candidate);
1135        build(
1136            current,
1137            Outcome::Skipped {
1138                reason: SkipReason::WithinFreshnessCooldown,
1139                target,
1140            },
1141            Vec::new(),
1142            None,
1143        )
1144    };
1145
1146    match disposition {
1147        CooldownDisposition::NotEvaluated | CooldownDisposition::Cleared => {
1148            let (current, outcome, advisory_ids) =
1149                resolve_from_latest(latest_candidate, ignore_rules);
1150            build(current, outcome, advisory_ids, None)
1151        }
1152        CooldownDisposition::Blocked { fallback: None, .. } => {
1153            if latest_is_osv_unplannable {
1154                // Row 5: never demoted by a routine cooldown pause.
1155                never_demoted(latest_candidate)
1156            } else {
1157                // Row 4.
1158                cooldown_skip_from_latest(&latest_candidate)
1159            }
1160        }
1161        // `by` (GOSSIP vs. local) is deliberately unread here: the wording difference is
1162        // `cooldown_disposition`'s own read-time concern (`apply_outdated_rule`), not this
1163        // planner's — it only ever decides fallback-vs-latest, never which blocker to name.
1164        CooldownDisposition::Blocked {
1165            by: _,
1166            fallback: Some(fallback),
1167        } => {
1168            let latest_version = package_versions.map(|v| v.latest.clone());
1169            let available: &[deps_core::ConcreteVersion] =
1170                package_versions.map_or(&[], |v| &v.available);
1171            match fallback_candidate {
1172                // Row 6 (FR-009): absent from the fallback view means the fallback already
1173                // satisfies the declared requirement — never silently treat that as "use
1174                // latest anyway". Fix-cycle item 2/S2: never demote a flagged/unverified
1175                // latest to a routine cooldown pause just because no fallback view entry
1176                // exists — that regresses #1517's "never masked by cooldown" invariant.
1177                None => {
1178                    if latest_is_osv_unplannable {
1179                        never_demoted(latest_candidate)
1180                    } else {
1181                        cooldown_skip_from_latest(&latest_candidate)
1182                    }
1183                }
1184                Some(UpdateCandidate::Planned(fb)) => {
1185                    // Issue #1561 item 2 (defense-in-depth): `fb.target` (the fallback view's
1186                    // own planned edit) and `fallback.version` (this occurrence's own
1187                    // `cooldown_disposition` pick, computed independently above) must always
1188                    // agree by construction — both ultimately derive from the same stored
1189                    // `CooldownFallback`. Never write an edit neither computation fully
1190                    // vouches for; a divergence falls through to the same fail-closed handling
1191                    // as "fallback absent" above.
1192                    if fb.target != fallback.version {
1193                        return if latest_is_osv_unplannable {
1194                            never_demoted(latest_candidate)
1195                        } else {
1196                            cooldown_skip_from_latest(&latest_candidate)
1197                        };
1198                    }
1199                    // Spec 076 FR-022/FR-023/FR-025: one uniform, re-parse-based guard applied
1200                    // identically to the `Located` (this occurrence has a lockfile-resolved
1201                    // in-use version) and `Absent` (spec 076's no-lockfile) paths — no per-
1202                    // ecosystem override, no Go exception (removed, FR-022: `ExactMatcher`
1203                    // alone is sufficient once this guard ships).
1204                    let requirement_ok = dep_by_key.get(&key).copied().is_some_and(|dep| {
1205                        let verdict = fallback_edit_excludes_newer(
1206                            formatter, reparse, content, dep, &fb.edit, &fb.target, available,
1207                        );
1208                        if let FallbackEditVerdict::Rejected(reason) = verdict {
1209                            tracing::debug!(
1210                                package = %normalized_name,
1211                                fallback = %fb.target,
1212                                ?reason,
1213                                "fallback edit rejected by fallback_edit_excludes_newer"
1214                            );
1215                        }
1216                        matches!(verdict, FallbackEditVerdict::Writable)
1217                    });
1218                    if requirement_ok {
1219                        // Rows 7 & 9 (fix-cycle item 3/S3): `ignore_rules` now runs against
1220                        // the SELECTED (fallback) target unconditionally, regardless of
1221                        // whether latest is OSV-blocked — previously row 7 wrote the fallback
1222                        // edit without ever consulting `[update].ignore`.
1223                        let current = CurrentVersion::from(fb.current);
1224                        let kind = current.update_kind_to(&fb.target);
1225                        if let Some(reason) = ignore_rules.skip_reason(&fb.normalized_name, kind) {
1226                            build(
1227                                current,
1228                                Outcome::Skipped {
1229                                    reason,
1230                                    target: Some(fb.target.clone()),
1231                                },
1232                                Vec::new(),
1233                                None,
1234                            )
1235                        } else if latest_is_osv_unplannable {
1236                            // Row 7 (FR-012/OQ3): target the fallback, keep the
1237                            // flagged/unverified latest's own attribution in the same row.
1238                            let advisory_ids =
1239                                latest_version.as_ref().map_or_else(Vec::new, |latest| {
1240                                    osv_advisory_ids(
1241                                        analysis.latest_status.as_ref(),
1242                                        dep_by_key.get(&key).copied(),
1243                                        vuln_keys,
1244                                        &normalized_name,
1245                                        latest.as_str(),
1246                                        formatter,
1247                                    )
1248                                });
1249                            build(
1250                                current,
1251                                Outcome::Applied {
1252                                    edit: fb.edit,
1253                                    target: fb.target,
1254                                },
1255                                advisory_ids,
1256                                latest_version.map(CooldownFallbackNote::AppliedInsteadOf),
1257                            )
1258                        } else {
1259                            // Row 9: the ordinary cooldown-fallback substitution.
1260                            build(
1261                                current,
1262                                Outcome::Applied {
1263                                    edit: fb.edit,
1264                                    target: fb.target,
1265                                },
1266                                Vec::new(),
1267                                latest_version.map(CooldownFallbackNote::AppliedInsteadOf),
1268                            )
1269                        }
1270                    } else {
1271                        // FR-003 (A1) / fix-cycle item 2/S2: the compiled matcher rejects the
1272                        // fallback as a downgrade (or is unavailable) — never write it, and
1273                        // never demote a flagged/unverified latest here either.
1274                        if latest_is_osv_unplannable {
1275                            never_demoted(latest_candidate)
1276                        } else {
1277                            cooldown_skip_from_latest(&latest_candidate)
1278                        }
1279                    }
1280                }
1281                // FR-011: an OSV-flagged/unverified fallback must surface here even when
1282                // `fallback_edit_excludes_newer` (never consulted in this arm) would also reject it.
1283                Some(UpdateCandidate::Unplannable {
1284                    reason: fb_reason, ..
1285                }) => {
1286                    if latest_is_osv_unplannable {
1287                        // Row 8: both blocked — defer to latest's own attribution, exit 1,
1288                        // never demoted regardless of why the fallback also failed.
1289                        never_demoted(latest_candidate)
1290                    } else if let Some(rule_reason) =
1291                        ignore_rules.skip_reason(&normalized_name, UpdateKind::Unknown)
1292                    {
1293                        // Fix-cycle item 4/S4: an ignored package's OSV-blocked fallback must
1294                        // not exit non-zero — mirrors `resolve_from_latest`'s identical
1295                        // Unplannable-candidate ignore-rule check (there is no concrete
1296                        // current/target pair here either, the same fail-closed
1297                        // `UpdateKind::Unknown` treatment applies).
1298                        build(
1299                            CurrentVersion::Unknown,
1300                            Outcome::Skipped {
1301                                reason: rule_reason,
1302                                target: None,
1303                            },
1304                            Vec::new(),
1305                            None,
1306                        )
1307                    } else if matches!(
1308                        fb_reason,
1309                        UnplannableReason::LatestFlaggedByOsv | UnplannableReason::LatestUnverified
1310                    ) {
1311                        // Row 10 (FR-011): the fallback itself is OSV-blocked — exit 1, naming
1312                        // the fallback version, never a silent cooldown skip.
1313                        let advisory_ids = osv_advisory_ids(
1314                            analysis.fallback_status.as_ref(),
1315                            dep_by_key.get(&key).copied(),
1316                            vuln_keys,
1317                            &normalized_name,
1318                            fallback.version.as_str(),
1319                            formatter,
1320                        );
1321                        let (current, _) = latest_current_target(&latest_candidate);
1322                        build(
1323                            current,
1324                            Outcome::Skipped {
1325                                reason: SkipReason::NotSafelyEditable(fb_reason),
1326                                target: Some(fallback.version.clone()),
1327                            },
1328                            advisory_ids,
1329                            Some(CooldownFallbackNote::Blocked {
1330                                version: fallback.version.clone(),
1331                            }),
1332                        )
1333                    } else {
1334                        // Row 11: fallback blocked by a non-OSV structural reason.
1335                        cooldown_skip_from_latest(&latest_candidate)
1336                    }
1337                }
1338            }
1339        }
1340    }
1341}
1342
1343/// Error applying an [`UpdatePlan`] to disk.
1344#[derive(Debug, thiserror::Error)]
1345pub enum ApplyError {
1346    /// The manifest changed on disk between planning and writing (FR-019) — the byte-compare
1347    /// against the content the plan's ranges were computed against failed.
1348    #[error("{path} changed on disk since it was read; aborting without writing")]
1349    StaleManifest {
1350        /// The manifest path.
1351        path: std::path::PathBuf,
1352    },
1353    /// Re-reading the manifest for the TOCTOU check failed.
1354    #[error("failed to re-read {path}: {source}")]
1355    Read {
1356        /// The manifest path.
1357        path: std::path::PathBuf,
1358        /// The underlying I/O error.
1359        #[source]
1360        source: std::io::Error,
1361    },
1362    /// [`deps_core::fs_probe::write_atomic`] failed (including a symlink refusal).
1363    #[error("failed to write {path}: {source}")]
1364    Write {
1365        /// The manifest path.
1366        path: std::path::PathBuf,
1367        /// The underlying I/O error.
1368        #[source]
1369        source: std::io::Error,
1370    },
1371}
1372
1373/// Deduplicates `items`' [`Outcome::Applied`] edits by span in place, demoting any item whose
1374/// edit was dropped as an overlap to
1375/// [`Outcome::Skipped`] with reason [`SkipReason::OverlapsAnotherEdit`].
1376///
1377/// Spec 075 FR-015: [`plan_updates`] now calls this itself, after per-occurrence view
1378/// selection, over its own chosen `PlannedUpdate`s — the correct place for this pass to run
1379/// (never over both the latest and fallback views' raw candidates together). Still
1380/// load-bearing for [`security::plan_security_updates`], which does not dedup its own
1381/// `Applied` items (two vulnerable occurrences of one name can share a span), and for
1382/// `main.rs`'s own defensive call after either planner returns. Without this,
1383/// [`apply_plan`]'s own dedup pass could silently drop an edit a planner had already reported
1384/// `applied`, breaking the "`applied` implies written" invariant `--format json` consumers
1385/// rely on (critic finding M2). Call this on a planner's output before reporting/rendering
1386/// it, not after — the whole point is that the *reported* outcome must match what
1387/// [`apply_plan`] will actually write.
1388pub fn dedup_applied_items(items: &mut [PlannedUpdateItem]) {
1389    struct Indexed {
1390        index: usize,
1391        edit: ManifestEdit,
1392    }
1393    impl EditSpan for Indexed {
1394        fn start(&self) -> (u32, u32) {
1395            self.edit.start()
1396        }
1397        fn end(&self) -> (u32, u32) {
1398            self.edit.end()
1399        }
1400    }
1401
1402    let indexed: Vec<Indexed> = items
1403        .iter()
1404        .enumerate()
1405        .filter_map(|(index, item)| match &item.outcome {
1406            Outcome::Applied { edit, .. } => Some(Indexed {
1407                index,
1408                edit: edit.clone(),
1409            }),
1410            _ => None,
1411        })
1412        .collect();
1413    let kept_indices: std::collections::HashSet<usize> =
1414        dedup_overlapping_edits(indexed, "deps-cli update dedup_applied_items")
1415            .into_iter()
1416            .map(|indexed| indexed.index)
1417            .collect();
1418
1419    for (index, item) in items.iter_mut().enumerate() {
1420        if !kept_indices.contains(&index)
1421            && let Outcome::Applied { target, .. } = &item.outcome
1422        {
1423            item.outcome = Outcome::Skipped {
1424                reason: SkipReason::OverlapsAnotherEdit,
1425                target: Some(target.clone()),
1426            };
1427        }
1428    }
1429}
1430
1431/// Applies `plan`'s [`Outcome::Applied`] edits to `path`, whose content the plan's ranges
1432/// were computed against was `original_content` (FR-016 through FR-020).
1433///
1434/// Always re-reads and byte-compares `path` against `original_content` before writing (FR-019)
1435/// — even under `dry_run`, so a `--dry-run` report never claims success for a plan a following
1436/// real run would actually reject. [`crate::format::DryRun::Yes`] skips the
1437/// [`deps_core::fs_probe::write_atomic`] call itself; so does a plan whose edits would produce
1438/// byte-identical content (no `Applied` items, or all-no-op edits) — skipping an unnecessary
1439/// rewrite avoids churning the file's mtime/inode for file watchers and rebuild systems (critic
1440/// finding M3).
1441///
1442/// # Errors
1443///
1444/// Returns [`ApplyError::StaleManifest`] if `path`'s content no longer matches
1445/// `original_content`, [`ApplyError::Read`] if the re-read fails, or [`ApplyError::Write`] if
1446/// [`deps_core::fs_probe::write_atomic`] fails (including refusing a symlinked `path`).
1447pub fn apply_plan(
1448    plan: &UpdatePlan,
1449    path: &std::path::Path,
1450    original_content: &str,
1451    dry_run: crate::format::DryRun,
1452) -> Result<(), ApplyError> {
1453    // Defensive: `dedup_applied_items` should already have run over `plan` before this is
1454    // called, so this is normally a no-op — kept as a safety net, not the primary mechanism.
1455    let edits = dedup_overlapping_edits(plan.applied_edits(), "deps-cli update");
1456    let new_content = apply_edits(original_content, &edits);
1457
1458    let reread = deps_core::fs_probe::read_to_string_capped(path, crate::MAX_MANIFEST_FILE_SIZE)
1459        .map_err(|source| ApplyError::Read {
1460            path: path.to_path_buf(),
1461            source,
1462        })?;
1463    if reread.as_deref() != Some(original_content) {
1464        return Err(ApplyError::StaleManifest {
1465            path: path.to_path_buf(),
1466        });
1467    }
1468
1469    if dry_run == crate::format::DryRun::Yes || new_content == original_content {
1470        return Ok(());
1471    }
1472
1473    deps_core::fs_probe::write_atomic(path, &new_content).map_err(|source| ApplyError::Write {
1474        path: path.to_path_buf(),
1475        source,
1476    })
1477}
1478
1479#[cfg(test)]
1480mod tests {
1481    use super::*;
1482    use deps_core::licenses::LicensePolicy;
1483    use deps_core::parser::DependencySource;
1484    use deps_core::position::{Position, Range};
1485    use deps_core::{Dependency, EcosystemId, PackageVersions, ParseResult};
1486    use std::any::Any;
1487    use std::collections::{HashMap, HashSet};
1488
1489    const STUB_FORMATTER: deps_core::test_util::StubFormatter =
1490        deps_core::test_util::StubFormatter::new().with_package_url_prefix("");
1491
1492    /// Issue #1593 critic M5: a `Declared` current version must never classify as anything
1493    /// but `Unknown` — guards against a future caller feeding it into a classification path
1494    /// (e.g. `ignore_rules.skip_reason`) that starts treating a declared range as comparable.
1495    #[test]
1496    fn test_current_version_declared_update_kind_is_always_unknown() {
1497        let declared = CurrentVersion::Declared(deps_core::VersionReq::new("^1.0"));
1498        assert_eq!(
1499            declared.update_kind_to(&ConcreteVersion::from("2.0.0")),
1500            UpdateKind::Unknown
1501        );
1502    }
1503
1504    struct TestDep {
1505        name: PackageName,
1506        version_req: deps_core::VersionReq,
1507        version_range: Range,
1508    }
1509    impl Dependency for TestDep {
1510        fn name(&self) -> &PackageName {
1511            &self.name
1512        }
1513        fn name_range(&self) -> Range {
1514            Range::default()
1515        }
1516        fn version_requirement(&self) -> Option<&deps_core::VersionReq> {
1517            Some(&self.version_req)
1518        }
1519        fn version_range(&self) -> Option<Range> {
1520            Some(self.version_range)
1521        }
1522        fn source(&self) -> DependencySource {
1523            DependencySource::Registry
1524        }
1525        fn as_any(&self) -> &dyn Any {
1526            self
1527        }
1528    }
1529
1530    struct TestParseResult {
1531        deps: Vec<TestDep>,
1532        uri: url::Url,
1533    }
1534    impl ParseResult for TestParseResult {
1535        fn dependencies(&self) -> Vec<&dyn Dependency> {
1536            self.deps.iter().map(|d| d as &dyn Dependency).collect()
1537        }
1538        fn workspace_root(&self) -> Option<&std::path::Path> {
1539            None
1540        }
1541        fn uri(&self) -> &url::Url {
1542            &self.uri
1543        }
1544        fn as_any(&self) -> &dyn Any {
1545            self
1546        }
1547    }
1548
1549    fn test_dep(name: &str, req: &str, range: Range) -> TestDep {
1550        TestDep {
1551            name: PackageName::new(name),
1552            version_req: deps_core::VersionReq::new(req),
1553            version_range: range,
1554        }
1555    }
1556
1557    /// Spec 076 T004/M6: a [`ManifestReparse`] stub that must never actually be called — for
1558    /// fixtures whose disposition never reaches [`fallback_edit_excludes_newer`]'s re-parse
1559    /// phase (freshness disabled, no fallback candidate, or the fallback view's own OSV/
1560    /// structural rejection resolves the occurrence before the guard ever runs).
1561    fn never_reparse(_content: &str) -> Option<Box<dyn ParseResult>> {
1562        unreachable!("this fixture's disposition must never reach the fallback-edit re-parse phase")
1563    }
1564
1565    /// Spec 076 M6: shared `ManifestReparse` test stub for this module's quoted-version,
1566    /// one-dependency-per-line fixtures (`name = "X"`). Rebuilds each `deps` entry's occurrence
1567    /// by reading back whatever version text now sits between ITS OWN quotes in the edited
1568    /// content, at the SAME position its original `version_range.start` had — genuinely
1569    /// reflects the edit `plan_updates` applied, rather than hard-coding the expected target
1570    /// (mirrors production `EcosystemReparse`'s re-parse-the-actual-edit contract).
1571    fn reparse_quoted_deps(deps: Vec<(&'static str, Position)>) -> impl ManifestReparse {
1572        move |edited_content: &str| -> Option<Box<dyn ParseResult>> {
1573            let rebuilt: Vec<TestDep> = deps
1574                .iter()
1575                .map(|&(name, start)| {
1576                    let line = edited_content.lines().nth(start.line as usize)?;
1577                    let rest = line.get(start.character as usize..)?;
1578                    let version_len = rest.find('"')?;
1579                    let version = rest.get(..version_len)?;
1580                    Some(TestDep {
1581                        name: PackageName::new(name),
1582                        version_req: deps_core::VersionReq::new(version),
1583                        version_range: Range::new(
1584                            start,
1585                            Position::new(
1586                                start.line,
1587                                start.character + u32::try_from(version_len).ok()?,
1588                            ),
1589                        ),
1590                    })
1591                })
1592                .collect::<Option<_>>()?;
1593            Some(Box::new(TestParseResult {
1594                deps: rebuilt,
1595                uri: deps_core::test_util::test_uri("/test/Cargo.toml"),
1596            }) as Box<dyn ParseResult>)
1597        }
1598    }
1599
1600    /// [`plan_updates`] with freshness cooldown filtering disabled — the pre-#1525 behavior
1601    /// every test not specifically about that filter wants.
1602    ///
1603    /// Critique M5: actually passes [`deps_core::FreshnessSettings::Disabled`], not
1604    /// [`deps_core::FreshnessSettings::default`] (which is enabled — a prior version of this
1605    /// helper passed that and only happened to work because every fixture omitted
1606    /// `published_at`; a fixture that later set one via [`PackageVersions::with_published_at`]
1607    /// would have been silently skipped instead of applied).
1608    fn plan_updates_no_cooldown(
1609        analysis: &ManifestAnalysis,
1610        content: &str,
1611        formatter: &dyn EcosystemFormatter,
1612        package_filter: &[String],
1613        ignore_rules: &IgnoreRules,
1614    ) -> UpdatePlan {
1615        plan_updates(
1616            analysis,
1617            content,
1618            formatter,
1619            &never_reparse,
1620            package_filter,
1621            ignore_rules,
1622            deps_core::FreshnessSettings::Disabled,
1623            deps_core::PublishTime::now(),
1624        )
1625    }
1626
1627    fn test_analysis(
1628        deps: Vec<TestDep>,
1629        cached: HashMap<PackageName, PackageVersions>,
1630    ) -> ManifestAnalysis {
1631        ManifestAnalysis {
1632            parse_result: Box::new(TestParseResult {
1633                deps,
1634                uri: deps_core::test_util::test_uri("/test/Cargo.toml"),
1635            }),
1636            uri: deps_core::test_util::test_uri("/test/Cargo.toml"),
1637            now: deps_core::PublishTime::now(),
1638            ecosystem_id: EcosystemId::Cargo,
1639            cached_versions: cached,
1640            resolved_versions: HashMap::new(),
1641            resolved_version_candidates: HashMap::new(),
1642            outcomes: deps_core::lsp_helpers::DependencyOutcomes::new(),
1643            vulnerabilities: None,
1644            latest_status: None,
1645            fallback_status: None,
1646            cooldown_fallback_view: None,
1647            gossip_findings: HashMap::new(),
1648            licenses: HashMap::new(),
1649            license_policy: LicensePolicy::default(),
1650            license_source: deps_core::LicenseSource::default(),
1651            network: deps_core::NetworkMode::Online,
1652            fetch_failed: HashSet::new(),
1653            registry_unreachable: false,
1654            license_fetch_incomplete: false,
1655        }
1656    }
1657
1658    fn cached(name: &str, latest: &str) -> HashMap<PackageName, PackageVersions> {
1659        let mut map = HashMap::new();
1660        map.insert(PackageName::new(name), PackageVersions::latest_only(latest));
1661        map
1662    }
1663
1664    /// Issue #1525: a `latest` published within `freshness.cooldown_secs` of `now` must be
1665    /// skipped as the update target in default mode, not silently applied — the empirically
1666    /// verified bug (`deps-cli update --cooldown N --dry-run` had no effect at all).
1667    #[test]
1668    fn test_plan_updates_within_freshness_cooldown_is_skipped() {
1669        let content = "serde = \"1.0.0\"\n";
1670        let now = deps_core::PublishTime::from_unix_secs(10_000);
1671        let published_at = deps_core::PublishTime::from_unix_secs(9_000); // 1000s old
1672        let mut versions = cached("serde", "1.2.0");
1673        versions.insert(
1674            PackageName::new("serde"),
1675            PackageVersions::latest_only("1.2.0").with_published_at(published_at),
1676        );
1677        let analysis = test_analysis(
1678            vec![test_dep(
1679                "serde",
1680                "1.0.0",
1681                Range::new(Position::new(0, 9), Position::new(0, 14)),
1682            )],
1683            versions,
1684        );
1685
1686        let plan = plan_updates(
1687            &analysis,
1688            content,
1689            &STUB_FORMATTER,
1690            &never_reparse,
1691            &[],
1692            &IgnoreRules::empty(),
1693            deps_core::FreshnessSettings::Enabled {
1694                cooldown: deps_core::CooldownWindow::from_secs(2_000), // wider than the 1000s age above
1695            },
1696            now,
1697        );
1698
1699        assert_eq!(plan.items.len(), 1);
1700        assert!(matches!(
1701            plan.items[0].outcome,
1702            Outcome::Skipped {
1703                reason: SkipReason::WithinFreshnessCooldown,
1704                ..
1705            }
1706        ));
1707        assert_eq!(
1708            crate::exit::update_exit_code(&plan),
1709            crate::exit::EXIT_CLEAN,
1710            "an automatic freshness-cooldown pause must not fail the run"
1711        );
1712    }
1713
1714    /// The same fixture, but `freshness.enabled = false`: the cooldown filter must be a
1715    /// complete no-op, mirroring how the local heuristic is opt-out everywhere else.
1716    #[test]
1717    fn test_plan_updates_freshness_disabled_ignores_cooldown() {
1718        let content = "serde = \"1.0.0\"\n";
1719        let now = deps_core::PublishTime::from_unix_secs(10_000);
1720        let published_at = deps_core::PublishTime::from_unix_secs(9_000);
1721        let mut versions = cached("serde", "1.2.0");
1722        versions.insert(
1723            PackageName::new("serde"),
1724            PackageVersions::latest_only("1.2.0").with_published_at(published_at),
1725        );
1726        let analysis = test_analysis(
1727            vec![test_dep(
1728                "serde",
1729                "1.0.0",
1730                Range::new(Position::new(0, 9), Position::new(0, 14)),
1731            )],
1732            versions,
1733        );
1734
1735        let plan = plan_updates(
1736            &analysis,
1737            content,
1738            &STUB_FORMATTER,
1739            &never_reparse,
1740            &[],
1741            &IgnoreRules::empty(),
1742            deps_core::FreshnessSettings::Disabled,
1743            now,
1744        );
1745
1746        assert_eq!(plan.items.len(), 1);
1747        assert!(matches!(plan.items[0].outcome, Outcome::Applied { .. }));
1748    }
1749
1750    /// Issue #1521 item 1: `update`'s output must attribute a GOSSIP-cooldown-excluded newer
1751    /// version on its `PlannedUpdateItem`, mirroring `check`'s equivalent message attribution.
1752    #[test]
1753    fn test_plan_updates_surfaces_gossip_excluded_version() {
1754        let content = "serde = \"1.0.0\"\n";
1755        let mut versions = cached("serde", "1.2.0");
1756        versions.insert(
1757            PackageName::new("serde"),
1758            PackageVersions::latest_only("1.2.0")
1759                .with_gossip_excluded_version(deps_core::ConcreteVersion::new("2.0.0")),
1760        );
1761        let analysis = test_analysis(
1762            vec![test_dep(
1763                "serde",
1764                "1.0.0",
1765                Range::new(Position::new(0, 9), Position::new(0, 14)),
1766            )],
1767            versions,
1768        );
1769
1770        let plan = plan_updates_no_cooldown(
1771            &analysis,
1772            content,
1773            &STUB_FORMATTER,
1774            &[],
1775            &IgnoreRules::empty(),
1776        );
1777
1778        assert_eq!(plan.items.len(), 1);
1779        assert!(matches!(plan.items[0].outcome, Outcome::Applied { .. }));
1780        assert_eq!(
1781            plan.items[0].gossip_excluded_version,
1782            Some(deps_core::ConcreteVersion::new("2.0.0"))
1783        );
1784        // Tester finding: an exact-suffix assertion (not just `.contains`) catches future
1785        // wording drift between this literal and `crate::report::to_finding`'s identical one.
1786        assert_eq!(
1787            plan.items[0].reason(),
1788            "update applied (a newer version was excluded from this pick by an active GOSSIP cooldown finding)"
1789        );
1790    }
1791
1792    /// Critique M1: when both the local freshness cooldown and a GOSSIP cooldown exclusion
1793    /// apply to the same candidate, the freshness skip wins the `outcome` decision (no
1794    /// per-version publish-time list exists here to reconsider the pick), but the GOSSIP
1795    /// attribution must still surface on the item — previously it was hardcoded to `None` on
1796    /// every skip branch, silently dropping which version GOSSIP had already excluded.
1797    #[test]
1798    fn test_plan_updates_freshness_cooldown_takes_precedence_but_keeps_gossip_attribution() {
1799        let content = "serde = \"1.0.0\"\n";
1800        let now = deps_core::PublishTime::from_unix_secs(10_000);
1801        let published_at = deps_core::PublishTime::from_unix_secs(9_000); // 1000s old
1802        let mut versions = cached("serde", "1.2.0");
1803        versions.insert(
1804            PackageName::new("serde"),
1805            PackageVersions::latest_only("1.2.0")
1806                .with_published_at(published_at)
1807                .with_gossip_excluded_version(deps_core::ConcreteVersion::new("2.0.0")),
1808        );
1809        let analysis = test_analysis(
1810            vec![test_dep(
1811                "serde",
1812                "1.0.0",
1813                Range::new(Position::new(0, 9), Position::new(0, 14)),
1814            )],
1815            versions,
1816        );
1817
1818        let plan = plan_updates(
1819            &analysis,
1820            content,
1821            &STUB_FORMATTER,
1822            &never_reparse,
1823            &[],
1824            &IgnoreRules::empty(),
1825            deps_core::FreshnessSettings::Enabled {
1826                cooldown: deps_core::CooldownWindow::from_secs(2_000), // wider than the 1000s age above
1827            },
1828            now,
1829        );
1830
1831        assert_eq!(plan.items.len(), 1);
1832        assert!(
1833            matches!(
1834                plan.items[0].outcome,
1835                Outcome::Skipped {
1836                    reason: SkipReason::WithinFreshnessCooldown,
1837                    ..
1838                }
1839            ),
1840            "the local cooldown skip must win the outcome decision"
1841        );
1842        assert_eq!(
1843            plan.items[0].gossip_excluded_version,
1844            Some(deps_core::ConcreteVersion::new("2.0.0")),
1845            "the GOSSIP attribution must survive the cooldown skip, not be silently dropped"
1846        );
1847        let reason = plan.items[0].reason();
1848        assert!(
1849            reason.contains("freshness cooldown window") && reason.contains("GOSSIP cooldown"),
1850            "got: {reason}"
1851        );
1852    }
1853
1854    /// US-001: multiple outdated dependencies, one already at latest.
1855    #[test]
1856    fn test_plan_updates_us001_multiple_outdated_one_up_to_date() {
1857        let content = "serde = \"1.0.0\"\ntokio = \"1.0.0\"\nlibc = \"1.2.0\"\n";
1858        let mut versions = cached("serde", "1.2.0");
1859        versions.insert(
1860            PackageName::new("tokio"),
1861            PackageVersions::latest_only("1.3.0"),
1862        );
1863        versions.insert(
1864            PackageName::new("libc"),
1865            PackageVersions::latest_only("1.2.0"),
1866        );
1867        let analysis = test_analysis(
1868            vec![
1869                test_dep(
1870                    "serde",
1871                    "1.0.0",
1872                    Range::new(Position::new(0, 9), Position::new(0, 14)),
1873                ),
1874                test_dep(
1875                    "tokio",
1876                    "1.0.0",
1877                    Range::new(Position::new(1, 9), Position::new(1, 14)),
1878                ),
1879                test_dep(
1880                    "libc",
1881                    "1.2.0",
1882                    Range::new(Position::new(2, 8), Position::new(2, 13)),
1883                ),
1884            ],
1885            versions,
1886        );
1887
1888        let plan = plan_updates_no_cooldown(
1889            &analysis,
1890            content,
1891            &STUB_FORMATTER,
1892            &[],
1893            &IgnoreRules::empty(),
1894        );
1895        let applied: Vec<&str> = plan
1896            .items
1897            .iter()
1898            .filter(|i| matches!(i.outcome, Outcome::Applied { .. }))
1899            .map(|i| i.name.as_str())
1900            .collect();
1901        assert_eq!(
1902            applied.len(),
1903            2,
1904            "serde and tokio are outdated, libc is not: {applied:?}"
1905        );
1906        assert!(applied.contains(&"serde"));
1907        assert!(applied.contains(&"tokio"));
1908        assert!(
1909            !plan.items.iter().any(|i| i.name == "libc"),
1910            "an up-to-date dependency must never appear as a candidate at all"
1911        );
1912    }
1913
1914    /// Issue #1517 critique S6: no test covered `plan_updates`/`collect_update_candidates`'s
1915    /// actual gate on a `Flagged` OSV verdict for the registry's cached "latest" — this pins
1916    /// the whole wiring end to end: the candidate must come back
1917    /// `Skipped(NotSafelyEditable(LatestFlaggedByOsv))`, never `Applied`, and
1918    /// `deps_cli::exit::update_exit_code` must treat that as a nonzero (policy-violation) exit,
1919    /// the same as any other `NotSafelyEditable` reason.
1920    #[test]
1921    fn test_plan_updates_refuses_a_flagged_latest() {
1922        use deps_core::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
1923
1924        let content = "serde = \"1.0.0\"\n";
1925        let versions = cached("serde", "1.2.0");
1926        let mut analysis = test_analysis(
1927            vec![test_dep(
1928                "serde",
1929                "1.0.0",
1930                Range::new(Position::new(0, 9), Position::new(0, 14)),
1931            )],
1932            versions,
1933        );
1934        let mut latest_status = LatestStatusMap::new();
1935        latest_status.insert(
1936            deps_core::test_util::vuln_key("serde"),
1937            UpgradeStatus::CandidateVulnerable {
1938                version: ConcreteVersion::new("1.2.0"),
1939                advisory_ids: Capped::new(vec!["MAL-2026-00001".to_string()], 1),
1940                worst_severity: Some(VulnSeverity::Malicious),
1941            },
1942        );
1943        analysis.latest_status = Some(latest_status);
1944
1945        let plan = plan_updates_no_cooldown(
1946            &analysis,
1947            content,
1948            &STUB_FORMATTER,
1949            &[],
1950            &IgnoreRules::empty(),
1951        );
1952
1953        assert_eq!(plan.items.len(), 1);
1954        assert!(
1955            matches!(
1956                plan.items[0].outcome,
1957                Outcome::Skipped {
1958                    reason: SkipReason::NotSafelyEditable(
1959                        deps_core::edit::UnplannableReason::LatestFlaggedByOsv
1960                    ),
1961                    ..
1962                }
1963            ),
1964            "got: {:?}",
1965            plan.items[0].outcome
1966        );
1967        assert_eq!(
1968            crate::exit::update_exit_code(&plan),
1969            crate::exit::EXIT_POLICY_VIOLATION,
1970            "a flagged latest must never exit clean"
1971        );
1972    }
1973
1974    /// Critique M3 (the `LatestFlaggedByOsv` exception): a locally-fresh `latest` that is
1975    /// *also* OSV-flagged must still surface as `NotSafelyEditable(LatestFlaggedByOsv)` — the
1976    /// cooldown skip's "not a real recommendation yet, wait" framing must never demote a
1977    /// confirmed-malicious verdict to a routine, exit-0 pause.
1978    #[test]
1979    fn test_plan_updates_flagged_latest_is_never_masked_by_cooldown() {
1980        use deps_core::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
1981
1982        let content = "serde = \"1.0.0\"\n";
1983        let now = deps_core::PublishTime::from_unix_secs(10_000);
1984        let published_at = deps_core::PublishTime::from_unix_secs(9_000); // 1000s old, in-window
1985        let mut versions = cached("serde", "1.2.0");
1986        versions.insert(
1987            PackageName::new("serde"),
1988            PackageVersions::latest_only("1.2.0").with_published_at(published_at),
1989        );
1990        let mut analysis = test_analysis(
1991            vec![test_dep(
1992                "serde",
1993                "1.0.0",
1994                Range::new(Position::new(0, 9), Position::new(0, 14)),
1995            )],
1996            versions,
1997        );
1998        let mut latest_status = LatestStatusMap::new();
1999        latest_status.insert(
2000            deps_core::test_util::vuln_key("serde"),
2001            UpgradeStatus::CandidateVulnerable {
2002                version: ConcreteVersion::new("1.2.0"),
2003                advisory_ids: Capped::new(vec!["MAL-2026-00001".to_string()], 1),
2004                worst_severity: Some(VulnSeverity::Malicious),
2005            },
2006        );
2007        analysis.latest_status = Some(latest_status);
2008
2009        let plan = plan_updates(
2010            &analysis,
2011            content,
2012            &STUB_FORMATTER,
2013            &never_reparse,
2014            &[],
2015            &IgnoreRules::empty(),
2016            deps_core::FreshnessSettings::Enabled {
2017                cooldown: deps_core::CooldownWindow::from_secs(2_000),
2018            },
2019            now,
2020        );
2021
2022        assert_eq!(plan.items.len(), 1);
2023        assert!(
2024            matches!(
2025                plan.items[0].outcome,
2026                Outcome::Skipped {
2027                    reason: SkipReason::NotSafelyEditable(
2028                        deps_core::edit::UnplannableReason::LatestFlaggedByOsv
2029                    ),
2030                    ..
2031                }
2032            ),
2033            "got: {:?}",
2034            plan.items[0].outcome
2035        );
2036        assert_eq!(
2037            plan.items[0].target(),
2038            None,
2039            "an Unplannable candidate has no concrete target"
2040        );
2041        assert_eq!(
2042            crate::exit::update_exit_code(&plan),
2043            crate::exit::EXIT_POLICY_VIOLATION,
2044            "a flagged latest must never exit clean, even when also within cooldown"
2045        );
2046    }
2047
2048    /// Code-review finding (post-M3): `UnplannableReason::LatestUnverified`'s own doc says it
2049    /// "fails closed the same way `LatestFlaggedByOsv` does... never distinguishable from a
2050    /// flagged one at write time" — the M3 exception must therefore cover both variants, not
2051    /// only `LatestFlaggedByOsv`. Before this fix, a locally-fresh `latest` with an unverified
2052    /// OSV check was silently reclassified from `NotSafelyEditable(LatestUnverified)` (exit 1)
2053    /// to `WithinFreshnessCooldown` (exit 0).
2054    #[test]
2055    fn test_plan_updates_unverified_latest_is_never_masked_by_cooldown() {
2056        use deps_core::osv::LatestStatusMap;
2057
2058        let content = "serde = \"1.0.0\"\n";
2059        let now = deps_core::PublishTime::from_unix_secs(10_000);
2060        let published_at = deps_core::PublishTime::from_unix_secs(9_000); // 1000s old, in-window
2061        let mut versions = cached("serde", "1.2.0");
2062        versions.insert(
2063            PackageName::new("serde"),
2064            PackageVersions::latest_only("1.2.0").with_published_at(published_at),
2065        );
2066        let mut analysis = test_analysis(
2067            vec![test_dep(
2068                "serde",
2069                "1.0.0",
2070                Range::new(Position::new(0, 9), Position::new(0, 14)),
2071            )],
2072            versions,
2073        );
2074        // `Some(&empty map)`: OSV checking is on, but nothing has verified this dependency's
2075        // latest yet — the pre-phase-B state, distinct from `None` (checking disabled/offline).
2076        analysis.latest_status = Some(LatestStatusMap::new());
2077
2078        let plan = plan_updates(
2079            &analysis,
2080            content,
2081            &STUB_FORMATTER,
2082            &never_reparse,
2083            &[],
2084            &IgnoreRules::empty(),
2085            deps_core::FreshnessSettings::Enabled {
2086                cooldown: deps_core::CooldownWindow::from_secs(2_000),
2087            },
2088            now,
2089        );
2090
2091        assert_eq!(plan.items.len(), 1);
2092        assert!(
2093            matches!(
2094                plan.items[0].outcome,
2095                Outcome::Skipped {
2096                    reason: SkipReason::NotSafelyEditable(
2097                        deps_core::edit::UnplannableReason::LatestUnverified
2098                    ),
2099                    ..
2100                }
2101            ),
2102            "got: {:?}",
2103            plan.items[0].outcome
2104        );
2105        assert_eq!(
2106            crate::exit::update_exit_code(&plan),
2107            crate::exit::EXIT_POLICY_VIOLATION,
2108            "an unverified latest must never exit clean, even when also within cooldown"
2109        );
2110    }
2111
2112    /// Critique M3: an unplannable candidate whose `latest` is locally fresh gets the same
2113    /// clean cooldown skip a `Planned` candidate would — whether an edit happens to be
2114    /// mechanically writable is orthogonal to whether the version is even a real
2115    /// recommendation yet.
2116    #[test]
2117    fn test_plan_updates_unplannable_candidate_within_cooldown_is_cooldown_skip_not_unsafe() {
2118        let content = "tokio = \"weird\"\n"; // literal mismatch -> NonLiteralSpan, absent this fix
2119        let now = deps_core::PublishTime::from_unix_secs(10_000);
2120        let published_at = deps_core::PublishTime::from_unix_secs(9_000);
2121        let versions_map = {
2122            let mut map = cached("tokio", "2.0.0");
2123            map.insert(
2124                PackageName::new("tokio"),
2125                PackageVersions::latest_only("2.0.0").with_published_at(published_at),
2126            );
2127            map
2128        };
2129        let analysis = test_analysis(
2130            vec![test_dep(
2131                "tokio",
2132                "1.0.0",
2133                Range::new(Position::new(0, 9), Position::new(0, 14)),
2134            )],
2135            versions_map,
2136        );
2137
2138        let plan = plan_updates(
2139            &analysis,
2140            content,
2141            &STUB_FORMATTER,
2142            &never_reparse,
2143            &[],
2144            &IgnoreRules::empty(),
2145            deps_core::FreshnessSettings::Enabled {
2146                cooldown: deps_core::CooldownWindow::from_secs(2_000),
2147            },
2148            now,
2149        );
2150
2151        assert_eq!(plan.items.len(), 1);
2152        assert!(
2153            matches!(
2154                plan.items[0].outcome,
2155                Outcome::Skipped {
2156                    reason: SkipReason::WithinFreshnessCooldown,
2157                    ..
2158                }
2159            ),
2160            "got: {:?}",
2161            plan.items[0].outcome
2162        );
2163        assert_eq!(
2164            crate::exit::update_exit_code(&plan),
2165            crate::exit::EXIT_CLEAN,
2166            "a cooldown-driven pause must exit clean even when the candidate was also unplannable"
2167        );
2168    }
2169
2170    /// Issue #1517 critique S6: same as the flagged case above, for an `Unverified` OSV
2171    /// verdict (no phase-B/scan result for this dependency at all) — the shared gate must
2172    /// fail closed the same way, not only for a confirmed-malicious `Flagged` verdict.
2173    #[test]
2174    fn test_plan_updates_refuses_an_unverified_latest() {
2175        use deps_core::osv::LatestStatusMap;
2176
2177        let content = "serde = \"1.0.0\"\n";
2178        let versions = cached("serde", "1.2.0");
2179        let mut analysis = test_analysis(
2180            vec![test_dep(
2181                "serde",
2182                "1.0.0",
2183                Range::new(Position::new(0, 9), Position::new(0, 14)),
2184            )],
2185            versions,
2186        );
2187        // `Some(&empty map)`: OSV checking is on, but nothing has verified this dependency's
2188        // latest yet — the pre-phase-B state, distinct from `None` (checking disabled/offline).
2189        analysis.latest_status = Some(LatestStatusMap::new());
2190
2191        let plan = plan_updates_no_cooldown(
2192            &analysis,
2193            content,
2194            &STUB_FORMATTER,
2195            &[],
2196            &IgnoreRules::empty(),
2197        );
2198
2199        assert_eq!(plan.items.len(), 1);
2200        assert!(
2201            matches!(
2202                plan.items[0].outcome,
2203                Outcome::Skipped {
2204                    reason: SkipReason::NotSafelyEditable(
2205                        deps_core::edit::UnplannableReason::LatestUnverified
2206                    ),
2207                    ..
2208                }
2209            ),
2210            "got: {:?}",
2211            plan.items[0].outcome
2212        );
2213        assert_eq!(
2214            crate::exit::update_exit_code(&plan),
2215            crate::exit::EXIT_POLICY_VIOLATION,
2216            "an unverified latest must never exit clean"
2217        );
2218    }
2219
2220    /// US-002: `--package` narrows the update set; the excluded dependency is still reported.
2221    #[test]
2222    fn test_plan_updates_us002_package_filter_narrows_to_one() {
2223        let content = "serde = \"1.0.0\"\ntokio = \"1.0.0\"\n";
2224        let mut versions = cached("serde", "1.2.0");
2225        versions.insert(
2226            PackageName::new("tokio"),
2227            PackageVersions::latest_only("1.3.0"),
2228        );
2229        let analysis = test_analysis(
2230            vec![
2231                test_dep(
2232                    "serde",
2233                    "1.0.0",
2234                    Range::new(Position::new(0, 9), Position::new(0, 14)),
2235                ),
2236                test_dep(
2237                    "tokio",
2238                    "1.0.0",
2239                    Range::new(Position::new(1, 9), Position::new(1, 14)),
2240                ),
2241            ],
2242            versions,
2243        );
2244
2245        let plan = plan_updates_no_cooldown(
2246            &analysis,
2247            content,
2248            &STUB_FORMATTER,
2249            &["serde".to_string()],
2250            &IgnoreRules::empty(),
2251        );
2252
2253        let serde_item = plan.items.iter().find(|i| i.name == "serde").unwrap();
2254        assert!(matches!(serde_item.outcome, Outcome::Applied { .. }));
2255        let tokio_item = plan.items.iter().find(|i| i.name == "tokio").unwrap();
2256        assert!(matches!(
2257            tokio_item.outcome,
2258            Outcome::Skipped {
2259                reason: SkipReason::NotRequested,
2260                ..
2261            }
2262        ));
2263    }
2264
2265    /// US-004 default-mode half: an `update_types`-scoped ignore rule skips a major bump.
2266    #[test]
2267    fn test_plan_updates_us004_ignore_rule_skips_major_bump() {
2268        let content = "tokio = \"1.0.0\"\n";
2269        let versions = cached("tokio", "2.0.0");
2270        let analysis = test_analysis(
2271            vec![test_dep(
2272                "tokio",
2273                "1.0.0",
2274                Range::new(Position::new(0, 9), Position::new(0, 14)),
2275            )],
2276            versions,
2277        );
2278        let ignore_rules = crate::update::ignore::IgnoreRules::new(
2279            vec![crate::config::IgnoreRule {
2280                name: "tokio".to_string(),
2281                update_types: Some(vec![crate::config::UpdateTypeToken::Major]),
2282            }],
2283            &STUB_FORMATTER,
2284        );
2285
2286        let plan =
2287            plan_updates_no_cooldown(&analysis, content, &STUB_FORMATTER, &[], &ignore_rules);
2288
2289        assert_eq!(plan.items.len(), 1);
2290        assert!(matches!(
2291            plan.items[0].outcome,
2292            Outcome::Skipped {
2293                reason: SkipReason::IgnoreRule,
2294                ..
2295            }
2296        ));
2297    }
2298
2299    /// Code review finding 1: an `Unplannable` candidate (here, a `NonLiteralSpan` — the
2300    /// content at `version_range` does not match the declared requirement text) that also
2301    /// matches an `[update].ignore` rule must be reported `Skipped(IgnoreRule)` — exit 0 — not
2302    /// `Skipped(NotSafelyEditable)` — exit 1. Before this fix, the unplannable-candidate loop
2303    /// never consulted `ignore_rules` at all, so this case always fell through to
2304    /// `NotSafelyEditable` regardless of a matching rule.
2305    #[test]
2306    fn test_plan_updates_unplannable_candidate_still_honors_ignore_rule() {
2307        let content = "tokio = \"weird\"\n";
2308        let versions = cached("tokio", "2.0.0");
2309        let analysis = test_analysis(
2310            vec![test_dep(
2311                "tokio",
2312                "1.0.0",
2313                Range::new(Position::new(0, 9), Position::new(0, 14)),
2314            )],
2315            versions,
2316        );
2317        let ignore_rules = crate::update::ignore::IgnoreRules::new(
2318            vec![crate::config::IgnoreRule {
2319                name: "tokio".to_string(),
2320                update_types: None,
2321            }],
2322            &STUB_FORMATTER,
2323        );
2324
2325        let plan =
2326            plan_updates_no_cooldown(&analysis, content, &STUB_FORMATTER, &[], &ignore_rules);
2327
2328        assert_eq!(plan.items.len(), 1);
2329        assert!(
2330            matches!(
2331                plan.items[0].outcome,
2332                Outcome::Skipped {
2333                    reason: SkipReason::IgnoreRule,
2334                    ..
2335                }
2336            ),
2337            "got {:?}",
2338            plan.items[0].outcome
2339        );
2340    }
2341
2342    /// Companion to the above: the same unplannable candidate with no matching ignore rule
2343    /// still reports `NotSafelyEditable`, proving the new `ignore_rules` check above is
2344    /// additive, not a blanket bypass of the unplannable-candidate reporting.
2345    #[test]
2346    fn test_plan_updates_unplannable_candidate_without_ignore_rule_is_not_safely_editable() {
2347        let content = "tokio = \"weird\"\n";
2348        let versions = cached("tokio", "2.0.0");
2349        let analysis = test_analysis(
2350            vec![test_dep(
2351                "tokio",
2352                "1.0.0",
2353                Range::new(Position::new(0, 9), Position::new(0, 14)),
2354            )],
2355            versions,
2356        );
2357
2358        let plan = plan_updates_no_cooldown(
2359            &analysis,
2360            content,
2361            &STUB_FORMATTER,
2362            &[],
2363            &IgnoreRules::empty(),
2364        );
2365
2366        assert_eq!(plan.items.len(), 1);
2367        assert!(matches!(
2368            plan.items[0].outcome,
2369            Outcome::Skipped {
2370                reason: SkipReason::NotSafelyEditable(
2371                    deps_core::edit::UnplannableReason::NonLiteralSpan
2372                ),
2373                ..
2374            }
2375        ));
2376    }
2377
2378    /// FR-007 edge case: with no `--config` (empty ignore rules), an `Unknown`-kind update is
2379    /// still applied — `Unknown` only fails closed *under a matching scoped rule*, never on
2380    /// its own.
2381    #[test]
2382    fn test_plan_updates_fr007_no_config_no_ignore_rules_loaded() {
2383        let content = "tokio = \"1.0.0\"\n";
2384        let versions = cached("tokio", "2.0.0");
2385        let analysis = test_analysis(
2386            vec![test_dep(
2387                "tokio",
2388                "1.0.0",
2389                Range::new(Position::new(0, 9), Position::new(0, 14)),
2390            )],
2391            versions,
2392        );
2393
2394        let plan = plan_updates_no_cooldown(
2395            &analysis,
2396            content,
2397            &STUB_FORMATTER,
2398            &[],
2399            &IgnoreRules::empty(),
2400        );
2401
2402        assert_eq!(plan.items.len(), 1);
2403        assert!(matches!(plan.items[0].outcome, Outcome::Applied { .. }));
2404    }
2405
2406    #[test]
2407    fn test_is_requested_empty_filter_matches_everything() {
2408        assert!(is_requested(&[], "serde", &STUB_FORMATTER));
2409        assert!(is_requested(
2410            &["serde".to_string()],
2411            "serde",
2412            &STUB_FORMATTER
2413        ));
2414        assert!(!is_requested(
2415            &["tokio".to_string()],
2416            "serde",
2417            &STUB_FORMATTER
2418        ));
2419    }
2420
2421    #[test]
2422    fn test_apply_plan_stale_manifest_is_rejected() {
2423        let dir = tempfile::tempdir().unwrap();
2424        let path = dir.path().join("Cargo.toml");
2425        std::fs::write(&path, "serde = \"1.0.0\"\n").unwrap();
2426
2427        // Simulates a concurrent edit landing between planning and applying.
2428        std::fs::write(&path, "serde = \"1.0.1\"\n").unwrap();
2429
2430        let plan = UpdatePlan {
2431            items: vec![PlannedUpdateItem {
2432                name: "serde".to_string(),
2433                current: CurrentVersion::Resolved(ConcreteVersion::from("1.0.0")),
2434                outcome: Outcome::Applied {
2435                    edit: ManifestEdit {
2436                        range: Range::new(Position::new(0, 9), Position::new(0, 14)),
2437                        new_text: "1.2.0".to_string(),
2438                    },
2439                    target: ConcreteVersion::from("1.2.0"),
2440                },
2441                advisory_ids: Vec::new(),
2442                ignore_rule_overridden: false,
2443                gossip_excluded_version: None,
2444                cooldown_fallback: None,
2445            }],
2446        };
2447
2448        let result = apply_plan(
2449            &plan,
2450            &path,
2451            "serde = \"1.0.0\"\n",
2452            crate::format::DryRun::No,
2453        );
2454        assert!(matches!(result, Err(ApplyError::StaleManifest { .. })));
2455        assert_eq!(
2456            std::fs::read_to_string(&path).unwrap(),
2457            "serde = \"1.0.1\"\n"
2458        );
2459    }
2460
2461    #[test]
2462    fn test_apply_plan_dry_run_does_not_write() {
2463        let dir = tempfile::tempdir().unwrap();
2464        let path = dir.path().join("Cargo.toml");
2465        std::fs::write(&path, "serde = \"1.0.0\"\n").unwrap();
2466
2467        let plan = UpdatePlan {
2468            items: vec![PlannedUpdateItem {
2469                name: "serde".to_string(),
2470                current: CurrentVersion::Resolved(ConcreteVersion::from("1.0.0")),
2471                outcome: Outcome::Applied {
2472                    edit: ManifestEdit {
2473                        range: Range::new(Position::new(0, 9), Position::new(0, 14)),
2474                        new_text: "1.2.0".to_string(),
2475                    },
2476                    target: ConcreteVersion::from("1.2.0"),
2477                },
2478                advisory_ids: Vec::new(),
2479                ignore_rule_overridden: false,
2480                gossip_excluded_version: None,
2481                cooldown_fallback: None,
2482            }],
2483        };
2484
2485        apply_plan(
2486            &plan,
2487            &path,
2488            "serde = \"1.0.0\"\n",
2489            crate::format::DryRun::Yes,
2490        )
2491        .unwrap();
2492        assert_eq!(
2493            std::fs::read_to_string(&path).unwrap(),
2494            "serde = \"1.0.0\"\n"
2495        );
2496    }
2497
2498    #[test]
2499    fn test_apply_plan_writes_applied_edits() {
2500        let dir = tempfile::tempdir().unwrap();
2501        let path = dir.path().join("Cargo.toml");
2502        std::fs::write(&path, "serde = \"1.0.0\"\n").unwrap();
2503
2504        let plan = UpdatePlan {
2505            items: vec![PlannedUpdateItem {
2506                name: "serde".to_string(),
2507                current: CurrentVersion::Resolved(ConcreteVersion::from("1.0.0")),
2508                outcome: Outcome::Applied {
2509                    edit: ManifestEdit {
2510                        range: Range::new(Position::new(0, 9), Position::new(0, 14)),
2511                        new_text: "1.2.0".to_string(),
2512                    },
2513                    target: ConcreteVersion::from("1.2.0"),
2514                },
2515                advisory_ids: Vec::new(),
2516                ignore_rule_overridden: false,
2517                gossip_excluded_version: None,
2518                cooldown_fallback: None,
2519            }],
2520        };
2521
2522        apply_plan(
2523            &plan,
2524            &path,
2525            "serde = \"1.0.0\"\n",
2526            crate::format::DryRun::No,
2527        )
2528        .unwrap();
2529        assert_eq!(
2530            std::fs::read_to_string(&path).unwrap(),
2531            "serde = \"1.2.0\"\n"
2532        );
2533    }
2534
2535    #[test]
2536    fn test_apply_plan_skipped_items_are_not_written() {
2537        let dir = tempfile::tempdir().unwrap();
2538        let path = dir.path().join("Cargo.toml");
2539        std::fs::write(&path, "serde = \"1.0.0\"\n").unwrap();
2540
2541        let plan = UpdatePlan {
2542            items: vec![PlannedUpdateItem {
2543                name: "serde".to_string(),
2544                current: CurrentVersion::Resolved(ConcreteVersion::from("1.0.0")),
2545                outcome: Outcome::Skipped {
2546                    reason: SkipReason::IgnoreRule,
2547                    target: Some(ConcreteVersion::from("1.2.0")),
2548                },
2549                advisory_ids: Vec::new(),
2550                ignore_rule_overridden: false,
2551                gossip_excluded_version: None,
2552                cooldown_fallback: None,
2553            }],
2554        };
2555
2556        apply_plan(
2557            &plan,
2558            &path,
2559            "serde = \"1.0.0\"\n",
2560            crate::format::DryRun::No,
2561        )
2562        .unwrap();
2563        assert_eq!(
2564            std::fs::read_to_string(&path).unwrap(),
2565            "serde = \"1.0.0\"\n"
2566        );
2567    }
2568
2569    // --- dedup_applied_items (previously untested; touched by #1349's `Outcome::Applied`
2570    // edit-extraction rewrite) ---
2571
2572    fn applied_item(name: &str, range: Range) -> PlannedUpdateItem {
2573        PlannedUpdateItem {
2574            name: name.to_string(),
2575            current: CurrentVersion::Resolved(ConcreteVersion::from("1.0.0")),
2576            outcome: Outcome::Applied {
2577                edit: ManifestEdit {
2578                    range,
2579                    new_text: "1.2.0".to_string(),
2580                },
2581                target: ConcreteVersion::from("1.2.0"),
2582            },
2583            advisory_ids: Vec::new(),
2584            ignore_rule_overridden: false,
2585            gossip_excluded_version: None,
2586            cooldown_fallback: None,
2587        }
2588    }
2589
2590    #[test]
2591    fn test_dedup_applied_items_keeps_non_overlapping_edits_applied() {
2592        let mut items = vec![
2593            applied_item(
2594                "serde",
2595                Range::new(Position::new(0, 9), Position::new(0, 14)),
2596            ),
2597            applied_item(
2598                "tokio",
2599                Range::new(Position::new(1, 9), Position::new(1, 14)),
2600            ),
2601        ];
2602        dedup_applied_items(&mut items);
2603        assert!(
2604            items
2605                .iter()
2606                .all(|i| matches!(i.outcome, Outcome::Applied { .. }))
2607        );
2608    }
2609
2610    /// M2/critic finding this dedup pass exists for: two vulnerable occurrences of one name
2611    /// (or any other overlap) both reported `Applied` must have the loser demoted to
2612    /// `Skipped(OverlapsAnotherEdit)` before `apply_plan` runs, not silently write only one.
2613    #[test]
2614    fn test_dedup_applied_items_demotes_the_later_overlap() {
2615        let mut items = vec![
2616            applied_item(
2617                "serde",
2618                Range::new(Position::new(0, 9), Position::new(0, 14)),
2619            ),
2620            applied_item(
2621                "serde",
2622                Range::new(Position::new(0, 11), Position::new(0, 16)),
2623            ),
2624        ];
2625        dedup_applied_items(&mut items);
2626        assert!(matches!(items[0].outcome, Outcome::Applied { .. }));
2627        assert!(matches!(
2628            items[1].outcome,
2629            Outcome::Skipped {
2630                reason: SkipReason::OverlapsAnotherEdit,
2631                ..
2632            }
2633        ));
2634        assert_eq!(
2635            items[1].target(),
2636            Some(&ConcreteVersion::from("1.2.0")),
2637            "a demoted item must keep its target, not silently drop it (#1615)"
2638        );
2639    }
2640
2641    // --- Spec 075 (`deps-cli update` cooldown fallback) ---
2642
2643    const FALLBACK_FORMATTER: deps_core::test_util::StubFormatter =
2644        deps_core::test_util::StubFormatter::new().with_manifest_requirement_as_resolved_version();
2645
2646    /// A formatter with a REAL `compile_bounded_requirement` — `semver::VersionReq`-backed, via the
2647    /// same `deps_core::lsp_helpers::compile_semver_requirement` deps-cargo/deps-swift use in
2648    /// production. Fix-cycle item 1/S1: the original test used a synthetic `AtLeastMajor3`
2649    /// matcher whose behavior happened to be self-consistent with the (wrong)
2650    /// `fallback_satisfies_requirement` implementation it was meant to catch — impl-critic
2651    /// proved the bug only surfaced with a real ecosystem comparator. Using the genuine semver
2652    /// matcher here (rather than adding a `deps-npm`/`deps-cargo` dev-dependency) closes that
2653    /// gap without a new workspace dependency.
2654    struct RealSemverFormatter;
2655    impl deps_core::lsp_helpers::PackageNaming for RealSemverFormatter {}
2656    impl deps_core::lsp_helpers::PackageRendering for RealSemverFormatter {
2657        fn format_version_for_text_edit(&self, v: &deps_core::ConcreteVersion) -> String {
2658            v.to_string()
2659        }
2660        fn package_url(&self, name: &PackageName) -> String {
2661            name.as_str().to_string()
2662        }
2663    }
2664    impl deps_core::lsp_helpers::RequirementResolution for RealSemverFormatter {
2665        fn compile_bounded_requirement(
2666            &self,
2667            requirement: deps_core::lsp_helpers::BoundedVersionReq<'_>,
2668        ) -> Option<Box<dyn deps_core::lsp_helpers::RequirementMatcher>> {
2669            let requirement = requirement.get();
2670            deps_core::lsp_helpers::compile_semver_requirement(requirement)
2671        }
2672    }
2673    impl deps_core::lsp_helpers::DiagnosticMessages for RealSemverFormatter {}
2674    impl deps_core::lsp_helpers::DiagnosticPolicy for RealSemverFormatter {}
2675    impl deps_core::lsp_helpers::SourcePolicy for RealSemverFormatter {}
2676    impl deps_core::lsp_helpers::OsvNaming for RealSemverFormatter {}
2677
2678    /// Spec 076 FR-022/FR-023 (A1 repro, phase-1 d0), corrected for #1564/#1561: rejects a
2679    /// fallback whose UNEDITED requirement's own floor (the oldest `available` entry it still
2680    /// matches) is newer than the fallback — replaces spec 075's isolated
2681    /// `fallback_satisfies_requirement` unit test (that function is deleted, and with it the Go
2682    /// `manifest_requirement_is_resolved_version` bypass, FR-022: Go's `ExactMatcher` alone is
2683    /// sufficient — the bypass's own inversion test lives in `deps-go` as part of T005/SC-020,
2684    /// since `deps-cli` does not depend on `deps-go`).
2685    #[test]
2686    fn test_fallback_edit_excludes_newer_rejects_a1_repro_downgrade() {
2687        let dep = test_dep(
2688            "pkg",
2689            ">=3.0.0",
2690            Range::new(Position::new(0, 0), Position::new(0, 5)),
2691        );
2692        let edit = ManifestEdit {
2693            range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2694            new_text: "2.9.0".to_string(),
2695        };
2696
2697        // A1 repro: `latest` (3.2.0) already satisfies `>=3.0.0`, so falling back to the older
2698        // 2.9.0 would be a downgrade relative to what re-resolution already gives — reject at
2699        // d0, before any re-parse (`never_reparse` must never be consulted).
2700        let available_with_newer_match: Vec<deps_core::ConcreteVersion> =
2701            vec!["3.2.0".into(), "2.9.0".into()];
2702        assert_eq!(
2703            deps_core::lsp_helpers::fallback_edit_excludes_newer(
2704                &RealSemverFormatter,
2705                &never_reparse,
2706                "pkg = \">=3.0.0\"\n",
2707                &dep,
2708                &edit,
2709                &deps_core::ConcreteVersion::new("2.9.0"),
2710                &available_with_newer_match,
2711            ),
2712            deps_core::lsp_helpers::FallbackEditVerdict::Rejected(
2713                deps_core::lsp_helpers::FallbackEditRejection::OriginalResolvesPastFallback
2714            ),
2715            "2.9.0 is a downgrade relative to what >=3.0.0 already resolves to (3.2.0) and must \
2716             never be treated as a usable fallback"
2717        );
2718    }
2719
2720    /// Issue #1564, adapted to spec 076's two-phase guard — and DELIBERATELY corrected, not
2721    /// carried over verbatim, from #1565's own single-phase `Applied`/`Writable` expectation.
2722    /// R0 (unedited, "0.22.6", implicit caret) already resolves past the fallback (0.22.7) just
2723    /// as freely as it resolves past the cooldown-blocked `latest` (0.22.8) — #1565's
2724    /// floor-comparison fix (d0 here) correctly does NOT reject this alone, since the
2725    /// requirement's own floor (0.22.6) is not newer than the fallback. But spec 076 adds a
2726    /// SECOND phase (d1) #1565 never had: the WRITTEN edit ("0.22.7", also an implicit caret)
2727    /// auto-follows forward into the fresh 0.22.8, which is exactly spec 076's S1 anti-
2728    /// auto-follow protection this guard exists to add. So unlike #1565's own repro, this must
2729    /// resolve `Rejected(EditedAdmitsNewer)`, not `Writable` — d0 passing does not mean the edit
2730    /// is safe to write once the two-phase guard also checks what the WRITTEN text re-admits.
2731    #[test]
2732    fn test_fallback_edit_excludes_newer_rejects_auto_follow_after_d0_passes_1564() {
2733        let dep = test_dep(
2734            "pkg",
2735            "0.22.6",
2736            Range::new(Position::new(0, 7), Position::new(0, 13)),
2737        );
2738        let edit = ManifestEdit {
2739            range: Range::new(Position::new(0, 7), Position::new(0, 13)),
2740            new_text: "0.22.7".to_string(),
2741        };
2742        let available: Vec<deps_core::ConcreteVersion> =
2743            vec!["0.22.8".into(), "0.22.7".into(), "0.22.6".into()];
2744        let reparse = reparse_quoted_deps(vec![("pkg", Position::new(0, 7))]);
2745
2746        let verdict = deps_core::lsp_helpers::fallback_edit_excludes_newer(
2747            &RealSemverFormatter,
2748            &reparse,
2749            "pkg = \"0.22.6\"\n",
2750            &dep,
2751            &edit,
2752            &deps_core::ConcreteVersion::new("0.22.7"),
2753            &available,
2754        );
2755        assert_eq!(
2756            verdict,
2757            deps_core::lsp_helpers::FallbackEditVerdict::Rejected(
2758                deps_core::lsp_helpers::FallbackEditRejection::EditedAdmitsNewer
2759            ),
2760            "d0 passes (0.22.6's own floor is not newer than 0.22.7), but the written 0.22.7 \
2761             caret still auto-follows into the fresh 0.22.8 — d1 must reject: {verdict:?}"
2762        );
2763    }
2764
2765    /// Issue #1561 repro (CWE-1284), adapted: the declared exact pin (`=1.5.0`) is absent from
2766    /// `available` (unpublished/yanked/filtered) — the requirement's own floor (d0) cannot be
2767    /// evidenced by any listed entry, so this must fail closed rather than vacuously accept the
2768    /// fallback (1.4.0, strictly older than the declared pin).
2769    #[test]
2770    fn test_fallback_edit_excludes_newer_fails_closed_on_unlisted_pin_1561() {
2771        let dep = test_dep(
2772            "pkg",
2773            "=1.5.0",
2774            Range::new(Position::new(0, 7), Position::new(0, 14)),
2775        );
2776        let edit = ManifestEdit {
2777            range: Range::new(Position::new(0, 7), Position::new(0, 14)),
2778            new_text: "1.4.0".to_string(),
2779        };
2780        // 1.5.0 (the declared pin) is not listed — unpublished/yanked/filtered.
2781        let available: Vec<deps_core::ConcreteVersion> =
2782            vec!["2.0.0".into(), "1.4.0".into(), "1.0.0".into()];
2783
2784        let verdict = deps_core::lsp_helpers::fallback_edit_excludes_newer(
2785            &RealSemverFormatter,
2786            &never_reparse,
2787            "pkg = \"=1.5.0\"\n",
2788            &dep,
2789            &edit,
2790            &deps_core::ConcreteVersion::new("1.4.0"),
2791            &available,
2792        );
2793        assert_eq!(
2794            verdict,
2795            deps_core::lsp_helpers::FallbackEditVerdict::Rejected(
2796                deps_core::lsp_helpers::FallbackEditRejection::OriginalResolvesPastFallback
2797            ),
2798            "the declared pin 1.5.0 is unlisted; nothing evidences that 1.4.0 is not a \
2799             downgrade below it, so this must fail closed: {verdict:?}"
2800        );
2801    }
2802
2803    /// Issue #1561, second cause: the FALLBACK itself is absent from `available` (a stale
2804    /// `CooldownFallback` computed against a version list that has since changed) — distinct
2805    /// from the declared-pin-unlisted case above, which is why `fallback_edit_excludes_newer`
2806    /// reports it as its own `FallbackUnlisted` variant.
2807    #[test]
2808    fn test_fallback_edit_excludes_newer_fails_closed_on_unlisted_fallback() {
2809        let dep = test_dep(
2810            "pkg",
2811            "0.22.6",
2812            Range::new(Position::new(0, 7), Position::new(0, 13)),
2813        );
2814        let edit = ManifestEdit {
2815            range: Range::new(Position::new(0, 7), Position::new(0, 13)),
2816            new_text: "0.22.7".to_string(),
2817        };
2818        // 0.22.7 (the fallback) is not listed at all.
2819        let available: Vec<deps_core::ConcreteVersion> = vec!["0.22.8".into(), "0.22.6".into()];
2820
2821        let verdict = deps_core::lsp_helpers::fallback_edit_excludes_newer(
2822            &RealSemverFormatter,
2823            &never_reparse,
2824            "pkg = \"0.22.6\"\n",
2825            &dep,
2826            &edit,
2827            &deps_core::ConcreteVersion::new("0.22.7"),
2828            &available,
2829        );
2830        assert_eq!(
2831            verdict,
2832            deps_core::lsp_helpers::FallbackEditVerdict::Rejected(
2833                deps_core::lsp_helpers::FallbackEditRejection::FallbackUnlisted
2834            ),
2835            "got: {verdict:?}"
2836        );
2837    }
2838
2839    /// One dependency, cooldown-blocked by the local heuristic, with a stored fallback
2840    /// candidate — shared setup for the SC-005/SC-006 fallback-selection tests below.
2841    ///
2842    /// Spec 076: the declared requirement is an exact pin (`=1.0.0`) and `latest` is a MAJOR
2843    /// version bump (`2.0.0`) — outside the caret range a bare-rendered fallback edit (e.g.
2844    /// `"1.1.0"` compiling to `^1.1.0`) would admit, so the two-phase guard's phase 1/2 checks
2845    /// pass for any test using this fixture that actually reaches `fallback_edit_excludes_newer`
2846    /// (most of the tests below do not: their fallback view's own OSV/structural rejection, or
2847    /// the ignore-rule short-circuit inside `never_demoted`, resolves the occurrence before the
2848    /// guard is ever consulted — see each test's own doc for which case it is).
2849    fn fallback_scenario_analysis(
2850        fallback_version: &str,
2851    ) -> (
2852        ManifestAnalysis,
2853        deps_core::FreshnessSettings,
2854        deps_core::PublishTime,
2855    ) {
2856        let now = deps_core::PublishTime::from_unix_secs(10_000);
2857        let published_at = deps_core::PublishTime::from_unix_secs(9_900); // 100s old, within cooldown
2858        let mut versions = cached("pkg", "2.0.0");
2859        versions.insert(
2860            PackageName::new("pkg"),
2861            // `available` must list both the exact-pin R0's own floor ("1.0.0") and the
2862            // fallback candidate itself, or `fallback_edit_excludes_newer`'s fail-closed
2863            // `FallbackUnlisted`/floor checks reject before ever reaching a test's own
2864            // scenario under test — `latest_only`'s single-element `[latest]` list isn't
2865            // enough once that guard's two-phase, position-based checks are in play.
2866            PackageVersions::new(
2867                deps_core::ConcreteVersion::new("2.0.0"),
2868                std::sync::Arc::from(vec![
2869                    deps_core::ConcreteVersion::new("2.0.0"),
2870                    deps_core::ConcreteVersion::new("1.1.0"),
2871                    deps_core::ConcreteVersion::new("1.0.0"),
2872                ]),
2873            )
2874            .with_published_at(published_at)
2875            .with_cooldown_fallback(deps_core::lsp_helpers::CooldownFallback::new(
2876                fallback_version.into(),
2877                deps_core::PublishTime::from_unix_secs(1_000),
2878            )),
2879        );
2880        let analysis = test_analysis(
2881            vec![test_dep(
2882                "pkg",
2883                "=1.0.0",
2884                // 6-char span ("=1.0.0"), matching the exact-pin requirement text — a test using
2885                // this fixture's fallback view as `Planned` (not short-circuited by its own OSV
2886                // status) needs `content`'s literal span to match, or `collect_update_candidates`
2887                // marks it `Unplannable(NonLiteralSpan)` before the guard is ever reached.
2888                Range::new(Position::new(0, 7), Position::new(0, 13)),
2889            )],
2890            versions,
2891        );
2892        let freshness = deps_core::FreshnessSettings::Enabled {
2893            cooldown: deps_core::CooldownWindow::from_secs(1_000),
2894        };
2895        (analysis, freshness, now)
2896    }
2897
2898    /// Like [`fallback_scenario_analysis`], but with a caller-controlled declared requirement
2899    /// and full `available` list — needed to exercise [`RealSemverFormatter`]'s real
2900    /// `compile_bounded_requirement` guard (fix-cycle item 1/S1, tester Gap C) end to end through
2901    /// [`plan_updates`]/[`resolve_occurrence`], not just the isolated helper.
2902    fn real_semver_scenario(
2903        req: &str,
2904        available: &[&str],
2905        fallback_version: &str,
2906    ) -> (
2907        ManifestAnalysis,
2908        deps_core::FreshnessSettings,
2909        deps_core::PublishTime,
2910    ) {
2911        let now = deps_core::PublishTime::from_unix_secs(10_000);
2912        let published_at = deps_core::PublishTime::from_unix_secs(9_900); // 100s old, within cooldown
2913        let latest = available.first().copied().expect("at least one version");
2914        let available_arc: std::sync::Arc<[deps_core::ConcreteVersion]> =
2915            available.iter().map(|v| (*v).into()).collect();
2916        let mut versions = HashMap::new();
2917        versions.insert(
2918            PackageName::new("pkg"),
2919            PackageVersions::new(latest.into(), available_arc)
2920                .with_published_at(published_at)
2921                .with_cooldown_fallback(deps_core::lsp_helpers::CooldownFallback::new(
2922                    fallback_version.into(),
2923                    deps_core::PublishTime::from_unix_secs(1_000),
2924                )),
2925        );
2926        let end = 7 + u32::try_from(req.len()).expect("short test literal");
2927        let analysis = test_analysis(
2928            vec![test_dep(
2929                "pkg",
2930                req,
2931                Range::new(Position::new(0, 7), Position::new(0, end)),
2932            )],
2933            versions,
2934        );
2935        let freshness = deps_core::FreshnessSettings::Enabled {
2936            cooldown: deps_core::CooldownWindow::from_secs(1_000),
2937        };
2938        (analysis, freshness, now)
2939    }
2940
2941    /// Spec 076 T006/FR-027 (verification candidate named by spec §10/tasks.md): under spec
2942    /// 075's guard this asserted `Applied`, because that guard only ever compiled the declared
2943    /// `=1.0.0` text itself, never the WRITTEN edit. Spec 076's two-phase guard re-parses the
2944    /// actual edit: `RealSemverFormatter`'s default rendering writes the fallback BARE
2945    /// (`"1.1.0"`), which Cargo-like semver compiles as a caret range `^1.1.0` — and that range
2946    /// still admits the fresh `1.2.0` it exists to exclude. d1 (`EditedAdmitsNewer`) now
2947    /// correctly rejects it — the documented, out-of-scope common-case fail-closed outcome for
2948    /// an auto-following ecosystem (spec 076 §1), not a regression. `1.0.0` (the exact pin's
2949    /// own value) must be listed for d0's fix-cycle floor-comparison scan to find it and let
2950    /// phase 1 pass, so this test actually reaches d1.
2951    #[test]
2952    fn test_plan_updates_real_semver_formatter_rejects_in_range_caret_admission() {
2953        let content = "pkg = \"=1.0.0\"\n";
2954        let (analysis, freshness, now) =
2955            real_semver_scenario("=1.0.0", &["1.2.0", "1.1.0", "1.0.0"], "1.1.0");
2956        let reparse = reparse_quoted_deps(vec![("pkg", Position::new(0, 7))]);
2957
2958        let plan = plan_updates(
2959            &analysis,
2960            content,
2961            &RealSemverFormatter,
2962            &reparse,
2963            &[],
2964            &IgnoreRules::empty(),
2965            freshness,
2966            now,
2967        );
2968
2969        assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
2970        assert!(
2971            matches!(
2972                plan.items[0].outcome,
2973                Outcome::Skipped {
2974                    reason: SkipReason::WithinFreshnessCooldown,
2975                    ..
2976                }
2977            ),
2978            "got: {:?}",
2979            plan.items[0].outcome
2980        );
2981
2982        // Fix-cycle M3 (impl-critic): `Skipped(WithinFreshnessCooldown)` alone doesn't
2983        // distinguish `EditedAdmitsNewer` from `ReparseFailed`/`OccurrenceNotUnique` — pin the
2984        // exact rejection variant directly.
2985        let dep = test_dep(
2986            "pkg",
2987            "=1.0.0",
2988            Range::new(Position::new(0, 7), Position::new(0, 13)),
2989        );
2990        let candidate = ManifestEdit {
2991            range: Range::new(Position::new(0, 7), Position::new(0, 13)),
2992            new_text: "1.1.0".to_string(),
2993        };
2994        let verdict = deps_core::lsp_helpers::fallback_edit_excludes_newer(
2995            &RealSemverFormatter,
2996            &reparse,
2997            content,
2998            &dep,
2999            &candidate,
3000            &deps_core::ConcreteVersion::new("1.1.0"),
3001            &[
3002                deps_core::ConcreteVersion::new("1.2.0"),
3003                deps_core::ConcreteVersion::new("1.1.0"),
3004                deps_core::ConcreteVersion::new("1.0.0"),
3005            ],
3006        );
3007        assert_eq!(
3008            verdict,
3009            deps_core::lsp_helpers::FallbackEditVerdict::Rejected(
3010                deps_core::lsp_helpers::FallbackEditRejection::EditedAdmitsNewer
3011            )
3012        );
3013    }
3014
3015    /// Spec 075 SC-004/FR-003 (A1 repro), end to end (tester Gap C): `resolve_occurrence`'s own
3016    /// `dep`/`req`/`fb.target` extraction (not just the isolated `fallback_satisfies_requirement`
3017    /// helper) must reject a fallback that is a downgrade relative to a real `>=3.0.0`
3018    /// requirement `latest` (3.2.0) already resolves past.
3019    #[test]
3020    fn test_plan_updates_real_semver_formatter_rejects_a1_repro_end_to_end() {
3021        let content = "pkg = \">=3.0.0\"\n";
3022        let (analysis, freshness, now) =
3023            real_semver_scenario(">=3.0.0", &["3.2.0", "2.9.0"], "2.9.0");
3024
3025        let plan = plan_updates(
3026            &analysis,
3027            content,
3028            &RealSemverFormatter,
3029            &never_reparse,
3030            &[],
3031            &IgnoreRules::empty(),
3032            freshness,
3033            now,
3034        );
3035
3036        assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3037        assert!(
3038            matches!(
3039                plan.items[0].outcome,
3040                Outcome::Skipped {
3041                    reason: SkipReason::WithinFreshnessCooldown,
3042                    ..
3043                }
3044            ),
3045            "got: {:?}",
3046            plan.items[0].outcome
3047        );
3048        assert!(plan.items[0].cooldown_fallback.is_none());
3049    }
3050
3051    /// Issue #1564, end to end (crates.io `bevy_brp_mcp`-shaped repro), corrected per T006/S1
3052    /// for spec 076: a three-version, live-registry-shaped fixture — `0.22.8` (newest,
3053    /// cooldown-blocked, would be `latest`), `0.22.7` (cleared, the stored fallback), `0.22.6`
3054    /// (declared/locked) — under a bare (implicit-caret) Cargo-style requirement.
3055    ///
3056    /// #1565 (shipped, spec-075-only scope) fixed d0 (the check against the UNEDITED
3057    /// requirement) to stop over-rejecting this shape and expected `Applied(0.22.7)`. Spec
3058    /// 076's independent, ADDITIONAL d1 check (against the WRITTEN edit, which #1565 never had
3059    /// since spec 075 had no re-parse mechanism at all) still correctly rejects it: writing the
3060    /// fallback bare renders `^0.22.7`, which — exactly like `^0.22.6` — still admits the
3061    /// fresh, cooldown-blocked `0.22.8`. This is spec 076 §1's documented, user-decided
3062    /// (OQ-C) common-case fail-closed outcome for an auto-following ecosystem, not a
3063    /// regression of #1565 — see this fix cycle's handoff for the full architectural note.
3064    #[test]
3065    fn test_plan_updates_real_semver_formatter_rejects_permissive_range_1564() {
3066        let content = "pkg = \"0.22.6\"\n";
3067        let (analysis, freshness, now) =
3068            real_semver_scenario("0.22.6", &["0.22.8", "0.22.7", "0.22.6"], "0.22.7");
3069        let reparse = reparse_quoted_deps(vec![("pkg", Position::new(0, 7))]);
3070
3071        let plan = plan_updates(
3072            &analysis,
3073            content,
3074            &RealSemverFormatter,
3075            &reparse,
3076            &[],
3077            &IgnoreRules::empty(),
3078            freshness,
3079            now,
3080        );
3081
3082        assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3083        assert!(
3084            matches!(
3085                plan.items[0].outcome,
3086                Outcome::Skipped {
3087                    reason: SkipReason::WithinFreshnessCooldown,
3088                    ..
3089                }
3090            ),
3091            "got: {:?}",
3092            plan.items[0].outcome
3093        );
3094        assert!(plan.items[0].cooldown_fallback.is_none());
3095    }
3096
3097    /// Issue #1561, end to end (CWE-1284): the declared exact pin (`=1.5.0`) is absent from the
3098    /// registry's version list (unpublished/yanked/filtered) and the fallback (`1.4.0`) is
3099    /// strictly older than it — must never be applied, regardless of how the fallback view
3100    /// itself classifies the occurrence. Rejected at d0 (`OriginalResolvesPastFallback`),
3101    /// before re-parse is ever consulted.
3102    #[test]
3103    fn test_plan_updates_fails_closed_on_unlisted_pin_downgrade_1561() {
3104        let content = "pkg = \"=1.5.0\"\n";
3105        let (analysis, freshness, now) =
3106            real_semver_scenario("=1.5.0", &["2.0.0", "1.4.0", "1.0.0"], "1.4.0");
3107
3108        let plan = plan_updates(
3109            &analysis,
3110            content,
3111            &RealSemverFormatter,
3112            &never_reparse,
3113            &[],
3114            &IgnoreRules::empty(),
3115            freshness,
3116            now,
3117        );
3118
3119        assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3120        assert!(
3121            matches!(
3122                plan.items[0].outcome,
3123                Outcome::Skipped {
3124                    reason: SkipReason::WithinFreshnessCooldown,
3125                    ..
3126                }
3127            ),
3128            "1.4.0 is a downgrade below the unlisted declared pin 1.5.0 and must never be \
3129             applied: {:?}",
3130            plan.items[0]
3131        );
3132        assert_eq!(
3133            plan.items[0].target(),
3134            Some(&ConcreteVersion::from("2.0.0")),
3135            "the rejected fallback must never leak into the reported target either"
3136        );
3137        assert!(plan.items[0].cooldown_fallback.is_none());
3138    }
3139
3140    /// Spec 075 SC-005/FR-012 (OQ3): a flagged latest with an independently Verified fallback
3141    /// candidate resolves to `Applied(fallback)`, keeping the flagged-latest attribution
3142    /// (advisory ids) in the same row.
3143    ///
3144    /// Issue #1561 item 1: `fallback_status` is populated alongside `latest_status` here (both,
3145    /// not just one) — a bare `None` for `fallback_status` while `latest_status` is `Some` now
3146    /// fails the fallback candidate closed to `Unverified` rather than silently bypassing OSV
3147    /// verification, so a test genuinely claiming an "independently Verified" fallback must set
3148    /// up that verification explicitly.
3149    #[test]
3150    fn test_plan_updates_flagged_latest_with_verified_fallback_applies_fallback() {
3151        use deps_core::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
3152
3153        let content = "pkg = \"=1.0.0\"\n";
3154        let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
3155        let mut latest_status = LatestStatusMap::new();
3156        latest_status.insert(
3157            deps_core::test_util::vuln_key("pkg"),
3158            UpgradeStatus::CandidateVulnerable {
3159                version: ConcreteVersion::new("2.0.0"),
3160                advisory_ids: Capped::new(vec!["GHSA-xxxx".to_string()], 1),
3161                worst_severity: Some(VulnSeverity::High),
3162            },
3163        );
3164        analysis.latest_status = Some(latest_status);
3165        // Issue #1561 item 1: `latest_status` is populated, so `fallback_status` must be too —
3166        // a bare `None` now fails the fallback closed to `Unverified` (defense-in-depth).
3167        let mut fallback_status = LatestStatusMap::new();
3168        fallback_status.insert(
3169            deps_core::test_util::vuln_key("pkg"),
3170            UpgradeStatus::CandidateClean {
3171                version: ConcreteVersion::new("1.1.0"),
3172            },
3173        );
3174        analysis.fallback_status = Some(fallback_status);
3175        // Spec 076: reaching `Applied` needs a real `compile_bounded_requirement` (the Go-bypass
3176        // `FALLBACK_FORMATTER` no longer short-circuits the guard, FR-022) and a working
3177        // re-parse (FR-024).
3178        let reparse = reparse_quoted_deps(vec![("pkg", Position::new(0, 7))]);
3179
3180        let plan = plan_updates(
3181            &analysis,
3182            content,
3183            &RealSemverFormatter,
3184            &reparse,
3185            &[],
3186            &IgnoreRules::empty(),
3187            freshness,
3188            now,
3189        );
3190
3191        assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3192        assert!(
3193            matches!(plan.items[0].outcome, Outcome::Applied { .. }),
3194            "got: {:?}",
3195            plan.items[0].outcome
3196        );
3197        assert_eq!(
3198            plan.items[0].target(),
3199            Some(&ConcreteVersion::from("1.1.0"))
3200        );
3201        assert!(
3202            !plan.items[0].advisory_ids.is_empty(),
3203            "the flagged-latest attribution must be retained: {:?}",
3204            plan.items[0]
3205        );
3206        assert_eq!(
3207            plan.items[0].cooldown_fallback,
3208            Some(CooldownFallbackNote::AppliedInsteadOf("2.0.0".into()))
3209        );
3210    }
3211
3212    /// Issue #1561 item 1: `latest_status` is populated (OSV verification is in effect for
3213    /// this run) but `fallback_status` was never set — a caller bug distinct from an
3214    /// intentionally offline/no-OSV run. The fallback candidate must fail closed to
3215    /// `Unverified` (never written) rather than silently bypassing OSV verification as
3216    /// `NotApplicable` ("no check needed") the way a bare `None` for BOTH fields does.
3217    #[test]
3218    fn test_plan_updates_latest_status_without_fallback_status_treats_fallback_as_unverified() {
3219        use deps_core::osv::{LatestStatusMap, UpgradeStatus};
3220
3221        let content = "pkg = \"1.0.0\"\n";
3222        let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
3223        let mut latest_status = LatestStatusMap::new();
3224        latest_status.insert(
3225            deps_core::test_util::vuln_key("pkg"),
3226            UpgradeStatus::CandidateClean {
3227                version: ConcreteVersion::new("1.2.0"),
3228            },
3229        );
3230        analysis.latest_status = Some(latest_status);
3231        // `analysis.fallback_status` deliberately left `None`.
3232
3233        let plan = plan_updates(
3234            &analysis,
3235            content,
3236            &FALLBACK_FORMATTER,
3237            &never_reparse,
3238            &[],
3239            &IgnoreRules::empty(),
3240            freshness,
3241            now,
3242        );
3243
3244        assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3245        assert!(
3246            matches!(
3247                plan.items[0].outcome,
3248                Outcome::Skipped {
3249                    reason: SkipReason::NotSafelyEditable(
3250                        deps_core::edit::UnplannableReason::LatestUnverified
3251                    ),
3252                    ..
3253                }
3254            ),
3255            "got: {:?}",
3256            plan.items[0].outcome
3257        );
3258    }
3259
3260    /// Issue #1561 item 2: `resolve_occurrence`'s `fb.target != fallback.version` guard
3261    /// (update/mod.rs's `'occurrence` block). Simulates the divergence it defends against — a
3262    /// stale/inconsistent precomputed `analysis.cooldown_fallback_view` (`latest` = `1.9.0`)
3263    /// disagreeing with `cached_versions`' own stored `CooldownFallback` (`1.1.0`) that
3264    /// `cooldown_disposition` independently picks inside `resolve_occurrence` — and confirms
3265    /// the divergence falls through to the same fail-closed handling as "fallback absent",
3266    /// never silently writing either version.
3267    #[test]
3268    fn test_plan_updates_fb_target_fallback_version_divergence_fails_closed() {
3269        let content = "pkg = \"=1.0.0\"\n";
3270        let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
3271
3272        let mut divergent_view = HashMap::new();
3273        divergent_view.insert(
3274            PackageName::new("pkg"),
3275            PackageVersions::latest_only("1.9.0"),
3276        );
3277        analysis.cooldown_fallback_view = Some(divergent_view);
3278
3279        let plan = plan_updates(
3280            &analysis,
3281            content,
3282            &FALLBACK_FORMATTER,
3283            &never_reparse,
3284            &[],
3285            &IgnoreRules::empty(),
3286            freshness,
3287            now,
3288        );
3289
3290        assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3291        assert!(
3292            matches!(
3293                plan.items[0].outcome,
3294                Outcome::Skipped {
3295                    reason: SkipReason::WithinFreshnessCooldown,
3296                    ..
3297                }
3298            ),
3299            "a divergence between the fallback view's own planned target (1.9.0) and \
3300             `cooldown_disposition`'s independently computed pick (1.1.0) must never be \
3301             silently written: {:?}",
3302            plan.items[0]
3303        );
3304        assert_eq!(
3305            plan.items[0].target(),
3306            Some(&ConcreteVersion::from("2.0.0")),
3307            "must fall back to the real (unmodified) latest, never either divergent fallback \
3308             value"
3309        );
3310        assert!(plan.items[0].cooldown_fallback.is_none());
3311    }
3312
3313    /// Spec 075 SC-006/FR-011 (OQ5'): the fallback candidate is itself OSV-`Flagged` — exit 1,
3314    /// naming the blocked fallback version, never a silent cooldown skip.
3315    #[test]
3316    fn test_plan_updates_flagged_fallback_blocks_and_exits_nonzero() {
3317        use deps_core::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
3318
3319        let content = "pkg = \"=1.0.0\"\n";
3320        let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
3321        let mut fallback_status = LatestStatusMap::new();
3322        fallback_status.insert(
3323            deps_core::test_util::vuln_key("pkg"),
3324            UpgradeStatus::CandidateVulnerable {
3325                version: ConcreteVersion::new("1.1.0"),
3326                advisory_ids: Capped::new(vec!["GHSA-yyyy".to_string()], 1),
3327                worst_severity: Some(VulnSeverity::High),
3328            },
3329        );
3330        analysis.fallback_status = Some(fallback_status);
3331
3332        let plan = plan_updates(
3333            &analysis,
3334            content,
3335            &FALLBACK_FORMATTER,
3336            &never_reparse,
3337            &[],
3338            &IgnoreRules::empty(),
3339            freshness,
3340            now,
3341        );
3342
3343        assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3344        assert!(
3345            matches!(
3346                plan.items[0].outcome,
3347                Outcome::Skipped {
3348                    reason: SkipReason::NotSafelyEditable(
3349                        deps_core::edit::UnplannableReason::LatestFlaggedByOsv
3350                    ),
3351                    ..
3352                }
3353            ),
3354            "got: {:?}",
3355            plan.items[0].outcome
3356        );
3357        assert_eq!(
3358            plan.items[0].target(),
3359            Some(&ConcreteVersion::from("1.1.0")),
3360            "must name the blocked fallback version, not latest"
3361        );
3362        assert!(!plan.items[0].advisory_ids.is_empty());
3363        assert_eq!(
3364            plan.items[0].cooldown_fallback,
3365            Some(CooldownFallbackNote::Blocked {
3366                version: "1.1.0".into()
3367            })
3368        );
3369        assert_eq!(
3370            crate::exit::update_exit_code(&plan),
3371            crate::exit::EXIT_POLICY_VIOLATION
3372        );
3373    }
3374
3375    /// Spec 075 SC-006/FR-011 (OQ5'): the fallback candidate is `Unverified` (never checked) —
3376    /// exit 1 for parity with the `Flagged` case, not a silent exit-0 skip.
3377    #[test]
3378    fn test_plan_updates_unverified_fallback_blocks_and_exits_nonzero() {
3379        use deps_core::osv::LatestStatusMap;
3380
3381        let content = "pkg = \"=1.0.0\"\n";
3382        let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
3383        // Present but empty: OSV checking is on, but this fallback was never verified.
3384        analysis.fallback_status = Some(LatestStatusMap::new());
3385
3386        let plan = plan_updates(
3387            &analysis,
3388            content,
3389            &FALLBACK_FORMATTER,
3390            &never_reparse,
3391            &[],
3392            &IgnoreRules::empty(),
3393            freshness,
3394            now,
3395        );
3396
3397        assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3398        assert!(
3399            matches!(
3400                plan.items[0].outcome,
3401                Outcome::Skipped {
3402                    reason: SkipReason::NotSafelyEditable(
3403                        deps_core::edit::UnplannableReason::LatestUnverified
3404                    ),
3405                    ..
3406                }
3407            ),
3408            "got: {:?}",
3409            plan.items[0].outcome
3410        );
3411        assert_eq!(
3412            plan.items[0].target(),
3413            Some(&ConcreteVersion::from("1.1.0"))
3414        );
3415        assert_eq!(
3416            crate::exit::update_exit_code(&plan),
3417            crate::exit::EXIT_POLICY_VIOLATION
3418        );
3419    }
3420
3421    /// Tester Gap A / decision-table row 8: both `latest` AND the fallback are OSV-blocked
3422    /// simultaneously — defers entirely to `latest`'s own attribution (via `resolve_from_latest`),
3423    /// exit 1, never demoted, regardless of why the fallback also failed.
3424    #[test]
3425    fn test_plan_updates_both_latest_and_fallback_osv_blocked_defers_to_latest() {
3426        use deps_core::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
3427
3428        let content = "pkg = \"=1.0.0\"\n";
3429        let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
3430        let flagged = |version: &str| {
3431            let mut status = LatestStatusMap::new();
3432            status.insert(
3433                deps_core::test_util::vuln_key("pkg"),
3434                UpgradeStatus::CandidateVulnerable {
3435                    version: deps_core::ConcreteVersion::new(version),
3436                    advisory_ids: Capped::new(vec!["GHSA-x".to_string()], 1),
3437                    worst_severity: Some(VulnSeverity::High),
3438                },
3439            );
3440            status
3441        };
3442        analysis.latest_status = Some(flagged("2.0.0"));
3443        analysis.fallback_status = Some(flagged("1.1.0"));
3444
3445        let plan = plan_updates(
3446            &analysis,
3447            content,
3448            &FALLBACK_FORMATTER,
3449            &never_reparse,
3450            &[],
3451            &IgnoreRules::empty(),
3452            freshness,
3453            now,
3454        );
3455
3456        assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3457        assert!(
3458            matches!(
3459                plan.items[0].outcome,
3460                Outcome::Skipped {
3461                    reason: SkipReason::NotSafelyEditable(
3462                        deps_core::edit::UnplannableReason::LatestFlaggedByOsv
3463                    ),
3464                    ..
3465                }
3466            ),
3467            "got: {:?}",
3468            plan.items[0].outcome
3469        );
3470        assert!(
3471            plan.items[0].cooldown_fallback.is_none(),
3472            "row 8 defers entirely to latest's own attribution, no fallback note"
3473        );
3474        assert_eq!(
3475            crate::exit::update_exit_code(&plan),
3476            crate::exit::EXIT_POLICY_VIOLATION
3477        );
3478    }
3479
3480    /// Tester Gap B / decision-table row 11: the fallback is blocked by a non-OSV structural
3481    /// reason (an unsafe version string) — a routine cooldown skip, exit clean, never
3482    /// `NotSafelyEditable`.
3483    #[test]
3484    fn test_plan_updates_fallback_blocked_by_non_osv_reason_is_cooldown_skip() {
3485        let content = "pkg = \"=1.0.0\"\n";
3486        // A space is outside `is_safe_version_string`'s allowlist, so the fallback view
3487        // resolves this occurrence to `Unplannable(UnsafeLatestVersion)`.
3488        let (analysis, freshness, now) = fallback_scenario_analysis("1.1.0 unsafe");
3489
3490        let plan = plan_updates(
3491            &analysis,
3492            content,
3493            &FALLBACK_FORMATTER,
3494            &never_reparse,
3495            &[],
3496            &IgnoreRules::empty(),
3497            freshness,
3498            now,
3499        );
3500
3501        assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3502        assert!(
3503            matches!(
3504                plan.items[0].outcome,
3505                Outcome::Skipped {
3506                    reason: SkipReason::WithinFreshnessCooldown,
3507                    ..
3508                }
3509            ),
3510            "a fallback blocked by a non-OSV structural reason is a routine cooldown skip, not \
3511             an exit-1 safety refusal: {:?}",
3512            plan.items[0].outcome
3513        );
3514        assert_eq!(
3515            crate::exit::update_exit_code(&plan),
3516            crate::exit::EXIT_CLEAN
3517        );
3518    }
3519
3520    /// Fix-cycle item 3/S3 (impl-critic repro E): row 7 (a flagged latest with a clean
3521    /// fallback) must still honor `[update].ignore` — previously it wrote `Applied(fb.edit)`
3522    /// unconditionally, bypassing the rule the identical row-9 case already respected.
3523    ///
3524    /// Fix-cycle S1 (impl-critic, significant): this MUST reach `requirement_ok = true` (the
3525    /// guard's `Writable` verdict) so the ignore-rule check inside that branch is the thing
3526    /// actually under test — `RealSemverFormatter` + a working `reparse_quoted_deps` and a
3527    /// `content` literal matching the exact-pin requirement text are required for that; using
3528    /// `FALLBACK_FORMATTER`/`never_reparse` (a0-uncompilable) made this test pass through
3529    /// `never_demoted`'s OWN, unrelated ignore-rule check instead, leaving the actual row-7
3530    /// branch with zero coverage (proven by mutation: removing the ignore-rule check inside
3531    /// `requirement_ok` still passed 318/318 deps-cli tests before this fix).
3532    #[tokio::test]
3533    async fn test_plan_updates_row7_honors_ignore_rule_instead_of_applying() {
3534        use deps_core::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
3535
3536        let content = "pkg = \"=1.0.0\"\n";
3537        let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
3538        let mut latest_status = LatestStatusMap::new();
3539        latest_status.insert(
3540            deps_core::test_util::vuln_key("pkg"),
3541            UpgradeStatus::CandidateVulnerable {
3542                version: ConcreteVersion::new("2.0.0"),
3543                advisory_ids: Capped::new(vec!["GHSA-x".to_string()], 1),
3544                worst_severity: Some(VulnSeverity::High),
3545            },
3546        );
3547        analysis.latest_status = Some(latest_status);
3548        let ignore_pkg = IgnoreRules::new(
3549            vec![crate::config::IgnoreRule {
3550                name: "pkg".to_string(),
3551                update_types: None,
3552            }],
3553            &RealSemverFormatter,
3554        );
3555        let reparse = reparse_quoted_deps(vec![("pkg", Position::new(0, 7))]);
3556
3557        let plan = plan_updates(
3558            &analysis,
3559            content,
3560            &RealSemverFormatter,
3561            &reparse,
3562            &[],
3563            &ignore_pkg,
3564            freshness,
3565            now,
3566        );
3567
3568        assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3569        assert!(
3570            matches!(
3571                plan.items[0].outcome,
3572                Outcome::Skipped {
3573                    reason: SkipReason::IgnoreRule,
3574                    ..
3575                }
3576            ),
3577            "an ignored package's row-7 fallback must not be applied: {:?}",
3578            plan.items[0].outcome
3579        );
3580        assert_eq!(
3581            crate::exit::update_exit_code(&plan),
3582            crate::exit::EXIT_CLEAN
3583        );
3584    }
3585
3586    /// Fix-cycle S1 follow-up (row-9 counterpart, impl-critic recommendation): the identical
3587    /// ignore-rule check inside `requirement_ok`, but for an UNFLAGGED latest (row 9's ordinary
3588    /// cooldown-fallback substitution, not row 7's flagged-latest variant) — proves the
3589    /// ignore-rule branch is covered regardless of which row reaches it.
3590    #[tokio::test]
3591    async fn test_plan_updates_row9_honors_ignore_rule_instead_of_applying() {
3592        let content = "pkg = \"=1.0.0\"\n";
3593        let (analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
3594        let ignore_pkg = IgnoreRules::new(
3595            vec![crate::config::IgnoreRule {
3596                name: "pkg".to_string(),
3597                update_types: None,
3598            }],
3599            &RealSemverFormatter,
3600        );
3601        let reparse = reparse_quoted_deps(vec![("pkg", Position::new(0, 7))]);
3602
3603        let plan = plan_updates(
3604            &analysis,
3605            content,
3606            &RealSemverFormatter,
3607            &reparse,
3608            &[],
3609            &ignore_pkg,
3610            freshness,
3611            now,
3612        );
3613
3614        assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3615        assert!(
3616            matches!(
3617                plan.items[0].outcome,
3618                Outcome::Skipped {
3619                    reason: SkipReason::IgnoreRule,
3620                    ..
3621                }
3622            ),
3623            "an ignored package's row-9 fallback must not be applied: {:?}",
3624            plan.items[0].outcome
3625        );
3626        assert_eq!(
3627            crate::exit::update_exit_code(&plan),
3628            crate::exit::EXIT_CLEAN
3629        );
3630    }
3631
3632    /// Fix-cycle item 4/S4 (impl-critic repro F): row 10 (an OSV-blocked fallback) must still
3633    /// honor `[update].ignore` — previously an ignored package's blocked fallback exited 1
3634    /// regardless of the rule, unlike row 8's identical `resolve_from_latest` handling.
3635    #[test]
3636    fn test_plan_updates_row10_honors_ignore_rule_instead_of_exiting_nonzero() {
3637        use deps_core::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
3638
3639        let content = "pkg = \"=1.0.0\"\n";
3640        let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
3641        let mut fallback_status = LatestStatusMap::new();
3642        fallback_status.insert(
3643            deps_core::test_util::vuln_key("pkg"),
3644            UpgradeStatus::CandidateVulnerable {
3645                version: ConcreteVersion::new("1.1.0"),
3646                advisory_ids: Capped::new(vec!["GHSA-x".to_string()], 1),
3647                worst_severity: Some(VulnSeverity::High),
3648            },
3649        );
3650        analysis.fallback_status = Some(fallback_status);
3651        let ignore_pkg = IgnoreRules::new(
3652            vec![crate::config::IgnoreRule {
3653                name: "pkg".to_string(),
3654                update_types: None,
3655            }],
3656            &FALLBACK_FORMATTER,
3657        );
3658
3659        let plan = plan_updates(
3660            &analysis,
3661            content,
3662            &FALLBACK_FORMATTER,
3663            &never_reparse,
3664            &[],
3665            &ignore_pkg,
3666            freshness,
3667            now,
3668        );
3669
3670        assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3671        assert!(
3672            matches!(
3673                plan.items[0].outcome,
3674                Outcome::Skipped {
3675                    reason: SkipReason::IgnoreRule,
3676                    ..
3677                }
3678            ),
3679            "an ignored package's row-10 blocked fallback must exit clean, not policy-violation: {:?}",
3680            plan.items[0].outcome
3681        );
3682        assert_eq!(
3683            plan.items[0].target(),
3684            None,
3685            "the ignore-rule skip of an OSV-blocked fallback has no concrete target"
3686        );
3687        assert_eq!(
3688            crate::exit::update_exit_code(&plan),
3689            crate::exit::EXIT_CLEAN
3690        );
3691    }
3692
3693    /// Fix-cycle item 5/security M1: the GOSSIP lookup in `resolve_occurrence` must use the
3694    /// RAW package name — `analysis.gossip_findings` (from `fetch_gossip_findings_batch`) is
3695    /// keyed by the raw name, matching `diagnostics::apply_outdated_rule`/hover. Using the
3696    /// *normalized* name instead made the planner blind to GOSSIP data for any formatter whose
3697    /// normalization changes case, reopening the check/update divergence #1529 closed.
3698    #[test]
3699    fn test_plan_updates_gossip_lookup_uses_raw_name_not_normalized() {
3700        let lowercase_formatter: deps_core::test_util::StubFormatter =
3701            deps_core::test_util::StubFormatter::new().with_lowercase_names();
3702        let content = "Django = \"1.0.0\"\n";
3703        let now = deps_core::PublishTime::from_unix_secs(10_000);
3704        // Well outside any realistic local cooldown window — if the GOSSIP-Active verdict is
3705        // missed (the bug), this dependency falls through to the local heuristic and clears.
3706        let old_published_at = deps_core::PublishTime::from_unix_secs(10_000 - 30 * 24 * 60 * 60);
3707        let mut versions = HashMap::new();
3708        versions.insert(
3709            PackageName::new("Django"),
3710            PackageVersions::latest_only("2.0.0").with_published_at(old_published_at),
3711        );
3712        let mut analysis = test_analysis(
3713            vec![test_dep(
3714                "Django",
3715                "1.0.0",
3716                Range::new(Position::new(0, 10), Position::new(0, 15)),
3717            )],
3718            versions,
3719        );
3720        let mut gossip = HashMap::new();
3721        gossip.insert(
3722            PackageName::new("Django"), // raw name — must NOT be looked up as "django"
3723            deps_core::test_util::stub_gossip_findings(
3724                "2.0.0",
3725                Some(deps_core::GossipCooldown::new(
3726                    deps_core::PublishTime::from_unix_secs(10_000 + 1_000),
3727                    deps_core::GossipRiskLevel::High,
3728                )),
3729            ),
3730        );
3731        analysis.gossip_findings = gossip;
3732
3733        let plan = plan_updates(
3734            &analysis,
3735            content,
3736            &lowercase_formatter,
3737            &never_reparse,
3738            &[],
3739            &IgnoreRules::empty(),
3740            deps_core::FreshnessSettings::Enabled {
3741                cooldown: deps_core::CooldownWindow::from_secs(1_000),
3742            },
3743            now,
3744        );
3745
3746        assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
3747        assert!(
3748            matches!(
3749                plan.items[0].outcome,
3750                Outcome::Skipped {
3751                    reason: SkipReason::WithinFreshnessCooldown,
3752                    ..
3753                }
3754            ),
3755            "the GOSSIP-Active cooldown for the raw name 'Django' must be detected even though \
3756             normalize_package_name lowercases it to 'django': {:?}",
3757            plan.items[0].outcome
3758        );
3759    }
3760
3761    /// Code review (M2 minor, strengthened `OccurrenceKey`): two different packages whose
3762    /// `name_range` collides (`TestDep::name_range` always returns `Range::default()`, modeling
3763    /// Gradle/Composer's degraded-parsing paths) must never swap fallback edits or requirement
3764    /// checks — each occurrence's distinct `version_range` disambiguates them.
3765    #[test]
3766    fn test_plan_updates_name_range_collision_does_not_swap_occurrences() {
3767        let content = "alpha = \"=1.0.0\"\nbeta = \"=9.0.0\"\n";
3768        let now = deps_core::PublishTime::from_unix_secs(10_000);
3769        let published_at = deps_core::PublishTime::from_unix_secs(9_900); // within cooldown
3770
3771        // Spec 076: exact pins, with `latest` a major-version bump OUTSIDE the caret range a
3772        // bare-rendered fallback edit compiles to (`^1.1.0` excludes `2.0.0`; `^9.1.0` excludes
3773        // `10.0.0`) — otherwise the two-phase guard's d1 check would correctly fail closed
3774        // (spec 076 §1's documented auto-following-ecosystem case), which is not what this
3775        // test's own point (no cross-occurrence swap) is about.
3776        let mut versions = HashMap::new();
3777        versions.insert(
3778            PackageName::new("alpha"),
3779            // `available` must list both the exact-pin R0's own floor ("1.0.0") and the
3780            // fallback itself ("1.1.0"), or the guard's fail-closed `FallbackUnlisted`/floor
3781            // checks reject before this test's own cross-occurrence scenario is exercised.
3782            PackageVersions::new(
3783                deps_core::ConcreteVersion::new("2.0.0"),
3784                std::sync::Arc::from(vec![
3785                    deps_core::ConcreteVersion::new("2.0.0"),
3786                    deps_core::ConcreteVersion::new("1.1.0"),
3787                    deps_core::ConcreteVersion::new("1.0.0"),
3788                ]),
3789            )
3790            .with_published_at(published_at)
3791            .with_cooldown_fallback(deps_core::lsp_helpers::CooldownFallback::new(
3792                "1.1.0".into(),
3793                deps_core::PublishTime::from_unix_secs(1_000),
3794            )),
3795        );
3796        versions.insert(
3797            PackageName::new("beta"),
3798            PackageVersions::new(
3799                deps_core::ConcreteVersion::new("10.0.0"),
3800                std::sync::Arc::from(vec![
3801                    deps_core::ConcreteVersion::new("10.0.0"),
3802                    deps_core::ConcreteVersion::new("9.1.0"),
3803                    deps_core::ConcreteVersion::new("9.0.0"),
3804                ]),
3805            )
3806            .with_published_at(published_at)
3807            .with_cooldown_fallback(deps_core::lsp_helpers::CooldownFallback::new(
3808                "9.1.0".into(),
3809                deps_core::PublishTime::from_unix_secs(1_000),
3810            )),
3811        );
3812
3813        let analysis = test_analysis(
3814            vec![
3815                test_dep(
3816                    "alpha",
3817                    "=1.0.0",
3818                    Range::new(Position::new(0, 9), Position::new(0, 15)),
3819                ),
3820                test_dep(
3821                    "beta",
3822                    "=9.0.0",
3823                    Range::new(Position::new(1, 8), Position::new(1, 14)),
3824                ),
3825            ],
3826            versions,
3827        );
3828        let reparse = reparse_quoted_deps(vec![
3829            ("alpha", Position::new(0, 9)),
3830            ("beta", Position::new(1, 8)),
3831        ]);
3832
3833        let plan = plan_updates(
3834            &analysis,
3835            content,
3836            &RealSemverFormatter,
3837            &reparse,
3838            &[],
3839            &IgnoreRules::empty(),
3840            deps_core::FreshnessSettings::Enabled {
3841                cooldown: deps_core::CooldownWindow::from_secs(1_000),
3842            },
3843            now,
3844        );
3845
3846        assert_eq!(plan.items.len(), 2, "{:?}", plan.items);
3847        let alpha = plan
3848            .items
3849            .iter()
3850            .find(|i| i.name == "alpha")
3851            .expect("alpha item present");
3852        let beta = plan
3853            .items
3854            .iter()
3855            .find(|i| i.name == "beta")
3856            .expect("beta item present");
3857        assert_eq!(
3858            alpha.target(),
3859            Some(&ConcreteVersion::from("1.1.0")),
3860            "alpha must get its own fallback, not beta's: {alpha:?}"
3861        );
3862        assert_eq!(
3863            beta.target(),
3864            Some(&ConcreteVersion::from("9.1.0")),
3865            "beta must get its own fallback, not alpha's: {beta:?}"
3866        );
3867        assert!(matches!(alpha.outcome, Outcome::Applied { .. }));
3868        assert!(matches!(beta.outcome, Outcome::Applied { .. }));
3869    }
3870
3871    /// Code review (severity upgrade over the earlier "attribution only, never a wrong write"
3872    /// signoff): the SAME package declared twice, both occurrences degrading to the identical
3873    /// `name_range` (`TestDep::name_range` always returns `Range::default()`, modeling
3874    /// Gradle/Composer's degraded-position parsing) but with distinct `version_range`s, is a
3875    /// genuine collision the planner cannot disambiguate from `UpdateCandidate` alone. Proves the
3876    /// fail-closed fix: neither occurrence's FR-003 downgrade guard runs against the other's
3877    /// declared requirement, and neither gets a swapped/incorrect fallback write — both
3878    /// conservatively skip as `WithinFreshnessCooldown` with their own unmodified `latest`.
3879    #[test]
3880    fn test_plan_updates_true_name_range_collision_fails_closed_on_both_occurrences() {
3881        let content = "pkg = \"1.0.0\"\npkg = \"5.0.0\"\n";
3882        let now = deps_core::PublishTime::from_unix_secs(10_000);
3883        let published_at = deps_core::PublishTime::from_unix_secs(9_900); // within cooldown
3884
3885        let mut versions = HashMap::new();
3886        versions.insert(
3887            PackageName::new("pkg"),
3888            PackageVersions::latest_only("1.2.0")
3889                .with_published_at(published_at)
3890                .with_cooldown_fallback(deps_core::lsp_helpers::CooldownFallback::new(
3891                    "1.1.0".into(),
3892                    deps_core::PublishTime::from_unix_secs(1_000),
3893                )),
3894        );
3895
3896        let analysis = test_analysis(
3897            vec![
3898                test_dep(
3899                    "pkg",
3900                    "1.0.0",
3901                    Range::new(Position::new(0, 7), Position::new(0, 12)),
3902                ),
3903                test_dep(
3904                    "pkg",
3905                    "5.0.0",
3906                    Range::new(Position::new(1, 7), Position::new(1, 12)),
3907                ),
3908            ],
3909            versions,
3910        );
3911
3912        let plan = plan_updates(
3913            &analysis,
3914            content,
3915            &FALLBACK_FORMATTER,
3916            &never_reparse,
3917            &[],
3918            &IgnoreRules::empty(),
3919            deps_core::FreshnessSettings::Enabled {
3920                cooldown: deps_core::CooldownWindow::from_secs(1_000),
3921            },
3922            now,
3923        );
3924
3925        assert_eq!(plan.items.len(), 2, "{:?}", plan.items);
3926        for item in &plan.items {
3927            assert!(
3928                matches!(
3929                    item.outcome,
3930                    Outcome::Skipped {
3931                        reason: SkipReason::WithinFreshnessCooldown,
3932                        ..
3933                    }
3934                ),
3935                "an unresolvable collision must fail closed, never approve a downgrade or a \
3936                 swapped write: {item:?}"
3937            );
3938            assert_eq!(
3939                item.target(),
3940                Some(&ConcreteVersion::from("1.2.0")),
3941                "target must stay the real (unmodified) latest, never a fallback picked via a \
3942                 collided lookup: {item:?}"
3943            );
3944        }
3945    }
3946
3947    /// Team-lead/impl-critic follow-up: `fallback_by_key` used to build its map the same
3948    /// "collapse-then-key" way `dep_by_key` did, so on a collision `.collect()` kept only the
3949    /// last occurrence's fallback candidate — the other occurrence could then be resolved
3950    /// against a stolen `UpdateCandidate` in the `Unplannable` branch (rows 8/10/11), a
3951    /// mismatched `SkipReason`/attribution even though (per impl-critic's trace) it never
3952    /// produced a wrong write. `pkg`'s two occurrences collide on `(name, name_range)`: one has
3953    /// a perfectly valid, independently-Applicable fallback; the other's fallback is
3954    /// structurally broken (`NonLiteralSpan`, its declared requirement doesn't match the
3955    /// manifest text at its own span). Excluding collision keys from `fallback_by_key` (mirroring
3956    /// `dep_by_key`) means NEITHER occurrence gets a fallback candidate at all — both fail closed
3957    /// through the "fallback absent" (row 6) branch uniformly, never `Applied`, never a nonzero
3958    /// exit, and never one silently wearing the other's specific `UnplannableReason`.
3959    #[test]
3960    fn test_plan_updates_mixed_planned_unplannable_collision_fails_closed_uniformly() {
3961        let content = "pkg = \"1.0.0\"\npkg = \"1.0.0\"\n";
3962        let now = deps_core::PublishTime::from_unix_secs(10_000);
3963        let published_at = deps_core::PublishTime::from_unix_secs(9_900); // within cooldown
3964
3965        let mut versions = HashMap::new();
3966        versions.insert(
3967            PackageName::new("pkg"),
3968            PackageVersions::latest_only("1.2.0")
3969                .with_published_at(published_at)
3970                .with_cooldown_fallback(deps_core::lsp_helpers::CooldownFallback::new(
3971                    "1.1.0".into(),
3972                    deps_core::PublishTime::from_unix_secs(1_000),
3973                )),
3974        );
3975
3976        let analysis = test_analysis(
3977            vec![
3978                // occ1: requirement matches the manifest text — a genuinely valid,
3979                // independently-Applicable fallback candidate in isolation.
3980                test_dep(
3981                    "pkg",
3982                    "1.0.0",
3983                    Range::new(Position::new(0, 7), Position::new(0, 12)),
3984                ),
3985                // occ2: declared requirement ("9.9.9") does not match the manifest text
3986                // ("1.0.0") at its own span — always `Unplannable(NonLiteralSpan)`,
3987                // independent of which registry view (latest/fallback) classifies it.
3988                test_dep(
3989                    "pkg",
3990                    "9.9.9",
3991                    Range::new(Position::new(1, 7), Position::new(1, 12)),
3992                ),
3993            ],
3994            versions,
3995        );
3996
3997        let plan = plan_updates(
3998            &analysis,
3999            content,
4000            &FALLBACK_FORMATTER,
4001            &never_reparse,
4002            &[],
4003            &IgnoreRules::empty(),
4004            deps_core::FreshnessSettings::Enabled {
4005                cooldown: deps_core::CooldownWindow::from_secs(1_000),
4006            },
4007            now,
4008        );
4009
4010        assert_eq!(plan.items.len(), 2, "{:?}", plan.items);
4011        for item in &plan.items {
4012            assert!(
4013                matches!(
4014                    item.outcome,
4015                    Outcome::Skipped {
4016                        reason: SkipReason::WithinFreshnessCooldown,
4017                        ..
4018                    }
4019                ),
4020                "a collision must fail closed uniformly for both occurrences, never `Applied` \
4021                 and never attributing one occurrence's structural defect to the other: {item:?}"
4022            );
4023        }
4024        assert_eq!(
4025            crate::exit::update_exit_code(&plan),
4026            crate::exit::EXIT_CLEAN
4027        );
4028    }
4029}