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