Skip to main content

grabcli_core/
error.rs

1//! One error type for every grabcli command.
2//!
3//! Each error carries a stable machine-readable [`ErrorKind`] so scripts and
4//! assistants can react to it (`--json` prints `{"error":{"kind","message"}}`)
5//! and a process exit code derived from the kind.
6
7use serde::Serialize;
8
9/// Stable error categories. The `snake_case` names are part of the CLI's
10/// public interface: don't rename them.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
12#[serde(rename_all = "snake_case")]
13pub enum ErrorKind {
14    /// Bad arguments or input files.
15    InvalidInput,
16    /// No session saved yet: run `grab auth import`.
17    AuthMissing,
18    /// Grab rejected the session cookies (401) or they expired.
19    AuthExpired,
20    /// The hydra device JWT is missing, expired or rejected and could not be
21    /// re-minted.
22    HydraExpired,
23    /// The Chrome DevTools endpoint could not be reached or misbehaved.
24    CdpUnavailable,
25    /// A request URL was not on the allowlist.
26    UrlNotAllowed,
27    /// Ordering is disabled in config (`allow_ordering = false`).
28    OrderingDisabled,
29    /// An order needs an interactive confirmation or `--yes <quote-id>`.
30    ConfirmationRequired,
31    /// The fresh quote differs from the one the user confirmed.
32    QuoteChanged,
33    /// The local cart is empty or invalid against the menu.
34    CartInvalid,
35    /// No saved delivery address (`location` cookie) in the session.
36    AddressMissing,
37    /// Something requested does not exist (merchant, item, order, voucher).
38    NotFound,
39    /// Grab answered 429.
40    RateLimited,
41    /// `grab auth login --otp`: the code was wrong or already used.
42    OtpInvalid,
43    /// The login page demanded an interactive captcha a headless tab can't do.
44    CaptchaRequired,
45    /// The pending `grab auth login` expired (or none was started).
46    LoginExpired,
47    /// Grab answered with an error we don't map more precisely.
48    Api,
49    /// Network / TLS / timeout.
50    Network,
51    /// Local file system error.
52    Io,
53    /// A response or file didn't have the expected shape.
54    Parse,
55}
56
57impl ErrorKind {
58    pub fn as_str(self) -> &'static str {
59        match self {
60            Self::InvalidInput => "invalid_input",
61            Self::AuthMissing => "auth_missing",
62            Self::AuthExpired => "auth_expired",
63            Self::HydraExpired => "hydra_expired",
64            Self::CdpUnavailable => "cdp_unavailable",
65            Self::UrlNotAllowed => "url_not_allowed",
66            Self::OrderingDisabled => "ordering_disabled",
67            Self::ConfirmationRequired => "confirmation_required",
68            Self::QuoteChanged => "quote_changed",
69            Self::CartInvalid => "cart_invalid",
70            Self::AddressMissing => "address_missing",
71            Self::NotFound => "not_found",
72            Self::RateLimited => "rate_limited",
73            Self::OtpInvalid => "otp_invalid",
74            Self::CaptchaRequired => "captcha_required",
75            Self::LoginExpired => "login_expired",
76            Self::Api => "api",
77            Self::Network => "network",
78            Self::Io => "io",
79            Self::Parse => "parse",
80        }
81    }
82
83    /// Process exit code for this kind. 0 is success, 1 generic failure.
84    pub fn exit_code(self) -> i32 {
85        match self {
86            Self::InvalidInput | Self::CartInvalid | Self::NotFound | Self::AddressMissing => 2,
87            Self::QuoteChanged => 3,
88            Self::AuthMissing
89            | Self::AuthExpired
90            | Self::HydraExpired
91            | Self::CdpUnavailable
92            | Self::OtpInvalid
93            | Self::CaptchaRequired
94            | Self::LoginExpired => 4,
95            Self::OrderingDisabled | Self::UrlNotAllowed => 5,
96            Self::ConfirmationRequired => 6,
97            Self::RateLimited => 7,
98            Self::Api | Self::Network | Self::Io | Self::Parse => 1,
99        }
100    }
101}
102
103#[derive(Debug, thiserror::Error)]
104#[error("{message}")]
105pub struct Error {
106    pub kind: ErrorKind,
107    pub message: String,
108    /// Optional structured details (e.g. a quote diff), printed under
109    /// `error.details` in `--json` mode.
110    pub details: Option<serde_json::Value>,
111}
112
113pub type Result<T, E = Error> = std::result::Result<T, E>;
114
115impl Error {
116    pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
117        Self {
118            kind,
119            message: message.into(),
120            details: None,
121        }
122    }
123
124    #[must_use]
125    pub fn with_details(mut self, details: serde_json::Value) -> Self {
126        self.details = Some(details);
127        self
128    }
129
130    pub fn invalid(message: impl Into<String>) -> Self {
131        Self::new(ErrorKind::InvalidInput, message)
132    }
133
134    pub fn parse(message: impl Into<String>) -> Self {
135        Self::new(ErrorKind::Parse, message)
136    }
137
138    pub fn not_found(message: impl Into<String>) -> Self {
139        Self::new(ErrorKind::NotFound, message)
140    }
141
142    pub fn exit_code(&self) -> i32 {
143        self.kind.exit_code()
144    }
145
146    /// The `{"error": {...}}` object printed in `--json` mode.
147    pub fn to_json(&self) -> serde_json::Value {
148        let mut e = serde_json::json!({
149            "kind": self.kind.as_str(),
150            "message": self.message,
151        });
152        if let Some(d) = &self.details {
153            e["details"] = d.clone();
154        }
155        serde_json::json!({ "error": e })
156    }
157}
158
159impl From<std::io::Error> for Error {
160    fn from(e: std::io::Error) -> Self {
161        Self::new(ErrorKind::Io, e.to_string())
162    }
163}
164
165impl From<serde_json::Error> for Error {
166    fn from(e: serde_json::Error) -> Self {
167        Self::new(ErrorKind::Parse, format!("JSON: {e}"))
168    }
169}
170
171impl From<rusqlite::Error> for Error {
172    fn from(e: rusqlite::Error) -> Self {
173        // Only the local cache database uses SQLite.
174        Self::new(ErrorKind::Io, format!("cache database: {e}"))
175    }
176}
177
178impl From<reqwest::Error> for Error {
179    fn from(e: reqwest::Error) -> Self {
180        // reqwest errors can include the URL; ours never carry secrets in the
181        // URL (cookies and tokens travel in headers), so this is safe to show.
182        Self::new(ErrorKind::Network, e.to_string())
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn json_shape() {
192        let e = Error::new(ErrorKind::HydraExpired, "hydra gone");
193        let j = e.to_json();
194        assert_eq!(j["error"]["kind"], "hydra_expired");
195        assert_eq!(j["error"]["message"], "hydra gone");
196        assert_eq!(e.exit_code(), 4);
197    }
198
199    #[test]
200    fn address_missing_is_not_an_auth_error() {
201        assert_eq!(ErrorKind::AddressMissing.as_str(), "address_missing");
202        assert_eq!(ErrorKind::AddressMissing.exit_code(), 2);
203    }
204
205    #[test]
206    fn quote_changed_exits_3() {
207        assert_eq!(ErrorKind::QuoteChanged.exit_code(), 3);
208    }
209}