Skip to main content

rskit_cli/render/
error.rs

1//! Machine-readable output format, exit codes, and error rendering.
2
3use rskit_errors::{AppError, ErrorCode};
4use serde::Serialize;
5
6/// Machine-readable output format for CLI renderers.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8#[non_exhaustive]
9pub enum OutputFormat {
10    /// Human-readable terminal text.
11    #[default]
12    Text,
13    /// JSON object or array.
14    Json,
15    /// YAML document.
16    Yaml,
17}
18
19/// Process exit code convention shared by rskit CLIs.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21#[repr(i32)]
22#[non_exhaustive]
23pub enum ExitCode {
24    /// Successful command.
25    Success = 0,
26    /// Invalid command input or configuration.
27    Usage = 2,
28    /// Authentication or authorization failure.
29    Permission = 3,
30    /// Requested resource was not found.
31    NotFound = 4,
32    /// Conflict with current state.
33    Conflict = 5,
34    /// Remote dependency or service failure.
35    Unavailable = 69,
36    /// Command was rate limited.
37    RateLimited = 75,
38    /// Command timed out.
39    Timeout = 124,
40    /// Command was cancelled.
41    Cancelled = 130,
42    /// Unclassified failure.
43    Failure = 1,
44}
45
46impl ExitCode {
47    /// Return this exit code as an integer suitable for `std::process::exit`.
48    #[must_use]
49    pub const fn as_i32(self) -> i32 {
50        self as i32
51    }
52}
53
54impl From<ErrorCode> for ExitCode {
55    fn from(code: ErrorCode) -> Self {
56        match code {
57            ErrorCode::InvalidInput | ErrorCode::InvalidFormat | ErrorCode::MissingField => {
58                Self::Usage
59            }
60            ErrorCode::Unauthorized
61            | ErrorCode::Forbidden
62            | ErrorCode::TokenExpired
63            | ErrorCode::InvalidToken => Self::Permission,
64            ErrorCode::NotFound => Self::NotFound,
65            ErrorCode::Conflict | ErrorCode::AlreadyExists => Self::Conflict,
66            ErrorCode::ServiceUnavailable
67            | ErrorCode::ConnectionFailed
68            | ErrorCode::ExternalService => Self::Unavailable,
69            ErrorCode::RateLimited => Self::RateLimited,
70            ErrorCode::Timeout => Self::Timeout,
71            ErrorCode::Cancelled => Self::Cancelled,
72            _ => Self::Failure,
73        }
74    }
75}
76
77/// Renders [`AppError`] values consistently for command-line applications.
78pub struct ErrorRenderer {
79    format: OutputFormat,
80}
81
82impl ErrorRenderer {
83    /// Create a renderer for the requested output format.
84    #[must_use]
85    pub const fn new(format: OutputFormat) -> Self {
86        Self { format }
87    }
88
89    /// Render an error and return the matching CLI exit code.
90    #[must_use]
91    pub fn render(&self, error: &AppError) -> (String, ExitCode) {
92        let exit_code = ExitCode::from(error.code());
93        let rendered = match self.format {
94            OutputFormat::Text => format!("error[{}]: {}", error.code(), error.message()),
95            OutputFormat::Json => serde_json::to_string(&ErrorEnvelope::new(error, exit_code))
96                .unwrap_or_else(|_| fallback_json(error, exit_code)),
97            OutputFormat::Yaml => serde_norway::to_string(&ErrorEnvelope::new(error, exit_code))
98                .unwrap_or_else(|_| fallback_yaml(error, exit_code)),
99        };
100        (rendered, exit_code)
101    }
102}
103
104impl Default for ErrorRenderer {
105    fn default() -> Self {
106        Self::new(OutputFormat::Text)
107    }
108}
109
110#[derive(Serialize)]
111struct ErrorEnvelope<'a> {
112    code: ErrorCode,
113    message: &'a str,
114    retryable: bool,
115    http_status: u16,
116    exit_code: i32,
117    #[serde(skip_serializing_if = "serde_json::Map::is_empty")]
118    details: serde_json::Map<String, serde_json::Value>,
119}
120
121impl<'a> ErrorEnvelope<'a> {
122    fn new(error: &'a AppError, exit_code: ExitCode) -> Self {
123        Self {
124            code: error.code(),
125            message: error.message(),
126            retryable: error.is_retryable(),
127            http_status: error.http_status().as_u16(),
128            exit_code: exit_code.as_i32(),
129            details: error.details().clone().into_iter().collect(),
130        }
131    }
132}
133
134fn fallback_json(error: &AppError, exit_code: ExitCode) -> String {
135    format!(
136        r#"{{"code":"{}","message":{},"exit_code":{}}}"#,
137        error.code(),
138        serde_json::Value::String(error.message().to_string()),
139        exit_code.as_i32()
140    )
141}
142
143fn fallback_yaml(error: &AppError, exit_code: ExitCode) -> String {
144    format!(
145        "code: {}\nmessage: {}\nexit_code: {}\n",
146        error.code(),
147        serde_json::Value::String(error.message().to_string()),
148        exit_code.as_i32()
149    )
150}
151
152#[cfg(test)]
153mod tests {
154    use super::{ErrorRenderer, ExitCode, OutputFormat};
155    use rskit_errors::AppError;
156
157    #[test]
158    fn error_renderer_uses_same_exit_code_across_formats() {
159        let err = AppError::not_found("repo", Some("missing"));
160        for format in [OutputFormat::Text, OutputFormat::Json, OutputFormat::Yaml] {
161            let (rendered, code) = ErrorRenderer::new(format).render(&err);
162            assert_eq!(code, ExitCode::NotFound);
163            assert!(rendered.contains("not found"));
164        }
165    }
166
167    #[test]
168    fn json_fallback_stays_valid_and_escapes_the_message() {
169        let err = AppError::new(rskit_errors::ErrorCode::Internal, "boom \"quoted\"");
170        let rendered = super::fallback_json(&err, ExitCode::Failure);
171        let payload: serde_json::Value = serde_json::from_str(&rendered).expect("valid json");
172        assert_eq!(payload["code"], "INTERNAL_ERROR");
173        assert_eq!(payload["message"], "boom \"quoted\"");
174        assert_eq!(payload["exit_code"], 1);
175    }
176
177    #[test]
178    fn yaml_fallback_carries_code_message_and_exit_code() {
179        let err = AppError::new(rskit_errors::ErrorCode::Internal, "boom");
180        let rendered = super::fallback_yaml(&err, ExitCode::Failure);
181        assert!(rendered.contains("code: INTERNAL_ERROR"));
182        assert!(rendered.contains("exit_code: 1"));
183        let payload: serde_json::Value = serde_norway::from_str(&rendered).expect("valid yaml");
184        assert_eq!(payload["message"], "boom");
185    }
186}