Skip to main content

romm_cli/
error.rs

1//! Typed error hierarchy for the romm-cli library.
2//!
3//! Domain enums use `thiserror`; the binary boundary converts [`RommError`] to
4//! user-facing messages and exit codes via [`user_message`] and [`exit_code`].
5
6use reqwest::StatusCode;
7use thiserror::Error;
8
9use crate::core::interrupt::CancelledByUser;
10
11// ---------------------------------------------------------------------------
12// ApiError
13// ---------------------------------------------------------------------------
14
15/// HTTP and API-layer failures from [`crate::client::RommClient`].
16#[derive(Debug, Error)]
17pub enum ApiError {
18    #[error("ROMM API error: 401 Unauthorized - {body}")]
19    Unauthorized { body: String },
20
21    #[error("ROMM API error: 403 Forbidden - {body}")]
22    Forbidden { body: String },
23
24    #[error("ROMM API error: 404 Not Found - {body}")]
25    NotFound { path: String, body: String },
26
27    #[error("ROMM API error: 429 Too Many Requests - {body}")]
28    RateLimited {
29        retry_after: Option<u64>,
30        body: String,
31    },
32
33    #[error("ROMM API error: {status} - {body}")]
34    ClientError { status: u16, body: String },
35
36    #[error("ROMM API error: {status} - {body}")]
37    ServerError { status: u16, body: String },
38
39    #[error("request failed: {0}")]
40    Request(#[from] reqwest::Error),
41
42    #[error("invalid response: {0}")]
43    Decode(#[from] serde_json::Error),
44
45    #[error("invalid HTTP method: {0}")]
46    InvalidMethod(String),
47
48    #[error("invalid HTTP header: {0}")]
49    InvalidHeader(String),
50
51    #[error("unexpected API response: {0}")]
52    UnexpectedResponse(String),
53
54    #[error("I/O error: {0}")]
55    Io(#[from] std::io::Error),
56}
57
58impl ApiError {
59    /// Map an HTTP status code and response body to a typed variant.
60    pub fn from_http_response(status: StatusCode, body: impl Into<String>) -> Self {
61        let body = body.into();
62        match status.as_u16() {
63            401 => Self::Unauthorized { body },
64            403 => Self::Forbidden { body },
65            404 => Self::NotFound {
66                path: String::new(),
67                body,
68            },
69            429 => Self::RateLimited {
70                retry_after: None,
71                body,
72            },
73            500..=599 => Self::ServerError {
74                status: status.as_u16(),
75                body,
76            },
77            400..=499 => Self::ClientError {
78                status: status.as_u16(),
79                body,
80            },
81            _ => Self::ClientError {
82                status: status.as_u16(),
83                body,
84            },
85        }
86    }
87
88    /// HTTP status when this error represents an API response failure.
89    pub fn status_code(&self) -> Option<u16> {
90        match self {
91            Self::Unauthorized { .. } => Some(401),
92            Self::Forbidden { .. } => Some(403),
93            Self::NotFound { .. } => Some(404),
94            Self::RateLimited { .. } => Some(429),
95            Self::ClientError { status, .. } | Self::ServerError { status, .. } => Some(*status),
96            _ => None,
97        }
98    }
99
100    /// True for 401/403 — callers may prompt re-authentication.
101    pub fn is_auth_failure(&self) -> bool {
102        matches!(self, Self::Unauthorized { .. } | Self::Forbidden { .. })
103    }
104
105    /// True when the error body or display text indicates a 404 (URL fallback logic).
106    pub fn is_not_found(&self) -> bool {
107        matches!(self, Self::NotFound { .. })
108            || self.status_code().is_some_and(|s| s == 404)
109            || self.to_string().contains("404 Not Found")
110    }
111}
112
113// ---------------------------------------------------------------------------
114// ConfigError
115// ---------------------------------------------------------------------------
116
117/// Configuration, token file, and keyring failures.
118#[derive(Debug, Error)]
119pub enum ConfigError {
120    #[error(
121        "API_BASE_URL is not set. Set it in the environment, a config.json file, or run: romm-cli init"
122    )]
123    MissingBaseUrl,
124
125    #[error("read bearer token file {path}: {source}")]
126    TokenFileRead {
127        path: String,
128        #[source]
129        source: std::io::Error,
130    },
131
132    #[error("bearer token file exceeds max size of {max} bytes")]
133    TokenFileTooLarge { max: usize },
134
135    #[error("bearer token file must be valid UTF-8: {path}")]
136    TokenFileInvalidUtf8 { path: String },
137
138    #[error("bearer token file is empty after trimming whitespace: {path}")]
139    TokenFileEmpty { path: String },
140
141    #[error("keyring entry error for {key}: {message}")]
142    KeyringEntry { key: String, message: String },
143
144    #[error("keyring store error for {key}: {message}")]
145    KeyringStore { key: String, message: String },
146
147    #[error("Could not resolve config directory. Set ROMM_OPENAPI_PATH to store openapi.json.")]
148    ConfigDirUnavailable,
149
150    #[error("Could not determine config directory (no HOME / APPDATA?).")]
151    ConfigDirNotFound,
152
153    #[error("invalid config path")]
154    InvalidConfigPath,
155
156    #[error("{context}: {source}")]
157    Io {
158        context: String,
159        #[source]
160        source: std::io::Error,
161    },
162
163    #[error("{0}")]
164    Other(String),
165
166    #[error("failed to serialize config: {0}")]
167    Serialize(#[from] serde_json::Error),
168}
169
170// ---------------------------------------------------------------------------
171// DownloadError
172// ---------------------------------------------------------------------------
173
174/// Download, path resolution, and transfer failures.
175#[derive(Debug, Error)]
176pub enum DownloadError {
177    #[error("I/O error: {0}")]
178    Io(#[from] std::io::Error),
179
180    #[error("operation cancelled by user")]
181    Cancelled(#[from] CancelledByUser),
182
183    #[error("ROMs directory is not configured. Run setup to set a ROMs path.")]
184    PathNotConfigured,
185
186    #[error("ROMs directory cannot be empty")]
187    RomsDirEmpty,
188
189    #[error("ROMs directory is not valid: {path}")]
190    InvalidRomsDir { path: String },
191
192    #[error(transparent)]
193    Api(#[from] ApiError),
194
195    #[error(transparent)]
196    Request(#[from] reqwest::Error),
197
198    #[error("download job list lock poisoned: {0}")]
199    JobListPoisoned(String),
200
201    #[error("download failed without error details")]
202    FailedWithoutDetails,
203
204    #[error("Could not move temp ROM {path} to final destination {final_path}: {source}")]
205    RenameFailed {
206        path: String,
207        final_path: String,
208        #[source]
209        source: std::io::Error,
210    },
211
212    #[error("no extras targets selected")]
213    NoExtrasTargets,
214
215    #[error("extras job list lock poisoned: {0}")]
216    ExtrasJobListPoisoned(String),
217
218    #[error("{context}: {source}")]
219    IoContext {
220        context: String,
221        #[source]
222        source: std::io::Error,
223    },
224
225    #[error("{0}")]
226    Unexpected(String),
227}
228
229impl DownloadError {
230    /// True when the underlying API error is a 404 (URL fallback logic).
231    pub fn is_not_found(&self) -> bool {
232        matches!(self, DownloadError::Api(api) if api.is_not_found())
233    }
234}
235
236// ---------------------------------------------------------------------------
237// RommError
238// ---------------------------------------------------------------------------
239
240/// Composed public error type for library consumers.
241#[derive(Debug, Error)]
242pub enum RommError {
243    #[error(transparent)]
244    Api(#[from] ApiError),
245
246    #[error(transparent)]
247    Config(#[from] ConfigError),
248
249    #[error(transparent)]
250    Download(#[from] DownloadError),
251
252    #[error("{0}")]
253    Other(String),
254}
255
256impl RommError {
257    /// True when the user cancelled a long-running operation.
258    pub fn is_cancelled(&self) -> bool {
259        matches!(self, RommError::Download(DownloadError::Cancelled(_)))
260    }
261
262    /// True for auth-related API or config failures.
263    pub fn is_auth_or_config(&self) -> bool {
264        match self {
265            RommError::Config(_) => true,
266            RommError::Api(api) => api.is_auth_failure(),
267            _ => false,
268        }
269    }
270}
271
272// ---------------------------------------------------------------------------
273// Frontend mapping
274// ---------------------------------------------------------------------------
275
276/// Hint for TUI error toast behavior.
277#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum TuiErrorHint {
279    /// Prompt user to run setup / init.
280    RunInit,
281    /// Prompt user to re-authenticate in Settings.
282    ReAuth,
283    /// Transient failure — user may retry.
284    Retry,
285    /// Generic dismissible error.
286    Dismiss,
287}
288
289/// Actionable user-facing message (short, no full error chain).
290pub fn user_message(err: &RommError) -> String {
291    if err.is_cancelled() {
292        return "Operation cancelled.".to_string();
293    }
294    match err {
295        RommError::Config(ConfigError::MissingBaseUrl) => {
296            "API_BASE_URL is not set. Run `romm-cli init` to configure.".to_string()
297        }
298        RommError::Config(_) => {
299            format!("Configuration error: {err}. Check config or run `romm-cli init`.")
300        }
301        RommError::Api(api) if api.is_auth_failure() => {
302            "Authentication failed. Check credentials or run `romm-cli auth`.".to_string()
303        }
304        RommError::Api(ApiError::Request(_)) => {
305            "Network error. Check your connection and server URL.".to_string()
306        }
307        RommError::Api(ApiError::ServerError { .. }) => {
308            "Server error. Try again later.".to_string()
309        }
310        RommError::Download(DownloadError::PathNotConfigured) => {
311            "ROMs directory is not configured. Run `romm-cli init`.".to_string()
312        }
313        RommError::Download(_) => format!("Download failed: {err}"),
314        RommError::Api(_) => format!("API error: {err}"),
315        RommError::Other(msg) => msg.clone(),
316    }
317}
318
319/// Process exit code (Gap 3 partial).
320pub fn exit_code(err: &RommError) -> i32 {
321    if err.is_cancelled() {
322        return 0;
323    }
324    match err {
325        RommError::Config(_) => 3,
326        RommError::Api(api) if api.is_auth_failure() => 3,
327        RommError::Api(ApiError::Request(_)) => 4,
328        RommError::Api(_) => 4,
329        RommError::Download(_) => 4,
330        RommError::Other(_) => 1,
331    }
332}
333
334/// TUI behavior hint for an error.
335pub fn tui_hint(err: &RommError) -> TuiErrorHint {
336    if err.is_cancelled() {
337        return TuiErrorHint::Dismiss;
338    }
339    match err {
340        RommError::Config(ConfigError::MissingBaseUrl) => TuiErrorHint::RunInit,
341        RommError::Config(_) => TuiErrorHint::ReAuth,
342        RommError::Api(api) if api.is_auth_failure() => TuiErrorHint::ReAuth,
343        RommError::Api(ApiError::Request(_))
344        | RommError::Api(ApiError::ServerError { .. })
345        | RommError::Download(_) => TuiErrorHint::Retry,
346        RommError::Other(_) | RommError::Api(_) => TuiErrorHint::Dismiss,
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn from_http_response_maps_status_codes() {
356        let e = ApiError::from_http_response(StatusCode::UNAUTHORIZED, "bad token");
357        assert!(matches!(e, ApiError::Unauthorized { .. }));
358        assert_eq!(e.status_code(), Some(401));
359        assert!(e.is_auth_failure());
360
361        let e = ApiError::from_http_response(StatusCode::INTERNAL_SERVER_ERROR, "oops");
362        assert!(matches!(e, ApiError::ServerError { status: 500, .. }));
363        assert_eq!(e.status_code(), Some(500));
364
365        let e = ApiError::from_http_response(StatusCode::NOT_FOUND, "missing");
366        assert!(matches!(e, ApiError::NotFound { .. }));
367        assert!(e.is_not_found());
368    }
369
370    #[test]
371    fn romm_error_is_cancelled() {
372        let err = RommError::Download(DownloadError::Cancelled(CancelledByUser));
373        assert!(err.is_cancelled());
374        assert_eq!(exit_code(&err), 0);
375    }
376
377    #[test]
378    fn exit_code_auth_vs_network() {
379        let auth = RommError::Api(ApiError::Unauthorized { body: "x".into() });
380        assert_eq!(exit_code(&auth), 3);
381
382        let net = RommError::Api(ApiError::ServerError {
383            status: 503,
384            body: "down".into(),
385        });
386        assert_eq!(exit_code(&net), 4);
387    }
388}