Skip to main content

rskit_cli/
output.rs

1//! Structured terminal output — tables and key-value displays.
2
3use std::fmt;
4
5use rskit_errors::{AppError, ErrorCode};
6use serde::Serialize;
7
8/// Machine-readable output format for CLI renderers.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10#[non_exhaustive]
11pub enum OutputFormat {
12    /// Human-readable terminal text.
13    #[default]
14    Text,
15    /// JSON object or array.
16    Json,
17    /// YAML document.
18    Yaml,
19}
20
21/// Process exit code convention shared by rskit CLIs.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23#[repr(i32)]
24#[non_exhaustive]
25pub enum ExitCode {
26    /// Successful command.
27    Success = 0,
28    /// Invalid command input or configuration.
29    Usage = 2,
30    /// Authentication or authorization failure.
31    Permission = 3,
32    /// Requested resource was not found.
33    NotFound = 4,
34    /// Conflict with current state.
35    Conflict = 5,
36    /// Remote dependency or service failure.
37    Unavailable = 69,
38    /// Command was rate limited.
39    RateLimited = 75,
40    /// Command timed out.
41    Timeout = 124,
42    /// Command was cancelled.
43    Cancelled = 130,
44    /// Unclassified failure.
45    Failure = 1,
46}
47
48impl ExitCode {
49    /// Return this exit code as an integer suitable for `std::process::exit`.
50    #[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
79/// Renders [`AppError`] values consistently for command-line applications.
80pub struct ErrorRenderer {
81    format: OutputFormat,
82}
83
84impl ErrorRenderer {
85    /// Create a renderer for the requested output format.
86    #[must_use]
87    pub const fn new(format: OutputFormat) -> Self {
88        Self { format }
89    }
90
91    /// Render an error and return the matching CLI exit code.
92    #[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
154/// A formatted table for terminal output.
155pub struct OutputTable {
156    title: Option<String>,
157    columns: Vec<String>,
158    rows: Vec<Vec<String>>,
159}
160
161impl OutputTable {
162    /// Create a table with the given column headings.
163    #[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    /// Set a human-readable table title.
173    #[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    /// Append a row of cell values.
180    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
247/// Key-value display for headers/summaries.
248pub struct OutputKV {
249    pairs: Vec<(String, String)>,
250}
251
252impl OutputKV {
253    /// Create an empty key-value output block.
254    #[must_use]
255    pub const fn new() -> Self {
256        Self { pairs: Vec::new() }
257    }
258
259    /// Add a key-value pair to the output block.
260    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}