release_kit/error.rs
1//! Crate-level error type and exit-code mapping.
2//!
3//! [`RkError`] aggregates errors from every layer via `#[from]`, and
4//! [`RkError::exit_code`] maps each variant to its process exit code. No
5//! other module decides exit codes. Exit `1` stays reserved for one meaning
6//! — a check ran and found violations — while the BSD sysexits range covers
7//! the tool failing to do its job at all. Beside the code, every failure
8//! carries a [`Reason`] from the closed vocabulary in [`crate::diagnostic`]:
9//! the code is the category, the reason the instance.
10
11use thiserror::Error;
12
13use crate::diagnostic::{Diagnostic, Reason};
14
15/// Every failure the binary can exit with.
16#[derive(Debug, Error)]
17pub enum RkError {
18 /// Semantically invalid arguments clap cannot reject on shape alone.
19 #[error("usage: {0}")]
20 Usage(String),
21
22 /// A named embedded entry does not exist; the caller can list the
23 /// valid names with `--list`.
24 #[error("no {kind} named '{name}'; run with --list to see the valid names")]
25 NotFound {
26 /// What class of entry was asked for: chapter, binding, snippet,
27 /// or skill.
28 kind: &'static str,
29 /// The name that resolved to nothing.
30 name: String,
31 },
32
33 /// The command refused to touch the target as found, and the target
34 /// was left unchanged.
35 #[error("{0}")]
36 Refused(String),
37
38 /// A refusal with its parts named, carrying its own reason and the
39 /// hints the boundary renders. New refusals use this; [`Self::Refused`]
40 /// remains for the paths not yet carrying structured hints.
41 #[error("{}", .0.message)]
42 Refusal(Box<Diagnostic>),
43
44 /// A named input is absent — a target that is not a repository, or
45 /// detection that finds no remote — mapping to the sysexits no-input
46 /// code rather than the refusal code.
47 #[error("{}", .0.message)]
48 Missing(Box<Diagnostic>),
49
50 /// A check ran and found violations: the one sanctioned bare exit 1,
51 /// after the per-item report has already been rendered.
52 #[error("{}", .0.message)]
53 CheckFailed(Box<Diagnostic>),
54
55 /// A child process ran and failed for a reason this binary cannot
56 /// classify further; the child's own stderr travels with it.
57 #[error("{}", .0.message)]
58 Subprocess(Box<Diagnostic>),
59
60 /// Filesystem failure, classified by its I/O kind.
61 #[error("io: {0}")]
62 Io(#[from] std::io::Error),
63
64 /// Escape hatch for ad-hoc contexts at the binary boundary.
65 #[error(transparent)]
66 Other(#[from] anyhow::Error),
67}
68
69impl RkError {
70 /// A [`Self::Refusal`] from a built diagnostic.
71 #[must_use]
72 pub fn refusal(diagnostic: Diagnostic) -> Self {
73 Self::Refusal(Box::new(diagnostic))
74 }
75
76 /// A [`Self::Missing`] from a built diagnostic.
77 #[must_use]
78 pub fn missing(diagnostic: Diagnostic) -> Self {
79 Self::Missing(Box::new(diagnostic))
80 }
81
82 /// A [`Self::CheckFailed`] from a built diagnostic.
83 #[must_use]
84 pub fn check_failed(diagnostic: Diagnostic) -> Self {
85 Self::CheckFailed(Box::new(diagnostic))
86 }
87
88 /// A [`Self::Subprocess`] from a built diagnostic.
89 #[must_use]
90 pub fn subprocess(diagnostic: Diagnostic) -> Self {
91 Self::Subprocess(Box::new(diagnostic))
92 }
93
94 /// Map to the process exit code.
95 ///
96 /// `64..=78` follow BSD `sysexits(3)` and mean the tool itself could
97 /// not do its job.
98 #[must_use]
99 pub fn exit_code(&self) -> u8 {
100 match self {
101 Self::Usage(_) => 64,
102 Self::NotFound { .. } | Self::Missing(_) => 66,
103 Self::CheckFailed(_) => 1,
104 Self::Refused(_) | Self::Refusal(_) => 73,
105 Self::Io(e) if e.kind() == std::io::ErrorKind::NotFound => 66,
106 Self::Io(e) if e.kind() == std::io::ErrorKind::PermissionDenied => 77,
107 Self::Io(_) => 74,
108 Self::Subprocess(_) | Self::Other(_) => 70,
109 }
110 }
111
112 /// The reason beside the code: a [`Self::Refusal`] carries its own,
113 /// and every other variant maps to its honest coarse entry.
114 #[must_use]
115 pub fn reason(&self) -> Reason {
116 match self {
117 Self::Usage(_) | Self::NotFound { .. } => Reason::Usage,
118 Self::Refused(_) => Reason::StateDrift,
119 Self::Refusal(diagnostic)
120 | Self::Missing(diagnostic)
121 | Self::CheckFailed(diagnostic)
122 | Self::Subprocess(diagnostic) => diagnostic.reason,
123 Self::Io(_) => Reason::Io,
124 Self::Other(_) => Reason::Internal,
125 }
126 }
127
128 /// The typed form of this failure, for the JSON rendering.
129 #[must_use]
130 pub fn diagnostic(&self) -> Diagnostic {
131 match self {
132 Self::Refusal(diagnostic)
133 | Self::Missing(diagnostic)
134 | Self::CheckFailed(diagnostic)
135 | Self::Subprocess(diagnostic) => (**diagnostic).clone(),
136 _ => Diagnostic::new(self.reason(), self.to_string()),
137 }
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::RkError;
144 use crate::diagnostic::{Diagnostic, Reason};
145
146 #[test]
147 fn exit_code_matrix() {
148 let cases: Vec<(RkError, u8)> = vec![
149 (RkError::Usage("bad".into()), 64),
150 (
151 RkError::NotFound {
152 kind: "chapter",
153 name: "nope".into(),
154 },
155 66,
156 ),
157 (RkError::Refused("left unchanged".into()), 73),
158 (
159 RkError::refusal(Diagnostic::new(Reason::ConfigInvalid, "bad config")),
160 73,
161 ),
162 (
163 RkError::refusal(Diagnostic::new(Reason::TargetNotFound, "no target")),
164 73,
165 ),
166 // A record that does not parse is the target's file to
167 // repair, refused like an invalid configuration.
168 (
169 RkError::refusal(Diagnostic::new(Reason::RecordInvalid, "bad record")),
170 73,
171 ),
172 (
173 RkError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "gone")),
174 66,
175 ),
176 (
177 RkError::Io(std::io::Error::new(
178 std::io::ErrorKind::PermissionDenied,
179 "denied",
180 )),
181 77,
182 ),
183 (RkError::Io(std::io::Error::other("disk fell over")), 74),
184 // The apply's two cases: a plan that is not ready, or whose
185 // inputs moved, refuses as every refusal does; a postcondition
186 // that fails after the writes landed is a check that ran and
187 // found a violation.
188 (
189 RkError::refusal(Diagnostic::new(Reason::PlanNotReady, "blocked")),
190 73,
191 ),
192 (
193 RkError::check_failed(Diagnostic::new(
194 Reason::PostconditionFailed,
195 "the record does not read back",
196 )),
197 1,
198 ),
199 // A target another run holds is a refusal like any other:
200 // this run wrote nothing, and the operator retries.
201 (
202 RkError::refusal(Diagnostic::new(Reason::TargetBusy, "another run holds it")),
203 73,
204 ),
205 // An argument the recorded merge form does not take refuses
206 // with the target untouched, like every refusal.
207 (
208 RkError::refusal(Diagnostic::new(
209 Reason::MergeFormMismatch,
210 "-m under rebase",
211 )),
212 73,
213 ),
214 // A flag that contradicts a preset refuses before any write,
215 // naming the flag and the preset.
216 (
217 RkError::refusal(Diagnostic::new(
218 Reason::PresetContradiction,
219 "--integration forge contradicts --workflow-only",
220 )),
221 73,
222 ),
223 (RkError::Other(anyhow::anyhow!("unclassified")), 70),
224 ];
225 for (err, code) in cases {
226 assert_eq!(err.exit_code(), code, "wrong exit code for {err:?}");
227 }
228 }
229
230 /// Beside the code, every variant answers with a reason, and a refusal
231 /// keeps the one it was built with.
232 #[test]
233 fn every_error_carries_a_reason() {
234 assert_eq!(RkError::Usage("bad".into()).reason(), Reason::Usage);
235 assert_eq!(
236 RkError::Refused("left unchanged".into()).reason(),
237 Reason::StateDrift
238 );
239 assert_eq!(
240 RkError::refusal(Diagnostic::new(Reason::JournalUnavailable, "no journal")).reason(),
241 Reason::JournalUnavailable
242 );
243 assert_eq!(
244 RkError::Other(anyhow::anyhow!("unclassified")).reason(),
245 Reason::Internal
246 );
247 }
248}