Skip to main content

taba_cli/output/
mod.rs

1//! The output: `json` (the server's response, indented), `table` (columns) and `raw`, and the shape of the
2//! API errors (doc 19b, section 18.11.3).
3
4use std::io::{self, Write};
5
6use taba_client::json::{self, Fields};
7use taba_client::Error as ApiError;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Format {
11    Json,
12    Table,
13    Raw,
14}
15
16impl Format {
17    pub fn parse(name: &str) -> Result<Format, String> {
18        match name {
19            "json" => Ok(Format::Json),
20            "table" => Ok(Format::Table),
21            "raw" => Ok(Format::Raw),
22            other => Err(format!(
23                "unknown output format `{other}` (use json, table or raw)"
24            )),
25        }
26    }
27}
28
29/// The text of a response in the requested format. `raw` returns the text as it came.
30pub fn render(text: &str, format: Format) -> String {
31    match format {
32        Format::Json => json::pretty(text).unwrap_or_else(|_| text.trim_end().to_owned()),
33        Format::Table => table(text).unwrap_or_else(|| render(text, Format::Json)),
34        Format::Raw => text.trim_end().to_owned(),
35    }
36}
37
38/// A table with the items of a list (`items`, `messages` or a list at the root) or, for a
39/// single object, one row per field. `None` if the text is not JSON of one of those shapes.
40pub fn table(text: &str) -> Option<String> {
41    let text = text.trim();
42    let rows: Vec<&str>;
43    let mut single = false;
44    if text.starts_with('[') {
45        rows = json::array_items(text).ok()?;
46    } else if text.starts_with('{') {
47        let fields = Fields::parse(text).ok()?;
48        match fields.raw("items").or_else(|| fields.raw("messages")) {
49            Some(list) if list.starts_with('[') => rows = json::array_items(list).ok()?,
50            _ => {
51                rows = vec![text];
52                single = true;
53            }
54        }
55    } else {
56        return None;
57    }
58    if rows.is_empty() {
59        return Some("(empty)".to_owned());
60    }
61    if single {
62        let fields = json::object_fields(rows[0]).ok()?;
63        let table: Vec<Vec<String>> = fields
64            .iter()
65            .map(|(k, v)| vec![k.clone(), cell(v)])
66            .collect();
67        return Some(layout(&["field", "value"], &table));
68    }
69    let mut columns: Vec<String> = Vec::new();
70    let mut parsed = Vec::new();
71    for row in &rows {
72        let fields = json::object_fields(row).ok()?;
73        for (k, _) in &fields {
74            if !columns.contains(k) {
75                columns.push(k.clone());
76            }
77        }
78        parsed.push(fields);
79    }
80    let table: Vec<Vec<String>> = parsed
81        .iter()
82        .map(|fields| {
83            columns
84                .iter()
85                .map(|c| {
86                    fields
87                        .iter()
88                        .find(|(k, _)| k == c)
89                        .map(|(_, v)| cell(v))
90                        .unwrap_or_else(|| "-".to_owned())
91                })
92                .collect()
93        })
94        .collect();
95    let header: Vec<&str> = columns.iter().map(String::as_str).collect();
96    Some(layout(&header, &table))
97}
98
99/// The `ClusterStatus` as tables: the simple fields, one row per node and one per condition.
100pub fn cluster_table(text: &str) -> Option<String> {
101    let fields = Fields::parse(text.trim()).ok()?;
102    let simple: Vec<Vec<String>> = [
103        "scope",
104        "partial",
105        "worstCondition",
106        "collectedAt",
107        "summary",
108    ]
109    .iter()
110    .filter_map(|name| fields.raw(name).map(|v| vec![(*name).to_owned(), cell(v)]))
111    .collect();
112    let mut out = layout(&["field", "value"], &simple);
113    for (title, name) in [("nodes", "nodes"), ("conditions", "conditions")] {
114        let list = fields.raw(name).filter(|l| l.starts_with('['))?;
115        out.push_str(&format!("\n\n{title}\n"));
116        out.push_str(&table(list)?);
117    }
118    Some(out)
119}
120
121/// The text of a cell: strings without quotes, `null` as `-`, objects and lists compact.
122fn cell(raw: &str) -> String {
123    let raw = raw.trim();
124    let text = if raw.starts_with('"') {
125        json::decode_string(raw).unwrap_or_else(|_| raw.to_owned())
126    } else if raw == "null" {
127        "-".to_owned()
128    } else if raw.starts_with('{') || raw.starts_with('[') {
129        compact(raw)
130    } else {
131        raw.to_owned()
132    };
133    let text = text.replace(['\n', '\r', '\t'], " ");
134    if text.chars().count() > 60 {
135        format!("{}…", text.chars().take(59).collect::<String>())
136    } else {
137        text
138    }
139}
140
141/// The JSON without the spaces and line breaks outside strings.
142fn compact(raw: &str) -> String {
143    let mut out = String::with_capacity(raw.len());
144    let mut in_string = false;
145    let mut escaped = false;
146    for c in raw.chars() {
147        if in_string {
148            out.push(c);
149            if escaped {
150                escaped = false;
151            } else if c == '\\' {
152                escaped = true;
153            } else if c == '"' {
154                in_string = false;
155            }
156        } else if c == '"' {
157            in_string = true;
158            out.push(c);
159        } else if !c.is_whitespace() {
160            out.push(c);
161        }
162    }
163    out
164}
165
166fn layout(header: &[&str], rows: &[Vec<String>]) -> String {
167    let widths: Vec<usize> = (0..header.len())
168        .map(|i| {
169            rows.iter()
170                .map(|r| r[i].chars().count())
171                .chain(std::iter::once(header[i].chars().count()))
172                .max()
173                .unwrap_or(0)
174        })
175        .collect();
176    let line = |cells: Vec<&str>| {
177        let mut out = String::new();
178        for (i, c) in cells.iter().enumerate() {
179            if i + 1 == cells.len() {
180                out.push_str(c);
181            } else {
182                out.push_str(&format!("{c:<width$}  ", width = widths[i]));
183            }
184        }
185        out.trim_end().to_owned()
186    };
187    let mut lines = vec![line(header.to_vec())];
188    for row in rows {
189        lines.push(line(row.iter().map(String::as_str).collect()));
190    }
191    lines.join("\n")
192}
193
194/// Writes an API error to the error output: `code: message (requestId ...)`, the `details` and the
195/// `Retry-After`. With `json` and a server `ErrorResponse`, writes the body as it came (indented).
196pub fn write_api_error(w: &mut dyn Write, e: &ApiError, json: bool) -> io::Result<()> {
197    if json && e.status > 0 {
198        let body = String::from_utf8_lossy(&e.body);
199        if Fields::parse(&body).is_ok_and(|f| f.string("code").is_some()) {
200            writeln!(w, "{}", render(&body, Format::Json))?;
201            if let Some(n) = e.retry_after {
202                writeln!(w, "Retry-After: {n}")?;
203            }
204            return Ok(());
205        }
206    }
207    writeln!(w, "{e}")?;
208    if let Some(details) = &e.details {
209        for item in json::array_items(details).unwrap_or_default() {
210            if let Ok(f) = Fields::parse(item) {
211                let field = f.string("field").unwrap_or_default();
212                let message = f.string("message").unwrap_or_default();
213                let code = f.string("code").unwrap_or_default();
214                writeln!(w, "  - {field}: {message} ({code})")?;
215            }
216        }
217    }
218    if let Some(n) = e.retry_after {
219        writeln!(w, "Retry-After: {n} s")?;
220    }
221    Ok(())
222}
223
224#[cfg(test)]
225mod tests;