Skip to main content

deps_cli/
exit.rs

1//! Exit-code mapping (FR-011, FR-012): 0 clean, 1 policy violation, 2 execution error.
2
3use crate::report::{CheckReport, FailOnPolicy};
4use crate::update::{Outcome, SkipReason, UpdatePlan};
5
6/// The process exited cleanly: no finding matched the `--fail-on` policy.
7pub const EXIT_CLEAN: i32 = 0;
8/// At least one finding matched the `--fail-on` policy.
9pub const EXIT_POLICY_VIOLATION: i32 = 1;
10/// A registry required by a non-offline run was unreachable, or another execution error
11/// occurred (a malformed `deps.toml`, an unreadable explicitly-given manifest path, ...).
12pub const EXIT_EXECUTION_ERROR: i32 = 2;
13
14/// Whether a `check` run's execution hit a registry/parse failure independent of any
15/// `--fail-on` policy violation.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ExecutionOutcome {
18    /// No execution-level registry/parse failure occurred.
19    Clean,
20    /// A registry required by a non-offline run was unreachable, or another execution error
21    /// occurred (a malformed `deps.toml`, an unreadable explicitly-given manifest path, ...).
22    Failed,
23}
24
25impl ExecutionOutcome {
26    /// Builds an `ExecutionOutcome` from the accumulated execution-error flag (`true` means
27    /// [`Self::Failed`]).
28    ///
29    /// The single, explicitly named conversion point from that boundary's `bool`
30    /// representation (issue #1436 S1) — deliberately not a `From<bool>` impl; see
31    /// `deps_core::cache::NetworkMode::from_offline_flag`'s doc for why an ambient blanket
32    /// impl defeats the point of typing this API.
33    #[must_use]
34    pub fn from_had_execution_error(had_execution_error: bool) -> Self {
35        if had_execution_error {
36            Self::Failed
37        } else {
38            Self::Clean
39        }
40    }
41}
42
43/// Computes the process exit code for a completed `check` run.
44///
45/// A real policy violation (T021) always reports as `1`, even when `had_execution_error` is
46/// also set: a genuine `--fail-on` hit is the more actionable signal, and an unrelated
47/// registry/parse error elsewhere in the run must not hide it behind a less specific `2`
48/// (spec 062 review S3 — the original precedence order lost this signal, e.g. one malformed
49/// manifest anywhere in a monorepo turning a real `--fail-on vulnerable` hit from `1` into
50/// `2`). `had_execution_error` only takes precedence over an otherwise-*clean* policy result:
51/// a run that could not reach a registry it needed, or failed to parse a manifest, produced
52/// an incomplete report, so its exit code must never claim a clean `0`, even when nothing
53/// already-resolved happened to violate `policy`.
54///
55/// # Examples
56///
57/// ```
58/// use deps_cli::exit::{EXIT_CLEAN, EXIT_EXECUTION_ERROR, EXIT_POLICY_VIOLATION, ExecutionOutcome, exit_code};
59/// use deps_cli::report::{CheckReport, FailOnPolicy};
60///
61/// let clean = CheckReport::default();
62/// let policy = FailOnPolicy::default_categories();
63/// assert_eq!(exit_code(&clean, &policy, ExecutionOutcome::Clean), EXIT_CLEAN);
64/// assert_eq!(exit_code(&clean, &policy, ExecutionOutcome::Failed), EXIT_EXECUTION_ERROR);
65/// ```
66#[must_use]
67pub fn exit_code(
68    report: &CheckReport,
69    policy: &FailOnPolicy,
70    had_execution_error: ExecutionOutcome,
71) -> i32 {
72    if policy.matches(&report.findings) {
73        return EXIT_POLICY_VIOLATION;
74    }
75    if had_execution_error == ExecutionOutcome::Failed {
76        return EXIT_EXECUTION_ERROR;
77    }
78    EXIT_CLEAN
79}
80
81/// Computes the process exit code for a completed `update` run (spec §5's exit-code table,
82/// as amended by S3 — see below).
83///
84/// `0` if every item is [`Outcome::Applied`], the plan was empty (nothing eligible), or every
85/// non-`Applied` item is a deliberate exclusion
86/// ([`Outcome::Skipped`] with reason [`SkipReason::NotRequested`] — a `--package` narrowing —
87/// [`Outcome::Skipped`] with reason [`SkipReason::IgnoreRule`] — a `[update].ignore` match —
88/// [`Outcome::Skipped`] with reason [`SkipReason::WithinFreshnessCooldown`], issue #1525's
89/// automatic, policy-driven pause rather than a failed fix attempt — **not guaranteed to
90/// self-resolve**: it clears only once a release survives long enough to age past the cooldown
91/// window without a newer release replacing it, so a package publishing at least once per
92/// window can stay skipped indefinitely (see [`SkipReason::WithinFreshnessCooldown`]'s own doc)
93/// — or [`Outcome::Skipped`] with reason [`SkipReason::OverlapsAnotherEdit`]: a genuinely
94/// overlapping edit dropped by [`crate::update::dedup_applied_items`], not a failed fix attempt
95/// — the surviving edit at the same span already achieves the write (spec 075 fix-cycle
96/// finding: [`crate::update::plan_updates`] now calls `dedup_applied_items` on itself, per
97/// FR-015, making this variant reachable from default-mode `update` for the first time; leaving
98/// it out of this exemption list regressed a scenario that exited clean before that change).
99/// `1` if at least one item is
100/// [`Outcome::Skipped`] with reason [`SkipReason::NotSafelyEditable`],
101/// [`Outcome::RequiresLockfileUpdate`], or [`Outcome::Unfixable`] — these represent something
102/// the run *wanted* to fix but could not, unlike an operator-requested exclusion. `2`
103/// (execution error — parse/write/TOCTOU/symlink/offline-gate failures) is set by the caller
104/// before this function is ever reached, never by this function itself (FR-022 — a non-zero
105/// exit here never implies the working tree is unmodified: a mixed run can exit `1` with real
106/// edits already on disk).
107///
108/// **S3 amendment**: spec.md's own exit-code table (§5) originally listed *any* skip,
109/// including `NotRequested`/`IgnoreRule`, under exit `1` — directly contradicting US-004's
110/// stated acceptance criterion ("`tokio` is skipped, reported `skipped (ignore-rule)`, and
111/// the run still exits `0` if every other selected dependency was applied"). This function
112/// implements US-004's reading: an operator explicitly asking to skip something is not a
113/// failure. spec.md §5 and `book/src/cli.md` are updated to match.
114///
115/// # Examples
116///
117/// ```
118/// use deps_cli::exit::{EXIT_CLEAN, EXIT_POLICY_VIOLATION, update_exit_code};
119/// use deps_cli::update::UpdatePlan;
120///
121/// assert_eq!(update_exit_code(&UpdatePlan::default()), EXIT_CLEAN);
122/// ```
123#[must_use]
124pub fn update_exit_code(plan: &UpdatePlan) -> i32 {
125    let has_unresolved_item = plan.items.iter().any(|item| {
126        !matches!(
127            item.outcome,
128            Outcome::Applied { .. }
129                | Outcome::Skipped {
130                    reason: SkipReason::NotRequested
131                        | SkipReason::IgnoreRule
132                        | SkipReason::WithinFreshnessCooldown
133                        | SkipReason::OverlapsAnotherEdit,
134                    ..
135                }
136        )
137    });
138    if has_unresolved_item {
139        EXIT_POLICY_VIOLATION
140    } else {
141        EXIT_CLEAN
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::report::{Category, CheckFinding};
149    use deps_core::EcosystemId;
150    use deps_core::diagnostic::Severity;
151    use deps_core::position::Range;
152    use std::path::PathBuf;
153
154    fn finding(category: Category) -> CheckFinding {
155        CheckFinding {
156            ecosystem: EcosystemId::Cargo,
157            manifest_path: PathBuf::from("Cargo.toml"),
158            dependency_name: Some("serde".to_string()),
159            requirement: None,
160            category,
161            code: None,
162            advisory_url: None,
163            advisory_severity: None,
164            severity: Severity::Warning,
165            range: Range::default(),
166            message: "test".to_string(),
167        }
168    }
169
170    #[test]
171    fn test_exit_code_clean_report_is_zero() {
172        let report = CheckReport::default();
173        let policy = FailOnPolicy::default_categories();
174        assert_eq!(
175            exit_code(&report, &policy, ExecutionOutcome::Clean),
176            EXIT_CLEAN
177        );
178    }
179
180    #[test]
181    fn test_exit_code_policy_violation_is_one() {
182        let report = CheckReport {
183            findings: vec![finding(Category::Vulnerable)],
184        };
185        let policy = FailOnPolicy::default_categories();
186        assert_eq!(
187            exit_code(&report, &policy, ExecutionOutcome::Clean),
188            EXIT_POLICY_VIOLATION
189        );
190    }
191
192    #[test]
193    fn test_exit_code_non_failing_category_is_zero() {
194        let report = CheckReport {
195            findings: vec![finding(Category::Outdated)],
196        };
197        let policy = FailOnPolicy::default_categories();
198        assert_eq!(
199            exit_code(&report, &policy, ExecutionOutcome::Clean),
200            EXIT_CLEAN
201        );
202    }
203
204    #[test]
205    fn test_exit_code_execution_error_is_two() {
206        let report = CheckReport::default();
207        let policy = FailOnPolicy::default_categories();
208        assert_eq!(
209            exit_code(&report, &policy, ExecutionOutcome::Failed),
210            EXIT_EXECUTION_ERROR
211        );
212    }
213
214    #[test]
215    fn test_exit_code_execution_error_takes_precedence_over_clean_policy() {
216        let report = CheckReport {
217            findings: vec![finding(Category::Outdated)],
218        };
219        let policy = FailOnPolicy::default_categories();
220        assert_eq!(
221            exit_code(&report, &policy, ExecutionOutcome::Failed),
222            EXIT_EXECUTION_ERROR
223        );
224    }
225
226    /// Regression test for S3 (spec 062 review): a real policy violation must win over an
227    /// unrelated execution error, not be masked by it.
228    #[test]
229    fn test_exit_code_policy_violation_takes_precedence_over_execution_error() {
230        let report = CheckReport {
231            findings: vec![finding(Category::Vulnerable)],
232        };
233        let policy = FailOnPolicy::default_categories();
234        assert_eq!(
235            exit_code(&report, &policy, ExecutionOutcome::Failed),
236            EXIT_POLICY_VIOLATION
237        );
238    }
239
240    // --- update_exit_code (spec 068, S3) ---
241
242    use crate::update::{PlannedUpdateItem, UnfixableReason};
243    use deps_core::edit::{ManifestEdit, UnplannableReason};
244
245    fn update_item(outcome: Outcome) -> PlannedUpdateItem {
246        PlannedUpdateItem {
247            name: "serde".to_string(),
248            current: crate::update::CurrentVersion::Resolved(deps_core::ConcreteVersion::from(
249                "1.0.0",
250            )),
251            outcome,
252            advisory_ids: Vec::new(),
253            ignore_rule_overridden: false,
254            gossip_excluded_version: None,
255            cooldown_fallback: None,
256        }
257    }
258
259    fn applied() -> Outcome {
260        Outcome::Applied {
261            edit: ManifestEdit {
262                range: Range::default(),
263                new_text: "1.2.0".to_string(),
264            },
265            target: deps_core::ConcreteVersion::from("1.2.0"),
266        }
267    }
268
269    #[test]
270    fn test_update_exit_code_empty_plan_is_clean() {
271        assert_eq!(update_exit_code(&UpdatePlan::default()), EXIT_CLEAN);
272    }
273
274    #[test]
275    fn test_update_exit_code_all_applied_is_clean() {
276        let plan = UpdatePlan {
277            items: vec![update_item(applied()), update_item(applied())],
278        };
279        assert_eq!(update_exit_code(&plan), EXIT_CLEAN);
280    }
281
282    /// US-004: an ignore-rule skip alone must not fail the run.
283    #[test]
284    fn test_update_exit_code_ignore_rule_skip_alone_is_clean() {
285        let plan = UpdatePlan {
286            items: vec![
287                update_item(applied()),
288                update_item(Outcome::Skipped {
289                    reason: SkipReason::IgnoreRule,
290                    target: None,
291                }),
292            ],
293        };
294        assert_eq!(update_exit_code(&plan), EXIT_CLEAN);
295    }
296
297    /// US-002: a `--package` exclusion alone must not fail the run.
298    #[test]
299    fn test_update_exit_code_not_requested_skip_alone_is_clean() {
300        let plan = UpdatePlan {
301            items: vec![
302                update_item(applied()),
303                update_item(Outcome::Skipped {
304                    reason: SkipReason::NotRequested,
305                    target: None,
306                }),
307            ],
308        };
309        assert_eq!(update_exit_code(&plan), EXIT_CLEAN);
310    }
311
312    #[test]
313    fn test_update_exit_code_not_safely_editable_skip_is_policy_violation() {
314        let plan = UpdatePlan {
315            items: vec![update_item(Outcome::Skipped {
316                reason: SkipReason::NotSafelyEditable(UnplannableReason::NonLiteralSpan),
317                target: None,
318            })],
319        };
320        assert_eq!(update_exit_code(&plan), EXIT_POLICY_VIOLATION);
321    }
322
323    #[test]
324    fn test_update_exit_code_requires_lockfile_update_is_policy_violation() {
325        let plan = UpdatePlan {
326            items: vec![update_item(Outcome::RequiresLockfileUpdate {
327                target: deps_core::ConcreteVersion::from("1.2.0"),
328            })],
329        };
330        assert_eq!(update_exit_code(&plan), EXIT_POLICY_VIOLATION);
331    }
332
333    #[test]
334    fn test_update_exit_code_unfixable_is_policy_violation() {
335        let plan = UpdatePlan {
336            items: vec![update_item(Outcome::Unfixable(
337                UnfixableReason::FetchFailedOrAbsent,
338            ))],
339        };
340        assert_eq!(update_exit_code(&plan), EXIT_POLICY_VIOLATION);
341    }
342
343    /// Issue #1525: an automatic freshness-cooldown pause is a policy-driven exclusion, not a
344    /// failed fix attempt — must not fail the run any more than `NotRequested`/`IgnoreRule` do.
345    #[test]
346    fn test_update_exit_code_within_freshness_cooldown_skip_alone_is_clean() {
347        let plan = UpdatePlan {
348            items: vec![
349                update_item(applied()),
350                update_item(Outcome::Skipped {
351                    reason: SkipReason::WithinFreshnessCooldown,
352                    target: None,
353                }),
354            ],
355        };
356        assert_eq!(update_exit_code(&plan), EXIT_CLEAN);
357    }
358
359    /// Spec 075 fix-cycle (code review): a genuinely overlapping edit dropped by
360    /// `dedup_applied_items` is not a failed fix attempt — the surviving edit at the same span
361    /// already achieves the write. Regression guard: `plan_updates` now calls
362    /// `dedup_applied_items` on itself (FR-015), making this variant reachable from
363    /// default-mode `update` for the first time; before that change the dropped item never
364    /// became a `PlannedUpdateItem` at all, so this scenario always exited clean.
365    #[test]
366    fn test_update_exit_code_overlaps_another_edit_skip_alone_is_clean() {
367        let plan = UpdatePlan {
368            items: vec![
369                update_item(applied()),
370                update_item(Outcome::Skipped {
371                    reason: SkipReason::OverlapsAnotherEdit,
372                    target: None,
373                }),
374            ],
375        };
376        assert_eq!(update_exit_code(&plan), EXIT_CLEAN);
377    }
378
379    #[test]
380    fn test_update_exit_code_mixed_applied_and_operator_skips_is_clean() {
381        let plan = UpdatePlan {
382            items: vec![
383                update_item(applied()),
384                update_item(Outcome::Skipped {
385                    reason: SkipReason::NotRequested,
386                    target: None,
387                }),
388                update_item(Outcome::Skipped {
389                    reason: SkipReason::IgnoreRule,
390                    target: None,
391                }),
392            ],
393        };
394        assert_eq!(update_exit_code(&plan), EXIT_CLEAN);
395    }
396}