Skip to main content

romm_api/
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`], [`exit_code`], and [`exit`].
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    /// Log-safe summary: status and variant only; HTTP response bodies are omitted.
113    pub fn redacted_for_log(&self) -> String {
114        match self {
115            Self::Unauthorized { .. } => "ApiError: 401 Unauthorized (body redacted)".to_string(),
116            Self::Forbidden { .. } => "ApiError: 403 Forbidden (body redacted)".to_string(),
117            Self::NotFound { path, .. } => {
118                format!("ApiError: 404 Not Found path={path} (body redacted)")
119            }
120            Self::RateLimited { retry_after, .. } => format!(
121                "ApiError: 429 Too Many Requests retry_after={retry_after:?} (body redacted)"
122            ),
123            Self::ClientError { status, .. } => {
124                format!("ApiError: client error {status} (body redacted)")
125            }
126            Self::ServerError { status, .. } => {
127                format!("ApiError: server error {status} (body redacted)")
128            }
129            Self::Request(e) => format!("ApiError: request failed: {e}"),
130            Self::Decode(e) => format!("ApiError: invalid response: {e}"),
131            Self::InvalidMethod(m) => format!("ApiError: invalid HTTP method: {m}"),
132            Self::InvalidHeader(h) => format!("ApiError: invalid HTTP header: {h}"),
133            Self::UnexpectedResponse(m) => format!("ApiError: unexpected API response: {m}"),
134            Self::Io(e) => format!("ApiError: I/O error: {e}"),
135        }
136    }
137}
138
139// ---------------------------------------------------------------------------
140// ConfigError
141// ---------------------------------------------------------------------------
142
143/// Configuration, token file, and keyring failures.
144#[derive(Debug, Error)]
145pub enum ConfigError {
146    #[error(
147        "API_BASE_URL is not set. Set it in the environment, a config.json file, or run: romm-cli init"
148    )]
149    MissingBaseUrl,
150
151    #[error("read bearer token file {path}: {source}")]
152    TokenFileRead {
153        path: String,
154        #[source]
155        source: std::io::Error,
156    },
157
158    #[error("bearer token file exceeds max size of {max} bytes")]
159    TokenFileTooLarge { max: usize },
160
161    #[error("bearer token file must be valid UTF-8: {path}")]
162    TokenFileInvalidUtf8 { path: String },
163
164    #[error("bearer token file is empty after trimming whitespace: {path}")]
165    TokenFileEmpty { path: String },
166
167    #[error("keyring entry error for {key}: {message}")]
168    KeyringEntry { key: String, message: String },
169
170    #[error("keyring store error for {key}: {message}")]
171    KeyringStore { key: String, message: String },
172
173    #[error("Could not resolve config directory. Set ROMM_OPENAPI_PATH to store openapi.json.")]
174    ConfigDirUnavailable,
175
176    #[error("Could not determine config directory (no HOME / APPDATA?).")]
177    ConfigDirNotFound,
178
179    #[error("invalid config path")]
180    InvalidConfigPath,
181
182    #[error("{context}: {source}")]
183    Io {
184        context: String,
185        #[source]
186        source: std::io::Error,
187    },
188
189    #[error("{0}")]
190    Other(String),
191
192    #[error("failed to serialize config: {0}")]
193    Serialize(#[from] serde_json::Error),
194}
195
196// ---------------------------------------------------------------------------
197// DownloadError
198// ---------------------------------------------------------------------------
199
200/// Download, path resolution, and transfer failures.
201#[derive(Debug, Error)]
202pub enum DownloadError {
203    #[error("I/O error: {0}")]
204    Io(#[from] std::io::Error),
205
206    #[error("operation cancelled by user")]
207    Cancelled(#[from] CancelledByUser),
208
209    #[error("ROMs directory is not configured. Run setup to set a ROMs path.")]
210    PathNotConfigured,
211
212    #[error("ROMs directory cannot be empty")]
213    RomsDirEmpty,
214
215    #[error("ROMs directory is not valid: {path}")]
216    InvalidRomsDir { path: String },
217
218    #[error(transparent)]
219    Api(#[from] ApiError),
220
221    #[error(transparent)]
222    Request(#[from] reqwest::Error),
223
224    #[error("download job list lock poisoned: {0}")]
225    JobListPoisoned(String),
226
227    #[error("download failed without error details")]
228    FailedWithoutDetails,
229
230    #[error("Could not move temp ROM {path} to final destination {final_path}: {source}")]
231    RenameFailed {
232        path: String,
233        final_path: String,
234        #[source]
235        source: std::io::Error,
236    },
237
238    #[error("no extras targets selected")]
239    NoExtrasTargets,
240
241    #[error("extras job list lock poisoned: {0}")]
242    ExtrasJobListPoisoned(String),
243
244    #[error("{context}: {source}")]
245    IoContext {
246        context: String,
247        #[source]
248        source: std::io::Error,
249    },
250
251    #[error("{0}")]
252    Unexpected(String),
253}
254
255impl DownloadError {
256    /// True when the underlying API error is a 404 (URL fallback logic).
257    pub fn is_not_found(&self) -> bool {
258        matches!(self, DownloadError::Api(api) if api.is_not_found())
259    }
260}
261
262// ---------------------------------------------------------------------------
263// RommError
264// ---------------------------------------------------------------------------
265
266/// Composed public error type for library consumers.
267#[derive(Debug, Error)]
268pub enum RommError {
269    #[error(transparent)]
270    Api(#[from] ApiError),
271
272    #[error(transparent)]
273    Config(#[from] ConfigError),
274
275    #[error(transparent)]
276    Download(#[from] DownloadError),
277
278    #[error("{0}")]
279    Other(String),
280}
281
282/// Converts a binary-boundary `anyhow::Error` into [`RommError`], preserving typed
283/// domain errors that were bubbled via `?` from legacy command handlers.
284pub fn from_anyhow(err: anyhow::Error) -> RommError {
285    match err.downcast::<ApiError>() {
286        Ok(api) => RommError::Api(api),
287        Err(err) => match err.downcast::<ConfigError>() {
288            Ok(cfg) => RommError::Config(cfg),
289            Err(err) => match err.downcast::<DownloadError>() {
290                Ok(dl) => RommError::Download(dl),
291                Err(err) => match err.downcast::<RommError>() {
292                    Ok(re) => re,
293                    Err(err) => RommError::Other(err.to_string()),
294                },
295            },
296        },
297    }
298}
299
300impl RommError {
301    /// True when the user cancelled a long-running operation.
302    pub fn is_cancelled(&self) -> bool {
303        matches!(self, RommError::Download(DownloadError::Cancelled(_)))
304    }
305
306    /// True for auth-related API or config failures.
307    pub fn is_auth_or_config(&self) -> bool {
308        match self {
309            RommError::Config(_) => true,
310            RommError::Api(api) => api.is_auth_failure(),
311            _ => false,
312        }
313    }
314}
315
316// ---------------------------------------------------------------------------
317// Frontend mapping
318// ---------------------------------------------------------------------------
319
320/// Hint for TUI error toast behavior.
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322pub enum TuiErrorHint {
323    /// Prompt user to run setup / init.
324    RunInit,
325    /// Prompt user to re-authenticate in Settings.
326    ReAuth,
327    /// Transient failure — user may retry.
328    Retry,
329    /// Generic dismissible error.
330    Dismiss,
331}
332
333/// Actionable user-facing message (short, no full error chain).
334pub fn user_message(err: &RommError) -> String {
335    if err.is_cancelled() {
336        return "Operation cancelled.".to_string();
337    }
338    match err {
339        RommError::Config(ConfigError::MissingBaseUrl) => {
340            "API_BASE_URL is not set. Run `romm-cli init` to configure.".to_string()
341        }
342        RommError::Config(_) => {
343            format!("Configuration error: {err}. Check config or run `romm-cli init`.")
344        }
345        RommError::Api(api) if api.is_auth_failure() => {
346            "Authentication failed. Check credentials or run `romm-cli auth`.".to_string()
347        }
348        RommError::Api(ApiError::Forbidden { .. }) => {
349            "Access denied. Check credentials or run `romm-cli auth`.".to_string()
350        }
351        RommError::Api(ApiError::NotFound { .. }) => {
352            "Resource not found. Check the server URL and resource ID.".to_string()
353        }
354        RommError::Api(ApiError::RateLimited { .. }) => {
355            "Rate limited by the server. Wait a moment and try again.".to_string()
356        }
357        RommError::Api(ApiError::ClientError { status, .. }) if (400..500).contains(status) => {
358            format!("Request rejected ({status}). Check command arguments and try again.")
359        }
360        RommError::Api(ApiError::Request(_)) => {
361            "Network error. Check your connection and server URL.".to_string()
362        }
363        RommError::Api(ApiError::ServerError { .. }) => {
364            "Server error. Try again later.".to_string()
365        }
366        RommError::Download(DownloadError::PathNotConfigured) => {
367            "ROMs directory is not configured. Run `romm-cli init`.".to_string()
368        }
369        RommError::Download(DownloadError::IoContext { .. }) => {
370            format!("Download I/O error: {err}. Check disk permissions and output path.")
371        }
372        RommError::Download(_) => format!("Download failed: {err}"),
373        RommError::Api(_) => format!("API error: {err}"),
374        RommError::Other(msg) => msg.clone(),
375    }
376}
377
378/// Process exit codes for scripting (see README "Exit codes").
379pub mod exit {
380    /// Command completed successfully (including user-cancelled downloads).
381    pub const SUCCESS: i32 = 0;
382    /// Unexpected or app-level validation failure.
383    pub const GENERAL: i32 = 1;
384    /// Invalid flags or arguments (clap parse errors only).
385    pub const USAGE: i32 = 2;
386    /// Configuration or authentication failure.
387    pub const CONFIG: i32 = 3;
388    /// API, network, or download failure.
389    pub const API: i32 = 4;
390}
391
392/// Maps a [`RommError`] to a process exit code for the `romm-cli` binary.
393pub fn exit_code(err: &RommError) -> i32 {
394    if err.is_cancelled() {
395        return exit::SUCCESS;
396    }
397    match err {
398        RommError::Config(_) => exit::CONFIG,
399        RommError::Api(api) if api.is_auth_failure() => exit::CONFIG,
400        RommError::Api(ApiError::Request(_)) => exit::API,
401        RommError::Api(_) => exit::API,
402        RommError::Download(_) => exit::API,
403        RommError::Other(_) => exit::GENERAL,
404    }
405}
406
407/// TUI behavior hint for an error.
408pub fn tui_hint(err: &RommError) -> TuiErrorHint {
409    if err.is_cancelled() {
410        return TuiErrorHint::Dismiss;
411    }
412    match err {
413        RommError::Config(ConfigError::MissingBaseUrl) => TuiErrorHint::RunInit,
414        RommError::Config(_) => TuiErrorHint::ReAuth,
415        RommError::Api(api) if api.is_auth_failure() => TuiErrorHint::ReAuth,
416        RommError::Api(ApiError::Request(_))
417        | RommError::Api(ApiError::ServerError { .. })
418        | RommError::Download(_) => TuiErrorHint::Retry,
419        RommError::Other(_) | RommError::Api(_) => TuiErrorHint::Dismiss,
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn from_http_response_maps_status_codes() {
429        let e = ApiError::from_http_response(StatusCode::UNAUTHORIZED, "bad token");
430        assert!(matches!(e, ApiError::Unauthorized { .. }));
431        assert_eq!(e.status_code(), Some(401));
432        assert!(e.is_auth_failure());
433
434        let e = ApiError::from_http_response(StatusCode::INTERNAL_SERVER_ERROR, "oops");
435        assert!(matches!(e, ApiError::ServerError { status: 500, .. }));
436        assert_eq!(e.status_code(), Some(500));
437
438        let e = ApiError::from_http_response(StatusCode::NOT_FOUND, "missing");
439        assert!(matches!(e, ApiError::NotFound { .. }));
440        assert!(e.is_not_found());
441    }
442
443    #[test]
444    fn romm_error_is_cancelled() {
445        let err = RommError::Download(DownloadError::Cancelled(CancelledByUser));
446        assert!(err.is_cancelled());
447        assert_eq!(exit_code(&err), 0);
448    }
449
450    #[test]
451    fn exit_code_auth_vs_network() {
452        let auth = RommError::Api(ApiError::Unauthorized { body: "x".into() });
453        assert_eq!(exit_code(&auth), exit::CONFIG);
454
455        let net = RommError::Api(ApiError::ServerError {
456            status: 503,
457            body: "down".into(),
458        });
459        assert_eq!(exit_code(&net), exit::API);
460    }
461
462    #[test]
463    fn exit_code_maps_all_variants() {
464        assert_eq!(
465            exit_code(&RommError::Config(ConfigError::MissingBaseUrl)),
466            exit::CONFIG
467        );
468
469        let forbidden = RommError::Api(ApiError::Forbidden {
470            body: "denied".into(),
471        });
472        assert_eq!(exit_code(&forbidden), exit::CONFIG);
473
474        // `Request` branch is covered by CLI integration tests; `ClientError` shares the API arm.
475        let api = RommError::Api(ApiError::ClientError {
476            status: 502,
477            body: "bad gateway".into(),
478        });
479        assert_eq!(exit_code(&api), exit::API);
480
481        assert_eq!(
482            exit_code(&RommError::Download(DownloadError::PathNotConfigured)),
483            exit::API
484        );
485
486        assert_eq!(exit_code(&RommError::Other("x".into())), exit::GENERAL);
487    }
488
489    #[test]
490    fn user_message_actionable_hints() {
491        let not_found = RommError::Api(ApiError::NotFound {
492            path: "/api/x".into(),
493            body: "missing".into(),
494        });
495        assert!(user_message(&not_found).contains("server URL"));
496
497        let forbidden = RommError::Api(ApiError::Forbidden {
498            body: "denied".into(),
499        });
500        assert!(user_message(&forbidden).contains("romm-cli auth"));
501
502        let rate = RommError::Api(ApiError::RateLimited {
503            retry_after: Some(30),
504            body: "slow down".into(),
505        });
506        assert!(user_message(&rate).contains("Rate limited"));
507    }
508}