rskit_cli/render/
error.rs1use rskit_errors::{AppError, ErrorCode};
4use serde::Serialize;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8#[non_exhaustive]
9pub enum OutputFormat {
10 #[default]
12 Text,
13 Json,
15 Yaml,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21#[repr(i32)]
22#[non_exhaustive]
23pub enum ExitCode {
24 Success = 0,
26 Usage = 2,
28 Permission = 3,
30 NotFound = 4,
32 Conflict = 5,
34 Unavailable = 69,
36 RateLimited = 75,
38 Timeout = 124,
40 Cancelled = 130,
42 Failure = 1,
44}
45
46impl ExitCode {
47 #[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
77pub struct ErrorRenderer {
79 format: OutputFormat,
80}
81
82impl ErrorRenderer {
83 #[must_use]
85 pub const fn new(format: OutputFormat) -> Self {
86 Self { format }
87 }
88
89 #[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}