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 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 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#[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#[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 pub fn is_not_found(&self) -> bool {
271 matches!(self, DownloadError::Api(api) if api.is_not_found())
272 }
273}
274
275#[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
295pub 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 pub fn is_cancelled(&self) -> bool {
316 matches!(self, RommError::Download(DownloadError::Cancelled(_)))
317 }
318
319 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
335pub enum TuiErrorHint {
336 RunInit,
338 ReAuth,
340 Retry,
342 Dismiss,
344}
345
346pub 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
391pub 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
408pub mod exit {
410 pub const SUCCESS: i32 = 0;
412 pub const GENERAL: i32 = 1;
414 pub const USAGE: i32 = 2;
416 pub const CONFIG: i32 = 3;
418 pub const API: i32 = 4;
420}
421
422pub 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
437pub 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 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(¬_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}