Skip to main content

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 payload 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::TargetNotFound, "no target")),
160                73,
161            ),
162            (
163                RkError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "gone")),
164                66,
165            ),
166            (
167                RkError::Io(std::io::Error::new(
168                    std::io::ErrorKind::PermissionDenied,
169                    "denied",
170                )),
171                77,
172            ),
173            (RkError::Io(std::io::Error::other("disk fell over")), 74),
174            (RkError::Other(anyhow::anyhow!("unclassified")), 70),
175        ];
176        for (err, code) in cases {
177            assert_eq!(err.exit_code(), code, "wrong exit code for {err:?}");
178        }
179    }
180
181    /// Beside the code, every variant answers with a reason, and a refusal
182    /// keeps the one it was built with.
183    #[test]
184    fn every_error_carries_a_reason() {
185        assert_eq!(RkError::Usage("bad".into()).reason(), Reason::Usage);
186        assert_eq!(
187            RkError::Refused("left unchanged".into()).reason(),
188            Reason::StateDrift
189        );
190        assert_eq!(
191            RkError::refusal(Diagnostic::new(Reason::JournalUnavailable, "no journal")).reason(),
192            Reason::JournalUnavailable
193        );
194        assert_eq!(
195            RkError::Other(anyhow::anyhow!("unclassified")).reason(),
196            Reason::Internal
197        );
198    }
199}