Skip to main content

raps_kernel/
error.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Error handling and exit code management
5//!
6//! Provides standardized exit codes for CI/CD scripting:
7//! - 0: Success
8//! - 2: Invalid arguments / validation failure
9//! - 3: Auth failure
10//! - 4: Not found
11//! - 5: Remote/API error
12//! - 6: Internal error
13//!
14//! Also provides APS error interpretation with human-readable explanations.
15
16use anyhow::Error;
17use colored::Colorize;
18use serde::Deserialize;
19use std::process;
20
21/// Exit codes following standard conventions
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23#[allow(dead_code)] // Success is used implicitly when no error occurs
24pub enum ExitCode {
25    /// Success
26    Success = 0,
27    /// Invalid arguments / validation failure
28    InvalidArguments = 2,
29    /// Authentication failure
30    AuthFailure = 3,
31    /// Resource not found
32    NotFound = 4,
33    /// Remote/API error
34    RemoteError = 5,
35    /// Internal error
36    InternalError = 6,
37}
38
39impl ExitCode {
40    /// Determine exit code from an error
41    ///
42    /// Analyzes the error chain to determine the appropriate exit code
43    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        // Check for authentication errors
48        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        // Check for not found errors
63        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        // Check for remote/API errors (5xx, network errors, bulk partial failures)
71        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        // Check for validation/argument errors (more specific patterns to avoid false positives)
90        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        // Default to internal error for unknown errors
107        ExitCode::InternalError
108    }
109
110    /// Exit the process with this exit code
111    pub fn exit(self) -> ! {
112        process::exit(self as i32);
113    }
114}
115
116/// Extension trait for Result to easily exit with appropriate code
117#[allow(dead_code)] // Trait may be used in future
118pub trait ResultExt<T> {
119    /// Unwrap or exit with appropriate exit code
120    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                // Print chain of errors
132                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// ============== APS ERROR INTERPRETATION ==============
145
146/// Common APS API error response structure
147#[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/// Parsed and interpreted APS error
162#[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/// Parse and interpret an APS API error response
173#[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    // Authentication errors
225    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    // Scope/permission errors
240    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    // Not found errors
263    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    // Rate limiting
275    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    // Server errors
286    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    // Default
297    (
298        format!("Request failed (HTTP {})", status_code),
299        vec!["Check the error details".to_string()],
300    )
301}
302
303/// Format an interpreted error for display
304#[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"); // Custom code
460    }
461
462    // ==================== Additional Exit Code Tests ====================
463
464    #[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    // ==================== Exit Code Value Tests ====================
538
539    #[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    // ==================== Additional Interpret Error Tests ====================
550
551    #[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    // ==================== Format Error Tests ====================
615
616    #[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        // Empty message shouldn't add extra "Details:" line
628        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        // Should contain the content (colors are ANSI codes)
642        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        // Should not have "Suggestions:" section
658        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        // Note: Current implementation shows both, which is acceptable behavior
672        // The test verifies the format function works with matching messages
673        assert!(formatted.contains("Same message"));
674    }
675}