Skip to main content

tuff_core/
error.rs

1use std::{error::Error, fmt};
2
3pub type Result<T> = std::result::Result<T, TuffError>;
4
5/// What kind of failure this is (RFC-105 D6).
6///
7/// The kind exists so callers can branch without reading prose: a script
8/// checks the exit code, `--json` output carries the kind string, and a
9/// library consumer matches the enum. Every variant answers a different
10/// question about whose problem it is.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ErrorKind {
13    /// Wrong arguments or flags for the situation. The user's input.
14    Usage,
15    /// A capability, lockfile, agent, or catalog id that does not exist.
16    NotFound,
17    /// A never-overwrite guard fired: Tuff refused to clobber something.
18    Refused,
19    /// Local changes block the operation; `--force` is usually the answer.
20    Drift,
21    /// A source failed: git, an OCI registry, the catalog, the network.
22    Source,
23    /// A file Tuff must parse is not valid: lockfile, manifest, config.
24    Corrupt,
25    /// Understood but not supported: a newer schema, an unknown transport,
26    /// an adapter that cannot express this capability.
27    Unsupported,
28    /// The filesystem said no.
29    Io,
30    /// An invariant Tuff itself broke. Always a bug worth reporting.
31    Internal,
32}
33
34impl ErrorKind {
35    /// The stable string used in `--json` output and in tests.
36    pub fn as_str(self) -> &'static str {
37        match self {
38            Self::Usage => "usage",
39            Self::NotFound => "not_found",
40            Self::Refused => "refused",
41            Self::Drift => "drift",
42            Self::Source => "source",
43            Self::Corrupt => "corrupt",
44            Self::Unsupported => "unsupported",
45            Self::Io => "io",
46            Self::Internal => "internal",
47        }
48    }
49
50    /// The process exit code for this kind.
51    ///
52    /// Three codes are what scripts actually branch on: 2 separates "you
53    /// typed it wrong" from a real failure, and 70 (the BSD `EX_SOFTWARE`
54    /// convention) separates "Tuff has a bug" from both, so a CI log can
55    /// tell them apart without parsing text. Everything else is 1.
56    pub fn exit_code(self) -> i32 {
57        match self {
58            Self::Usage => 2,
59            Self::Internal => 70,
60            _ => 1,
61        }
62    }
63}
64
65impl fmt::Display for ErrorKind {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        f.write_str(self.as_str())
68    }
69}
70
71/// One failure, with the kind of thing that went wrong, what happened, and
72/// optionally what to do about it.
73#[derive(Debug)]
74pub struct TuffError {
75    kind: ErrorKind,
76    message: String,
77    hint: Option<String>,
78    source: Option<Box<dyn Error + Send + Sync>>,
79}
80
81impl TuffError {
82    /// Build an error of a given kind. Prefer the per-kind constructors.
83    pub fn of(kind: ErrorKind, message: impl Into<String>) -> Self {
84        Self {
85            kind,
86            message: message.into(),
87            hint: None,
88            source: None,
89        }
90    }
91
92    /// An error for an invariant Tuff itself broke.
93    ///
94    /// This is also the landing spot for messages not yet classified during
95    /// the migration to typed kinds, which is why the lint gate counts its
96    /// uses outside this module and only lets that count fall.
97    pub fn new(message: impl Into<String>) -> Self {
98        Self::of(ErrorKind::Internal, message)
99    }
100
101    pub fn usage(message: impl Into<String>) -> Self {
102        Self::of(ErrorKind::Usage, message)
103    }
104
105    pub fn not_found(message: impl Into<String>) -> Self {
106        Self::of(ErrorKind::NotFound, message)
107    }
108
109    pub fn refused(message: impl Into<String>) -> Self {
110        Self::of(ErrorKind::Refused, message)
111    }
112
113    pub fn drift(message: impl Into<String>) -> Self {
114        Self::of(ErrorKind::Drift, message)
115    }
116
117    pub fn source_failed(message: impl Into<String>) -> Self {
118        Self::of(ErrorKind::Source, message)
119    }
120
121    pub fn corrupt(message: impl Into<String>) -> Self {
122        Self::of(ErrorKind::Corrupt, message)
123    }
124
125    pub fn unsupported(message: impl Into<String>) -> Self {
126        Self::of(ErrorKind::Unsupported, message)
127    }
128
129    /// Attach one imperative line telling the user what to do next. Kept
130    /// separate from the message so `--json` can carry it as its own field
131    /// and humans get it on its own line.
132    #[must_use]
133    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
134        self.hint = Some(hint.into());
135        self
136    }
137
138    /// Attach the underlying error, preserving the cause chain.
139    #[must_use]
140    pub fn with_source(mut self, source: impl Error + Send + Sync + 'static) -> Self {
141        self.source = Some(Box::new(source));
142        self
143    }
144
145    pub fn kind(&self) -> ErrorKind {
146        self.kind
147    }
148
149    pub fn message(&self) -> &str {
150        &self.message
151    }
152
153    pub fn hint(&self) -> Option<&str> {
154        self.hint.as_deref()
155    }
156
157    pub fn exit_code(&self) -> i32 {
158        self.kind.exit_code()
159    }
160}
161
162impl fmt::Display for TuffError {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        f.write_str(&self.message)
165    }
166}
167
168impl Error for TuffError {
169    fn source(&self) -> Option<&(dyn Error + 'static)> {
170        self.source
171            .as_ref()
172            .map(|source| source.as_ref() as &(dyn Error + 'static))
173    }
174}
175
176impl From<std::io::Error> for TuffError {
177    fn from(error: std::io::Error) -> Self {
178        Self::of(ErrorKind::Io, error.to_string()).with_source(error)
179    }
180}
181
182impl From<serde_json::Error> for TuffError {
183    fn from(error: serde_json::Error) -> Self {
184        Self::of(ErrorKind::Corrupt, error.to_string()).with_source(error)
185    }
186}
187
188impl From<toml::de::Error> for TuffError {
189    fn from(error: toml::de::Error) -> Self {
190        Self::of(
191            ErrorKind::Corrupt,
192            format!("invalid capability manifest TOML: {error}"),
193        )
194        .with_source(error)
195    }
196}
197
198impl From<toml::ser::Error> for TuffError {
199    fn from(error: toml::ser::Error) -> Self {
200        Self::of(
201            ErrorKind::Internal,
202            format!("could not serialize TOML: {error}"),
203        )
204        .with_source(error)
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn tuff_error_displays_message() {
214        let err = TuffError::new("test error");
215        assert_eq!(format!("{}", err), "test error");
216    }
217
218    #[test]
219    fn tuff_error_from_io_error() {
220        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
221        let err: TuffError = io_err.into();
222        assert!(format!("{}", err).contains("file not found"));
223        assert_eq!(err.kind(), ErrorKind::Io);
224        assert!(err.source().is_some(), "the cause chain is preserved");
225    }
226
227    #[test]
228    fn tuff_error_from_serde_json_error() {
229        let json_err = serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
230        let err: TuffError = json_err.into();
231        assert!(!format!("{}", err).is_empty());
232        assert_eq!(err.kind(), ErrorKind::Corrupt);
233    }
234
235    #[test]
236    fn exit_codes_separate_the_three_audiences() {
237        // A script needs to tell "I called it wrong" from "it failed" from
238        // "this is a bug in tuff" without reading the message.
239        assert_eq!(TuffError::usage("bad flag").exit_code(), 2);
240        assert_eq!(TuffError::new("invariant broken").exit_code(), 70);
241        for error in [
242            TuffError::not_found("x"),
243            TuffError::refused("x"),
244            TuffError::drift("x"),
245            TuffError::source_failed("x"),
246            TuffError::corrupt("x"),
247            TuffError::unsupported("x"),
248        ] {
249            assert_eq!(error.exit_code(), 1, "{:?}", error.kind());
250        }
251    }
252
253    #[test]
254    fn a_hint_is_carried_separately_from_the_message() {
255        let error =
256            TuffError::drift("'x' has local changes").with_hint("use --force to replace it");
257        assert_eq!(format!("{error}"), "'x' has local changes");
258        assert_eq!(error.hint(), Some("use --force to replace it"));
259        assert_eq!(error.kind().as_str(), "drift");
260    }
261}