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 (
167 RkError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "gone")),
168 66,
169 ),
170 (
171 RkError::Io(std::io::Error::new(
172 std::io::ErrorKind::PermissionDenied,
173 "denied",
174 )),
175 77,
176 ),
177 (RkError::Io(std::io::Error::other("disk fell over")), 74),
178 // The apply's two cases: a plan that is not ready, or whose
179 // inputs moved, refuses as every refusal does; a postcondition
180 // that fails after the writes landed is a check that ran and
181 // found a violation.
182 (
183 RkError::refusal(Diagnostic::new(Reason::PlanNotReady, "blocked")),
184 73,
185 ),
186 (
187 RkError::check_failed(Diagnostic::new(
188 Reason::PostconditionFailed,
189 "the record does not read back",
190 )),
191 1,
192 ),
193 // A target another run holds is a refusal like any other:
194 // this run wrote nothing, and the operator retries.
195 (
196 RkError::refusal(Diagnostic::new(Reason::TargetBusy, "another run holds it")),
197 73,
198 ),
199 (RkError::Other(anyhow::anyhow!("unclassified")), 70),
200 ];
201 for (err, code) in cases {
202 assert_eq!(err.exit_code(), code, "wrong exit code for {err:?}");
203 }
204 }
205
206 /// Beside the code, every variant answers with a reason, and a refusal
207 /// keeps the one it was built with.
208 #[test]
209 fn every_error_carries_a_reason() {
210 assert_eq!(RkError::Usage("bad".into()).reason(), Reason::Usage);
211 assert_eq!(
212 RkError::Refused("left unchanged".into()).reason(),
213 Reason::StateDrift
214 );
215 assert_eq!(
216 RkError::refusal(Diagnostic::new(Reason::JournalUnavailable, "no journal")).reason(),
217 Reason::JournalUnavailable
218 );
219 assert_eq!(
220 RkError::Other(anyhow::anyhow!("unclassified")).reason(),
221 Reason::Internal
222 );
223 }
224}