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
113#[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#[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 pub fn is_not_found(&self) -> bool {
232 matches!(self, DownloadError::Api(api) if api.is_not_found())
233 }
234}
235
236#[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 pub fn is_cancelled(&self) -> bool {
259 matches!(self, RommError::Download(DownloadError::Cancelled(_)))
260 }
261
262 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum TuiErrorHint {
279 RunInit,
281 ReAuth,
283 Retry,
285 Dismiss,
287}
288
289pub 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
319pub 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
334pub 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}