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}