1use reqwest::StatusCode;
7use thiserror::Error;
8
9use crate::core::interrupt::CancelledByUser;
10
11#[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 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 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 pub fn is_auth_failure(&self) -> bool {
102 matches!(self, Self::Unauthorized { .. } | Self::Forbidden { .. })
103 }
104
105 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 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#[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#[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 pub fn is_not_found(&self) -> bool {
258 matches!(self, DownloadError::Api(api) if api.is_not_found())
259 }
260}
261
262#[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
282pub 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 pub fn is_cancelled(&self) -> bool {
303 matches!(self, RommError::Download(DownloadError::Cancelled(_)))
304 }
305
306 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322pub enum TuiErrorHint {
323 RunInit,
325 ReAuth,
327 Retry,
329 Dismiss,
331}
332
333pub 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
378pub mod exit {
380 pub const SUCCESS: i32 = 0;
382 pub const GENERAL: i32 = 1;
384 pub const USAGE: i32 = 2;
386 pub const CONFIG: i32 = 3;
388 pub const API: i32 = 4;
390}
391
392pub 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
407pub 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 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(¬_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}