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}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct SparError {
29    message: String,
30    kind: ErrorKind,
31}
32
33impl SparError {
34    pub fn new(message: impl Into<String>) -> Self {
35        Self {
36            message: message.into(),
37            kind: ErrorKind::Other,
38        }
39    }
40
41    pub fn timed_out(message: impl Into<String>) -> Self {
42        Self {
43            message: message.into(),
44            kind: ErrorKind::TimedOut,
45        }
46    }
47
48    /// The CLI could not answer at all, as opposed to answering unusably.
49    pub fn call_failed(message: impl Into<String>) -> Self {
50        Self {
51            message: message.into(),
52            kind: ErrorKind::CallFailed,
53        }
54    }
55
56    pub fn uncertain_write(message: impl Into<String>) -> Self {
57        Self {
58            message: message.into(),
59            kind: ErrorKind::UncertainWrite,
60        }
61    }
62
63    /// Replace the explanation without changing how callers classify the
64    /// failure. Recovery checks use this after safely observing changed work:
65    /// the call must not be retried, but its original failure kind still tells
66    /// the workflow whether committed work can continue.
67    pub fn with_message(&self, message: impl Into<String>) -> Self {
68        Self {
69            message: message.into(),
70            kind: self.kind,
71        }
72    }
73
74    pub fn kind(&self) -> ErrorKind {
75        self.kind
76    }
77
78    /// Whether asking the same thing again could plausibly go better.
79    pub fn worth_retrying(&self) -> bool {
80        !matches!(self.kind, ErrorKind::TimedOut | ErrorKind::UncertainWrite)
81    }
82
83    pub fn message(&self) -> &str {
84        &self.message
85    }
86
87    /// The last line of a multi-line failure. Useful when a nested command's
88    /// own error is the interesting part and the preamble is not.
89    pub fn last_line(&self) -> &str {
90        self.message.lines().next_back().unwrap_or(&self.message)
91    }
92
93    /// The first line, for one-line status output.
94    pub fn first_line(&self) -> &str {
95        self.message.lines().next().unwrap_or(&self.message)
96    }
97}
98
99impl fmt::Display for SparError {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        f.write_str(&self.message)
102    }
103}
104
105impl std::error::Error for SparError {}
106
107impl From<std::io::Error> for SparError {
108    fn from(e: std::io::Error) -> Self {
109        SparError::new(e.to_string())
110    }
111}
112
113impl From<serde_json::Error> for SparError {
114    fn from(e: serde_json::Error) -> Self {
115        SparError::new(format!("invalid JSON: {e}"))
116    }
117}
118
119impl From<toml::de::Error> for SparError {
120    fn from(e: toml::de::Error) -> Self {
121        SparError::new(format!("invalid TOML: {e}"))
122    }
123}
124
125pub type Result<T> = std::result::Result<T, SparError>;
126
127/// Build a `SparError` with `format!` syntax.
128#[macro_export]
129macro_rules! spar_err {
130    ($($arg:tt)*) => { $crate::error::SparError::new(format!($($arg)*)) };
131}
132
133/// Return early with a `SparError`.
134#[macro_export]
135macro_rules! bail {
136    ($($arg:tt)*) => { return Err($crate::spar_err!($($arg)*)) };
137}