1use std::fmt;
4
5use rskit_errors::{AppError, ErrorCode};
6use serde::Serialize;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10#[non_exhaustive]
11pub enum OutputFormat {
12 #[default]
14 Text,
15 Json,
17 Yaml,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23#[repr(i32)]
24#[non_exhaustive]
25pub enum ExitCode {
26 Success = 0,
28 Usage = 2,
30 Permission = 3,
32 NotFound = 4,
34 Conflict = 5,
36 Unavailable = 69,
38 RateLimited = 75,
40 Timeout = 124,
42 Cancelled = 130,
44 Failure = 1,
46}
47
48impl ExitCode {
49 #[must_use]
51 pub const fn as_i32(self) -> i32 {
52 self as i32
53 }
54}
55
56impl From<ErrorCode> for ExitCode {
57 fn from(code: ErrorCode) -> Self {
58 match code {
59 ErrorCode::InvalidInput | ErrorCode::InvalidFormat | ErrorCode::MissingField => {
60 Self::Usage
61 }
62 ErrorCode::Unauthorized
63 | ErrorCode::Forbidden
64 | ErrorCode::TokenExpired
65 | ErrorCode::InvalidToken => Self::Permission,
66 ErrorCode::NotFound => Self::NotFound,
67 ErrorCode::Conflict | ErrorCode::AlreadyExists => Self::Conflict,
68 ErrorCode::ServiceUnavailable
69 | ErrorCode::ConnectionFailed
70 | ErrorCode::ExternalService => Self::Unavailable,
71 ErrorCode::RateLimited => Self::RateLimited,
72 ErrorCode::Timeout => Self::Timeout,
73 ErrorCode::Cancelled => Self::Cancelled,
74 _ => Self::Failure,
75 }
76 }
77}
78
79pub struct ErrorRenderer {
81 format: OutputFormat,
82}
83
84impl ErrorRenderer {
85 #[must_use]
87 pub const fn new(format: OutputFormat) -> Self {
88 Self { format }
89 }
90
91 #[must_use]
93 pub fn render(&self, error: &AppError) -> (String, ExitCode) {
94 let exit_code = ExitCode::from(error.code());
95 let rendered = match self.format {
96 OutputFormat::Text => format!("error[{}]: {}", error.code(), error.message()),
97 OutputFormat::Json => serde_json::to_string(&ErrorEnvelope::new(error, exit_code))
98 .unwrap_or_else(|_| fallback_json(error, exit_code)),
99 OutputFormat::Yaml => serde_norway::to_string(&ErrorEnvelope::new(error, exit_code))
100 .unwrap_or_else(|_| fallback_yaml(error, exit_code)),
101 };
102 (rendered, exit_code)
103 }
104}
105
106impl Default for ErrorRenderer {
107 fn default() -> Self {
108 Self::new(OutputFormat::Text)
109 }
110}
111
112#[derive(Serialize)]
113struct ErrorEnvelope<'a> {
114 code: ErrorCode,
115 message: &'a str,
116 retryable: bool,
117 http_status: u16,
118 exit_code: i32,
119 #[serde(skip_serializing_if = "serde_json::Map::is_empty")]
120 details: serde_json::Map<String, serde_json::Value>,
121}
122
123impl<'a> ErrorEnvelope<'a> {
124 fn new(error: &'a AppError, exit_code: ExitCode) -> Self {
125 Self {
126 code: error.code(),
127 message: error.message(),
128 retryable: error.is_retryable(),
129 http_status: error.http_status().as_u16(),
130 exit_code: exit_code.as_i32(),
131 details: error.details().clone().into_iter().collect(),
132 }
133 }
134}
135
136fn fallback_json(error: &AppError, exit_code: ExitCode) -> String {
137 format!(
138 r#"{{"code":"{}","message":{},"exit_code":{}}}"#,
139 error.code(),
140 serde_json::Value::String(error.message().to_string()),
141 exit_code.as_i32()
142 )
143}
144
145fn fallback_yaml(error: &AppError, exit_code: ExitCode) -> String {
146 format!(
147 "code: {}\nmessage: {}\nexit_code: {}\n",
148 error.code(),
149 serde_json::Value::String(error.message().to_string()),
150 exit_code.as_i32()
151 )
152}
153
154pub struct OutputTable {
156 title: Option<String>,
157 columns: Vec<String>,
158 rows: Vec<Vec<String>>,
159}
160
161impl OutputTable {
162 #[must_use]
164 pub fn new(columns: Vec<impl Into<String>>) -> Self {
165 Self {
166 title: None,
167 columns: columns.into_iter().map(Into::into).collect(),
168 rows: Vec::new(),
169 }
170 }
171
172 #[must_use]
174 pub fn with_title(mut self, title: impl Into<String>) -> Self {
175 self.title = Some(title.into());
176 self
177 }
178
179 pub fn add_row(&mut self, row: Vec<impl Into<String>>) {
180 self.rows.push(row.into_iter().map(Into::into).collect());
181 }
182}
183
184impl fmt::Display for OutputTable {
185 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186 let mut widths: Vec<usize> = self.columns.iter().map(|c| c.len()).collect();
187 for row in &self.rows {
188 for (i, cell) in row.iter().enumerate() {
189 if i < widths.len() {
190 widths[i] = widths[i].max(cell.len());
191 }
192 }
193 }
194
195 if let Some(title) = &self.title {
196 writeln!(f, "\n{title}")?;
197 }
198
199 let separator: String = widths
200 .iter()
201 .map(|w| "─".repeat(w + 2))
202 .collect::<Vec<_>>()
203 .join("┬");
204 writeln!(f, "┌{separator}┐")?;
205
206 let header: String = self
207 .columns
208 .iter()
209 .enumerate()
210 .map(|(i, c)| format!(" {:width$} ", c, width = widths[i]))
211 .collect::<Vec<_>>()
212 .join("│");
213 writeln!(f, "│{header}│")?;
214
215 let separator: String = widths
216 .iter()
217 .map(|w| "─".repeat(w + 2))
218 .collect::<Vec<_>>()
219 .join("┼");
220 writeln!(f, "├{separator}┤")?;
221
222 for row in &self.rows {
223 let cells: String = row
224 .iter()
225 .enumerate()
226 .map(|(i, c)| {
227 let w = widths.get(i).copied().unwrap_or(0);
228 format!(" {c:w$} ")
229 })
230 .collect::<Vec<_>>()
231 .join("│");
232 writeln!(f, "│{cells}│")?;
233 }
234
235 let separator: String = widths
236 .iter()
237 .map(|w| "─".repeat(w + 2))
238 .collect::<Vec<_>>()
239 .join("┴");
240 write!(f, "└{separator}┘")?;
241
242 Ok(())
243 }
244}
245
246pub struct OutputKV {
248 pairs: Vec<(String, String)>,
249}
250
251impl OutputKV {
252 #[must_use]
254 pub fn new() -> Self {
255 Self { pairs: Vec::new() }
256 }
257
258 pub fn add(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
259 self.pairs.push((key.into(), value.into()));
260 self
261 }
262}
263
264impl Default for OutputKV {
265 fn default() -> Self {
266 Self::new()
267 }
268}
269
270impl fmt::Display for OutputKV {
271 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272 let max_key = self.pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
273 for (key, value) in &self.pairs {
274 writeln!(f, " {key:>max_key$}: {value}")?;
275 }
276 Ok(())
277 }
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 #[test]
285 fn output_table_renders() {
286 let mut table = OutputTable::new(vec!["Name", "Count"]);
287 table.add_row(vec!["real", "500"]);
288 table.add_row(vec!["ai", "500"]);
289 let output = table.to_string();
290 assert!(output.contains("Name"));
291 assert!(output.contains("500"));
292 }
293
294 #[test]
295 fn output_kv_renders() {
296 let mut kv = OutputKV::new();
297 kv.add("Output", "/tmp/dataset");
298 kv.add("Preset", "image");
299 let output = kv.to_string();
300 assert!(output.contains("Output"));
301 assert!(output.contains("/tmp/dataset"));
302 }
303
304 #[test]
305 fn error_renderer_uses_same_exit_code_across_formats() {
306 let err = AppError::not_found("repo", Some("missing"));
307 for format in [OutputFormat::Text, OutputFormat::Json, OutputFormat::Yaml] {
308 let (rendered, code) = ErrorRenderer::new(format).render(&err);
309 assert_eq!(code, ExitCode::NotFound);
310 assert!(rendered.contains("not found"));
311 }
312 }
313}