Skip to main content

runtime_cli/
output.rs

1//! Output formatting helpers. Three modes:
2//! - `Human` — width-padded text columns / labeled rows.
3//! - `Tsv` — tab-separated, ready for `awk`/`cut`.
4//! - `Json` — raw GraphQL data slice.
5
6use std::fmt::Write as _;
7
8use clap::ValueEnum;
9use serde_json::Value;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Default)]
12pub enum Format {
13    #[default]
14    Human,
15    Tsv,
16    Json,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20pub struct FormatChoice {
21    pub json: bool,
22    pub tsv: bool,
23}
24
25impl FormatChoice {
26    pub fn resolve(self) -> Format {
27        if self.json {
28            Format::Json
29        } else if self.tsv {
30            Format::Tsv
31        } else {
32            Format::Human
33        }
34    }
35}
36
37/// Render a table of rows. `headers.len()` must equal each row's
38/// length. Empty input is a no-op.
39pub fn render_table(headers: &[&str], rows: &[Vec<String>], fmt: Format) -> String {
40    if rows.is_empty() && matches!(fmt, Format::Human) {
41        return String::from("(no rows)\n");
42    }
43    match fmt {
44        Format::Tsv => {
45            rows.iter()
46                .map(|r| r.join("\t"))
47                .collect::<Vec<_>>()
48                .join("\n")
49                .trim_end()
50                .to_string()
51                + "\n"
52        }
53        Format::Json => {
54            let arr: Vec<Value> = rows
55                .iter()
56                .map(|r| {
57                    let mut m = serde_json::Map::new();
58                    for (i, h) in headers.iter().enumerate() {
59                        m.insert((*h).to_string(), Value::String(r[i].clone()));
60                    }
61                    Value::Object(m)
62                })
63                .collect();
64            serde_json::to_string_pretty(&arr).unwrap_or_default() + "\n"
65        }
66        Format::Human => {
67            let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
68            for row in rows {
69                for (i, cell) in row.iter().enumerate() {
70                    if cell.len() > widths[i] {
71                        widths[i] = cell.len();
72                    }
73                }
74            }
75            let mut out = String::new();
76            for (i, h) in headers.iter().enumerate() {
77                let _ = write!(out, "{:<width$}", h, width = widths[i]);
78                if i + 1 < headers.len() {
79                    out.push_str("  ");
80                }
81            }
82            out.push('\n');
83            for row in rows {
84                for (i, cell) in row.iter().enumerate() {
85                    let _ = write!(out, "{:<width$}", cell, width = widths[i]);
86                    if i + 1 < row.len() {
87                        out.push_str("  ");
88                    }
89                }
90                out.push('\n');
91            }
92            out
93        }
94    }
95}
96
97/// Render a single record as labeled rows.
98pub fn render_record(pairs: &[(&str, String)], fmt: Format) -> String {
99    match fmt {
100        Format::Json => {
101            let mut m = serde_json::Map::new();
102            for (k, v) in pairs {
103                m.insert((*k).to_string(), Value::String(v.clone()));
104            }
105            serde_json::to_string_pretty(&Value::Object(m)).unwrap_or_default() + "\n"
106        }
107        Format::Tsv => {
108            pairs
109                .iter()
110                .map(|(_, v)| v.as_str())
111                .collect::<Vec<_>>()
112                .join("\t")
113                + "\n"
114        }
115        Format::Human => {
116            let key_w = pairs.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
117            let mut out = String::new();
118            for (k, v) in pairs {
119                let _ = writeln!(out, "{:<key_w$}  {}", k, v, key_w = key_w);
120            }
121            out
122        }
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn human_table_pads_columns() {
132        let s = render_table(
133            &["a", "bbb"],
134            &[
135                vec!["1".into(), "two".into()],
136                vec!["12".into(), "x".into()],
137            ],
138            Format::Human,
139        );
140        let lines: Vec<&str> = s.lines().collect();
141        assert_eq!(lines[0], "a   bbb");
142        assert_eq!(lines[1], "1   two");
143        assert_eq!(lines[2], "12  x  ");
144    }
145
146    #[test]
147    fn tsv_table_uses_tabs() {
148        let s = render_table(&["a", "b"], &[vec!["1".into(), "2".into()]], Format::Tsv);
149        assert_eq!(s, "1\t2\n");
150    }
151
152    #[test]
153    fn json_table_is_array_of_objects() {
154        let s = render_table(&["a", "b"], &[vec!["1".into(), "2".into()]], Format::Json);
155        let v: Value = serde_json::from_str(s.trim()).unwrap();
156        assert_eq!(v[0]["a"], "1");
157        assert_eq!(v[0]["b"], "2");
158    }
159
160    #[test]
161    fn empty_human_table_is_marker() {
162        let s = render_table(&["a"], &[], Format::Human);
163        assert_eq!(s, "(no rows)\n");
164    }
165
166    #[test]
167    fn format_choice_priority() {
168        assert_eq!(
169            FormatChoice {
170                json: true,
171                tsv: true
172            }
173            .resolve(),
174            Format::Json
175        );
176        assert_eq!(
177            FormatChoice {
178                json: false,
179                tsv: true
180            }
181            .resolve(),
182            Format::Tsv
183        );
184        assert_eq!(FormatChoice::default().resolve(), Format::Human);
185    }
186}