1use anyhow::Error;
17use colored::Colorize;
18use serde::Deserialize;
19use std::process;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23#[allow(dead_code)] pub enum ExitCode {
25 Success = 0,
27 InvalidArguments = 2,
29 AuthFailure = 3,
31 NotFound = 4,
33 RemoteError = 5,
35 InternalError = 6,
37}
38
39impl ExitCode {
40 pub fn from_error(err: &Error) -> Self {
44 let error_string = err.to_string().to_lowercase();
45 let error_chain: Vec<String> = err.chain().map(|e| e.to_string().to_lowercase()).collect();
46
47 if error_string.contains("authentication failed")
49 || error_string.contains("auth failed")
50 || error_string.contains("unauthorized")
51 || error_string.contains("forbidden")
52 || error_string.contains("invalid credentials")
53 || error_string.contains("token expired")
54 || error_string.contains("token invalid")
55 || error_chain
56 .iter()
57 .any(|e| e.contains("401") || e.contains("403") || e.contains("authentication"))
58 {
59 return ExitCode::AuthFailure;
60 }
61
62 if error_string.contains("not found")
64 || error_string.contains("404")
65 || error_chain.iter().any(|e| e.contains("404"))
66 {
67 return ExitCode::NotFound;
68 }
69
70 if error_string.contains("partially failed")
72 || error_string.contains("api error")
73 || error_string.contains("remote error")
74 || error_string.contains("server error")
75 || error_string.contains("timeout")
76 || error_string.contains("connection refused")
77 || error_string.contains("connection reset")
78 || error_chain.iter().any(|e| {
79 e.contains("500")
80 || e.contains("502")
81 || e.contains("503")
82 || e.contains("504")
83 || e.contains("timeout")
84 })
85 {
86 return ExitCode::RemoteError;
87 }
88
89 if error_string.contains("invalid argument")
91 || error_string.contains("invalid option")
92 || error_string.contains("invalid value")
93 || error_string.contains("invalid format")
94 || error_string.contains("validation failed")
95 || error_string.contains("validation error")
96 || error_string.contains("cannot be empty")
97 || error_string.contains("must be")
98 || error_string.contains("missing required")
99 || error_string.contains("is required")
100 || error_string.contains("required field")
101 || error_string.contains("required parameter")
102 {
103 return ExitCode::InvalidArguments;
104 }
105
106 ExitCode::InternalError
108 }
109
110 pub fn exit(self) -> ! {
112 process::exit(self as i32);
113 }
114}
115
116#[allow(dead_code)] pub trait ResultExt<T> {
119 fn unwrap_or_exit(self) -> T;
121}
122
123impl<T> ResultExt<T> for Result<T, Error> {
124 fn unwrap_or_exit(self) -> T {
125 match self {
126 Ok(val) => val,
127 Err(err) => {
128 let exit_code = ExitCode::from_error(&err);
129 eprintln!("Error: {err}");
130
131 let mut source = err.source();
133 while let Some(cause) = source {
134 eprintln!(" Caused by: {}", cause);
135 source = cause.source();
136 }
137
138 exit_code.exit();
139 }
140 }
141 }
142}
143
144#[derive(Debug, Deserialize)]
148#[serde(rename_all = "camelCase")]
149#[allow(dead_code)]
150pub struct ApsErrorResponse {
151 #[serde(alias = "error", alias = "errorCode")]
152 pub error_code: Option<String>,
153 #[serde(alias = "error_description", alias = "errorDescription")]
154 pub description: Option<String>,
155 #[serde(alias = "message", alias = "msg")]
156 pub detail: Option<String>,
157 pub reason: Option<String>,
158 pub developer_message: Option<String>,
159}
160
161#[derive(Debug)]
163#[allow(dead_code)]
164pub struct InterpretedError {
165 pub status_code: u16,
166 pub error_code: String,
167 pub explanation: String,
168 pub suggestions: Vec<String>,
169 pub original_message: String,
170}
171
172#[allow(dead_code)]
174pub fn interpret_error(status_code: u16, response_body: &str) -> InterpretedError {
175 let parsed: Option<ApsErrorResponse> = serde_json::from_str(response_body).ok();
176
177 let (error_code, message) = if let Some(ref err) = parsed {
178 let code = err
179 .error_code
180 .clone()
181 .or(err.reason.clone())
182 .unwrap_or_else(|| status_to_code(status_code));
183 let msg = err
184 .detail
185 .clone()
186 .or(err.description.clone())
187 .or(err.developer_message.clone())
188 .unwrap_or_else(|| response_body.to_string());
189 (code, msg)
190 } else {
191 (status_to_code(status_code), response_body.to_string())
192 };
193
194 let (explanation, suggestions) = get_error_help(status_code, &error_code, &message);
195
196 InterpretedError {
197 status_code,
198 error_code,
199 explanation,
200 suggestions,
201 original_message: message,
202 }
203}
204
205fn status_to_code(status: u16) -> String {
206 match status {
207 400 => "BadRequest".to_string(),
208 401 => "Unauthorized".to_string(),
209 403 => "Forbidden".to_string(),
210 404 => "NotFound".to_string(),
211 409 => "Conflict".to_string(),
212 429 => "TooManyRequests".to_string(),
213 500 => "InternalServerError".to_string(),
214 502 => "BadGateway".to_string(),
215 503 => "ServiceUnavailable".to_string(),
216 _ => format!("Error{}", status),
217 }
218}
219
220fn get_error_help(status_code: u16, error_code: &str, message: &str) -> (String, Vec<String>) {
221 let message_lower = message.to_lowercase();
222 let code_lower = error_code.to_lowercase();
223
224 if status_code == 401
226 || code_lower.contains("unauthorized")
227 || code_lower.contains("invalid_token")
228 {
229 return (
230 "Authentication failed. Your token is invalid, expired, or missing.".to_string(),
231 vec![
232 "Run 'raps auth login' to re-authenticate".to_string(),
233 "Check that your client credentials are correct".to_string(),
234 "Verify RAPS_CLIENT_ID and RAPS_CLIENT_SECRET environment variables".to_string(),
235 ],
236 );
237 }
238
239 if status_code == 403
241 || code_lower.contains("forbidden")
242 || code_lower.contains("insufficient_scope")
243 {
244 let mut suggestions = vec![
245 "Check that your app has the required scopes enabled in APS Portal".to_string(),
246 "Run 'raps auth login' with the necessary scopes".to_string(),
247 ];
248
249 if message_lower.contains("data:read") || message_lower.contains("data:write") {
250 suggestions.push("Add 'data:read'/'data:write' scopes for Data Management".to_string());
251 }
252 if message_lower.contains("bucket") {
253 suggestions.push("Add 'bucket:read'/'bucket:create' scopes for OSS".to_string());
254 }
255
256 return (
257 "Permission denied. Your token lacks required scopes.".to_string(),
258 suggestions,
259 );
260 }
261
262 if status_code == 404 {
264 return (
265 "Resource not found.".to_string(),
266 vec![
267 "Verify the resource ID is correct".to_string(),
268 "Check that the resource exists".to_string(),
269 "Ensure you have access to the resource".to_string(),
270 ],
271 );
272 }
273
274 if status_code == 429 {
276 return (
277 "Rate limit exceeded.".to_string(),
278 vec![
279 "Wait and retry the request".to_string(),
280 "Reduce request frequency".to_string(),
281 ],
282 );
283 }
284
285 if status_code >= 500 {
287 return (
288 "APS server error (temporary).".to_string(),
289 vec![
290 "Wait and retry".to_string(),
291 "Check APS status page".to_string(),
292 ],
293 );
294 }
295
296 (
298 format!("Request failed (HTTP {})", status_code),
299 vec!["Check the error details".to_string()],
300 )
301}
302
303#[allow(dead_code)]
305pub fn format_interpreted_error(error: &InterpretedError, use_colors: bool) -> String {
306 let mut output = String::new();
307
308 if use_colors {
309 output.push_str(&format!(
310 "\n{} {}\n",
311 "Error:".red().bold(),
312 error.explanation
313 ));
314 output.push_str(&format!(
315 " {} {} (HTTP {})\n",
316 "Code:".bold(),
317 error.error_code,
318 error.status_code
319 ));
320
321 if !error.original_message.is_empty() && error.original_message != error.explanation {
322 output.push_str(&format!(
323 " {} {}\n",
324 "Details:".bold(),
325 error.original_message.dimmed()
326 ));
327 }
328
329 if !error.suggestions.is_empty() {
330 output.push_str(&format!("\n{}\n", "Suggestions:".yellow().bold()));
331 for suggestion in &error.suggestions {
332 output.push_str(&format!(" {} {}\n", "→".cyan(), suggestion));
333 }
334 }
335 } else {
336 output.push_str(&format!("\nError: {}\n", error.explanation));
337 output.push_str(&format!(
338 " Code: {} (HTTP {})\n",
339 error.error_code, error.status_code
340 ));
341
342 if !error.original_message.is_empty() {
343 output.push_str(&format!(" Details: {}\n", error.original_message));
344 }
345
346 if !error.suggestions.is_empty() {
347 output.push_str("\nSuggestions:\n");
348 for suggestion in &error.suggestions {
349 output.push_str(&format!(" - {}\n", suggestion));
350 }
351 }
352 }
353
354 output
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn test_exit_code_from_auth_error() {
363 let err = anyhow::anyhow!("authentication failed: unauthorized");
364 assert_eq!(ExitCode::from_error(&err), ExitCode::AuthFailure);
365 }
366
367 #[test]
368 fn test_exit_code_from_not_found_error() {
369 let err = anyhow::anyhow!("Resource not found");
370 assert_eq!(ExitCode::from_error(&err), ExitCode::NotFound);
371 }
372
373 #[test]
374 fn test_exit_code_from_validation_error() {
375 let err = anyhow::anyhow!("Invalid bucket name: must be lowercase");
376 assert_eq!(ExitCode::from_error(&err), ExitCode::InvalidArguments);
377 }
378
379 #[test]
380 fn test_exit_code_from_remote_error() {
381 let err = anyhow::anyhow!("API error: 500 Internal Server Error");
382 assert_eq!(ExitCode::from_error(&err), ExitCode::RemoteError);
383 }
384
385 #[test]
386 fn test_interpret_401_error() {
387 let error = interpret_error(
388 401,
389 r#"{"error": "invalid_token", "error_description": "Token expired"}"#,
390 );
391 assert_eq!(error.status_code, 401);
392 assert!(error.explanation.contains("Authentication"));
393 assert!(!error.suggestions.is_empty());
394 }
395
396 #[test]
397 fn test_interpret_403_error() {
398 let error = interpret_error(
399 403,
400 r#"{"error": "insufficient_scope", "detail": "Missing data:read scope"}"#,
401 );
402 assert_eq!(error.status_code, 403);
403 assert!(error.explanation.contains("Permission"));
404 }
405
406 #[test]
407 fn test_interpret_404_error() {
408 let error = interpret_error(404, r#"{"message": "Bucket not found"}"#);
409 assert_eq!(error.status_code, 404);
410 assert!(error.explanation.contains("not found"));
411 }
412
413 #[test]
414 fn test_interpret_429_error() {
415 let error = interpret_error(429, "Rate limit exceeded");
416 assert_eq!(error.status_code, 429);
417 assert!(error.explanation.contains("Rate limit"));
418 }
419
420 #[test]
421 fn test_interpret_500_error() {
422 let error = interpret_error(500, "Internal server error");
423 assert_eq!(error.status_code, 500);
424 assert!(error.explanation.contains("server error"));
425 }
426
427 #[test]
428 fn test_interpret_plain_text_error() {
429 let error = interpret_error(400, "Bad request: invalid parameter");
430 assert_eq!(error.status_code, 400);
431 assert_eq!(error.error_code, "BadRequest");
432 }
433
434 #[test]
435 fn test_format_interpreted_error_no_colors() {
436 let error = InterpretedError {
437 status_code: 401,
438 error_code: "Unauthorized".to_string(),
439 explanation: "Authentication failed".to_string(),
440 suggestions: vec!["Run 'raps auth login'".to_string()],
441 original_message: "Token expired".to_string(),
442 };
443
444 let formatted = format_interpreted_error(&error, false);
445 assert!(formatted.contains("Authentication failed"));
446 assert!(formatted.contains("Unauthorized"));
447 assert!(formatted.contains("401"));
448 assert!(formatted.contains("raps auth login"));
449 }
450
451 #[test]
452 fn test_status_to_code() {
453 assert_eq!(status_to_code(400), "BadRequest");
454 assert_eq!(status_to_code(401), "Unauthorized");
455 assert_eq!(status_to_code(403), "Forbidden");
456 assert_eq!(status_to_code(404), "NotFound");
457 assert_eq!(status_to_code(429), "TooManyRequests");
458 assert_eq!(status_to_code(500), "InternalServerError");
459 assert_eq!(status_to_code(418), "Error418"); }
461
462 #[test]
465 fn test_exit_code_from_forbidden_error() {
466 let err = anyhow::anyhow!("403 Forbidden: insufficient permissions");
467 assert_eq!(ExitCode::from_error(&err), ExitCode::AuthFailure);
468 }
469
470 #[test]
471 fn test_exit_code_from_token_expired() {
472 let err = anyhow::anyhow!("token expired");
473 assert_eq!(ExitCode::from_error(&err), ExitCode::AuthFailure);
474 }
475
476 #[test]
477 fn test_exit_code_from_token_invalid() {
478 let err = anyhow::anyhow!("token invalid");
479 assert_eq!(ExitCode::from_error(&err), ExitCode::AuthFailure);
480 }
481
482 #[test]
483 fn test_exit_code_from_invalid_credentials() {
484 let err = anyhow::anyhow!("invalid credentials");
485 assert_eq!(ExitCode::from_error(&err), ExitCode::AuthFailure);
486 }
487
488 #[test]
489 fn test_exit_code_from_404_in_chain() {
490 let inner = anyhow::anyhow!("status: 404");
491 let err = inner.context("Failed to fetch resource");
492 assert_eq!(ExitCode::from_error(&err), ExitCode::NotFound);
493 }
494
495 #[test]
496 fn test_exit_code_from_missing_required() {
497 let err = anyhow::anyhow!("bucket name is required");
498 assert_eq!(ExitCode::from_error(&err), ExitCode::InvalidArguments);
499 }
500
501 #[test]
502 fn test_exit_code_from_cannot_be_empty() {
503 let err = anyhow::anyhow!("field cannot be empty");
504 assert_eq!(ExitCode::from_error(&err), ExitCode::InvalidArguments);
505 }
506
507 #[test]
508 fn test_exit_code_from_must_be() {
509 let err = anyhow::anyhow!("value must be positive");
510 assert_eq!(ExitCode::from_error(&err), ExitCode::InvalidArguments);
511 }
512
513 #[test]
514 fn test_exit_code_from_timeout() {
515 let err = anyhow::anyhow!("request timeout after 30s");
516 assert_eq!(ExitCode::from_error(&err), ExitCode::RemoteError);
517 }
518
519 #[test]
520 fn test_exit_code_from_network() {
521 let err = anyhow::anyhow!("network error: connection reset");
522 assert_eq!(ExitCode::from_error(&err), ExitCode::RemoteError);
523 }
524
525 #[test]
526 fn test_exit_code_from_connection() {
527 let err = anyhow::anyhow!("connection refused");
528 assert_eq!(ExitCode::from_error(&err), ExitCode::RemoteError);
529 }
530
531 #[test]
532 fn test_exit_code_unknown_defaults_to_internal() {
533 let err = anyhow::anyhow!("something went wrong");
534 assert_eq!(ExitCode::from_error(&err), ExitCode::InternalError);
535 }
536
537 #[test]
540 fn test_exit_code_values() {
541 assert_eq!(ExitCode::Success as i32, 0);
542 assert_eq!(ExitCode::InvalidArguments as i32, 2);
543 assert_eq!(ExitCode::AuthFailure as i32, 3);
544 assert_eq!(ExitCode::NotFound as i32, 4);
545 assert_eq!(ExitCode::RemoteError as i32, 5);
546 assert_eq!(ExitCode::InternalError as i32, 6);
547 }
548
549 #[test]
552 fn test_interpret_502_error() {
553 let error = interpret_error(502, "Bad Gateway");
554 assert_eq!(error.status_code, 502);
555 assert!(error.explanation.contains("server error"));
556 }
557
558 #[test]
559 fn test_interpret_503_error() {
560 let error = interpret_error(503, "Service Unavailable");
561 assert_eq!(error.status_code, 503);
562 assert!(error.explanation.contains("server error"));
563 }
564
565 #[test]
566 fn test_interpret_error_with_scope_suggestion() {
567 let error = interpret_error(
568 403,
569 r#"{"error": "forbidden", "detail": "Missing data:read scope"}"#,
570 );
571 assert!(error.suggestions.iter().any(|s| s.contains("data:read")));
572 }
573
574 #[test]
575 fn test_interpret_error_with_bucket_suggestion() {
576 let error = interpret_error(
577 403,
578 r#"{"error": "forbidden", "detail": "Missing bucket:create scope"}"#,
579 );
580 assert!(error.suggestions.iter().any(|s| s.contains("bucket")));
581 }
582
583 #[test]
584 fn test_interpret_error_json_parsing() {
585 let error = interpret_error(
586 400,
587 r#"{"errorCode": "InvalidRequest", "message": "Bad parameter"}"#,
588 );
589 assert_eq!(error.error_code, "InvalidRequest");
590 assert!(error.original_message.contains("Bad parameter"));
591 }
592
593 #[test]
594 fn test_interpret_error_developer_message() {
595 let error = interpret_error(
596 400,
597 r#"{"error": "BadRequest", "developer_message": "Check API docs"}"#,
598 );
599 assert!(error.original_message.contains("Check API docs"));
600 }
601
602 #[test]
603 fn test_interpret_error_reason_field() {
604 let error = interpret_error(400, r#"{"reason": "InvalidParameter"}"#);
605 assert_eq!(error.error_code, "InvalidParameter");
606 }
607
608 #[test]
609 fn test_interpret_409_conflict() {
610 let _error = interpret_error(409, r#"{"error": "Conflict"}"#);
611 assert_eq!(status_to_code(409), "Conflict");
612 }
613
614 #[test]
617 fn test_format_error_with_empty_message() {
618 let error = InterpretedError {
619 status_code: 400,
620 error_code: "BadRequest".to_string(),
621 explanation: "Bad request".to_string(),
622 suggestions: vec![],
623 original_message: "".to_string(),
624 };
625 let formatted = format_interpreted_error(&error, false);
626 assert!(formatted.contains("Bad request"));
627 assert!(!formatted.contains("Details:") || formatted.contains("Details: \n"));
629 }
630
631 #[test]
632 fn test_format_error_with_colors() {
633 let error = InterpretedError {
634 status_code: 401,
635 error_code: "Unauthorized".to_string(),
636 explanation: "Auth failed".to_string(),
637 suggestions: vec!["Login again".to_string()],
638 original_message: "Token expired".to_string(),
639 };
640 let formatted = format_interpreted_error(&error, true);
641 assert!(formatted.contains("Auth failed"));
643 assert!(formatted.contains("Token expired"));
644 assert!(formatted.contains("Login again"));
645 }
646
647 #[test]
648 fn test_format_error_no_suggestions() {
649 let error = InterpretedError {
650 status_code: 400,
651 error_code: "BadRequest".to_string(),
652 explanation: "Bad request".to_string(),
653 suggestions: vec![],
654 original_message: "Invalid input".to_string(),
655 };
656 let formatted = format_interpreted_error(&error, false);
657 assert!(!formatted.contains("Suggestions:"));
659 }
660
661 #[test]
662 fn test_format_error_same_explanation_and_message() {
663 let error = InterpretedError {
664 status_code: 400,
665 error_code: "BadRequest".to_string(),
666 explanation: "Same message".to_string(),
667 suggestions: vec![],
668 original_message: "Same message".to_string(),
669 };
670 let formatted = format_interpreted_error(&error, false);
671 assert!(formatted.contains("Same message"));
674 }
675}