Skip to main content

spar/
error.rs

1//! One error type. Every failure that a user could plausibly cause carries a
2//! sentence explaining what to do about it, because a failure whose reason is
3//! missing from the message costs more than the failure itself.
4
5use std::fmt;
6
7/// Why a call failed, where the answer changes what to do about it.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub enum ErrorKind {
10    #[default]
11    Other,
12    /// The call ran past its deadline and was killed. Worth its own kind
13    /// because asking again means waiting exactly as long again, which is the
14    /// one failure where a retry costs more than it can possibly win.
15    TimedOut,
16    /// The CLI itself could not answer: a non-zero exit, or an error event in
17    /// place of a message. Distinct from an answer that arrived and could not
18    /// be parsed, which is what the retry exists for and which a model
19    /// corrects readily when told what was wrong. Nothing about a refusal, a
20    /// quota, or a crash is corrected by being asked the same thing again.
21    CallFailed,
22    /// A write failed locally, but the destination could not be reread to tell
23    /// whether it landed. Repeating it blindly could duplicate the write.
24    UncertainWrite,
25    /// The process was told to stop while the call ran, and the call was
26    /// killed. Nothing about the call went wrong, so nothing is retried or
27    /// handed to a fallback: the next thing to do is stop.
28    Interrupted,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct SparError {
33    message: String,
34    kind: ErrorKind,
35}
36
37impl SparError {
38    pub fn new(message: impl Into<String>) -> Self {
39        Self {
40            message: message.into(),
41            kind: ErrorKind::Other,
42        }
43    }
44
45    pub fn timed_out(message: impl Into<String>) -> Self {
46        Self {
47            message: message.into(),
48            kind: ErrorKind::TimedOut,
49        }
50    }
51
52    /// The CLI could not answer at all, as opposed to answering unusably.
53    pub fn call_failed(message: impl Into<String>) -> Self {
54        Self {
55            message: message.into(),
56            kind: ErrorKind::CallFailed,
57        }
58    }
59
60    pub fn uncertain_write(message: impl Into<String>) -> Self {
61        Self {
62            message: message.into(),
63            kind: ErrorKind::UncertainWrite,
64        }
65    }
66
67    /// The process was told to stop while this ran.
68    pub fn interrupted(message: impl Into<String>) -> Self {
69        Self {
70            message: message.into(),
71            kind: ErrorKind::Interrupted,
72        }
73    }
74
75    /// Replace the explanation without changing how callers classify the
76    /// failure. Recovery checks use this after safely observing changed work:
77    /// the call must not be retried, but its original failure kind still tells
78    /// the workflow whether committed work can continue.
79    pub fn with_message(&self, message: impl Into<String>) -> Self {
80        Self {
81            message: message.into(),
82            kind: self.kind,
83        }
84    }
85
86    pub fn kind(&self) -> ErrorKind {
87        self.kind
88    }
89
90    /// Whether asking the same thing again could plausibly go better.
91    pub fn worth_retrying(&self) -> bool {
92        !matches!(
93            self.kind,
94            ErrorKind::TimedOut | ErrorKind::UncertainWrite | ErrorKind::Interrupted
95        )
96    }
97
98    pub fn message(&self) -> &str {
99        &self.message
100    }
101
102    /// The last line of a multi-line failure. Useful when a nested command's
103    /// own error is the interesting part and the preamble is not.
104    pub fn last_line(&self) -> &str {
105        self.message.lines().next_back().unwrap_or(&self.message)
106    }
107
108    /// The first line, for one-line status output.
109    pub fn first_line(&self) -> &str {
110        self.message.lines().next().unwrap_or(&self.message)
111    }
112}
113
114impl fmt::Display for SparError {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        f.write_str(&self.message)
117    }
118}
119
120impl std::error::Error for SparError {}
121
122impl From<std::io::Error> for SparError {
123    fn from(e: std::io::Error) -> Self {
124        SparError::new(e.to_string())
125    }
126}
127
128impl From<serde_json::Error> for SparError {
129    fn from(e: serde_json::Error) -> Self {
130        SparError::new(format!("invalid JSON: {e}"))
131    }
132}
133
134impl From<toml::de::Error> for SparError {
135    fn from(e: toml::de::Error) -> Self {
136        SparError::new(format!("invalid TOML: {e}"))
137    }
138}
139
140pub type Result<T> = std::result::Result<T, SparError>;
141
142/// Build a `SparError` with `format!` syntax.
143#[macro_export]
144macro_rules! spar_err {
145    ($($arg:tt)*) => { $crate::error::SparError::new(format!($($arg)*)) };
146}
147
148/// Return early with a `SparError`.
149#[macro_export]
150macro_rules! bail {
151    ($($arg:tt)*) => { return Err($crate::spar_err!($($arg)*)) };
152}