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>>) {
181 self.rows.push(row.into_iter().map(Into::into).collect());
182 }
183}
184
185impl fmt::Display for OutputTable {
186 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187 let mut widths: Vec<usize> = self.columns.iter().map(String::len).collect();
188 for row in &self.rows {
189 for (i, cell) in row.iter().enumerate() {
190 if i < widths.len() {
191 widths[i] = widths[i].max(cell.len());
192 }
193 }
194 }
195
196 if let Some(title) = &self.title {
197 writeln!(f, "\n{title}")?;
198 }
199
200 let separator: String = widths
201 .iter()
202 .map(|w| "─".repeat(w + 2))
203 .collect::<Vec<_>>()
204 .join("┬");
205 writeln!(f, "┌{separator}┐")?;
206
207 let header: String = self
208 .columns
209 .iter()
210 .enumerate()
211 .map(|(i, c)| format!(" {:width$} ", c, width = widths[i]))
212 .collect::<Vec<_>>()
213 .join("│");
214 writeln!(f, "│{header}│")?;
215
216 let separator: String = widths
217 .iter()
218 .map(|w| "─".repeat(w + 2))
219 .collect::<Vec<_>>()
220 .join("┼");
221 writeln!(f, "├{separator}┤")?;
222
223 for row in &self.rows {
224 let cells: String = row
225 .iter()
226 .enumerate()
227 .map(|(i, c)| {
228 let w = widths.get(i).copied().unwrap_or(0);
229 format!(" {c:w$} ")
230 })
231 .collect::<Vec<_>>()
232 .join("│");
233 writeln!(f, "│{cells}│")?;
234 }
235
236 let separator: String = widths
237 .iter()
238 .map(|w| "─".repeat(w + 2))
239 .collect::<Vec<_>>()
240 .join("┴");
241 write!(f, "└{separator}┘")?;
242
243 Ok(())
244 }
245}
246
247pub struct OutputKV {
249 pairs: Vec<(String, String)>,
250}
251
252impl OutputKV {
253 #[must_use]
255 pub const fn new() -> Self {
256 Self { pairs: Vec::new() }
257 }
258
259 pub fn add(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
261 self.pairs.push((key.into(), value.into()));
262 self
263 }
264}
265
266impl Default for OutputKV {
267 fn default() -> Self {
268 Self::new()
269 }
270}
271
272impl fmt::Display for OutputKV {
273 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274 let max_key = self.pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
275 for (key, value) in &self.pairs {
276 writeln!(f, " {key:>max_key$}: {value}")?;
277 }
278 Ok(())
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn output_table_renders() {
288 let mut table = OutputTable::new(vec!["Name", "Count"]);
289 table.add_row(vec!["real", "500"]);
290 table.add_row(vec!["ai", "500"]);
291 let output = table.to_string();
292 assert!(output.contains("Name"));
293 assert!(output.contains("500"));
294 }
295
296 #[test]
297 fn output_kv_renders() {
298 let mut kv = OutputKV::new();
299 kv.add("Output", "/tmp/dataset");
300 kv.add("Preset", "image");
301 let output = kv.to_string();
302 assert!(output.contains("Output"));
303 assert!(output.contains("/tmp/dataset"));
304 }
305
306 #[test]
307 fn error_renderer_uses_same_exit_code_across_formats() {
308 let err = AppError::not_found("repo", Some("missing"));
309 for format in [OutputFormat::Text, OutputFormat::Json, OutputFormat::Yaml] {
310 let (rendered, code) = ErrorRenderer::new(format).render(&err);
311 assert_eq!(code, ExitCode::NotFound);
312 assert!(rendered.contains("not found"));
313 }
314 }
315}