Skip to main content

warden/output/
table.rs

1//! The flat table renderer.
2//!
3//! Two rules earn this module its existence:
4//!
5//! - A cell is an enum, not a string. A report cannot accidentally hand the
6//!   renderer a `0` for a value its adapter cannot populate — it must say
7//!   [`Cell::Unsupported`], which renders as a dim `–`: unsupported columns are
8//!   greyed out rather than printing a misleading `0`.
9//! - Estimates are marked at the point of rendering: [`Cell::Money`] carries an
10//!   `estimated` flag, prints a trailing `~`, and triggers the legend footer.
11
12use std::fmt::Write as _;
13use std::io::IsTerminal;
14
15/// Placeholder for a KPI the source adapter cannot populate.
16pub const UNSUPPORTED: &str = "–";
17
18const DIM: &str = "\x1b[2m";
19const RESET: &str = "\x1b[0m";
20const GUTTER: usize = 2;
21const LEGEND: &str = "~ estimated";
22const PARTIAL_LEGEND: &str = "~+ partial — excludes models with no configured price";
23
24/// Whether rendering may emit ANSI escapes.
25///
26/// Escapes are only ever produced for a real terminal: never into `--json`,
27/// never into a pipe or a file.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct Style {
30    pub color: bool,
31}
32
33impl Style {
34    /// No escape codes at all. The right choice for tests and pipes.
35    pub fn plain() -> Self {
36        Self { color: false }
37    }
38
39    /// Colour only when stdout is a TTY.
40    pub fn auto() -> Self {
41        Self {
42            color: std::io::stdout().is_terminal(),
43        }
44    }
45}
46
47/// One rendered value. Numeric variants align right; text aligns left.
48#[derive(Debug, Clone, PartialEq)]
49pub enum Cell {
50    Text(String),
51    /// A count, rendered with human suffixes (`182.3k`, `3.42M`).
52    Int(i64),
53    /// A ratio or rate, rendered with `decimals` places.
54    Float(f64, usize),
55    /// A currency amount. `estimated` prints a `~` and adds the legend footer;
56    /// `partial` prints `~+` and says the figure omits unpriced spend.
57    Money {
58        amount: f64,
59        estimated: bool,
60        /// Some of what this figure covers could not be priced, so the number
61        /// is a floor. A bare total would silently understate the spend.
62        partial: bool,
63    },
64    /// The adapter cannot populate this KPI. Visually distinct from `0`.
65    Unsupported,
66    /// Genuinely nothing to show here (e.g. a spacer in a totals row).
67    Empty,
68}
69
70impl Cell {
71    pub fn text(s: impl Into<String>) -> Self {
72        Cell::Text(s.into())
73    }
74
75    /// A cost estimate — the only kind warden produces.
76    pub fn money_est(amount: f64) -> Self {
77        Cell::Money {
78            amount,
79            estimated: true,
80            partial: false,
81        }
82    }
83
84    /// A cost estimate that is also incomplete: real spend it cannot price is
85    /// missing from it, so it is marked rather than passed off as a total.
86    pub fn money_partial(amount: f64) -> Self {
87        Cell::Money {
88            amount,
89            estimated: true,
90            partial: true,
91        }
92    }
93
94    fn is_numeric(&self) -> bool {
95        matches!(self, Cell::Int(_) | Cell::Float(..) | Cell::Money { .. })
96    }
97
98    fn is_estimate(&self) -> bool {
99        matches!(
100            self,
101            Cell::Money {
102                estimated: true,
103                ..
104            }
105        )
106    }
107
108    fn is_partial(&self) -> bool {
109        matches!(self, Cell::Money { partial: true, .. })
110    }
111
112    /// The visible text, with no escape codes and therefore the true width.
113    fn plain(&self) -> String {
114        match self {
115            Cell::Text(s) => s.clone(),
116            Cell::Int(n) => format_count(*n),
117            Cell::Float(f, decimals) => format!("{f:.*}", *decimals),
118            Cell::Money {
119                amount,
120                estimated,
121                partial,
122            } => format_money(*amount, *estimated, *partial),
123            Cell::Unsupported => UNSUPPORTED.to_string(),
124            Cell::Empty => String::new(),
125        }
126    }
127}
128
129/// Human-readable counts: plain below 1000, `k` below a million, then `M`.
130pub fn format_count(n: i64) -> String {
131    let abs = (n as f64).abs();
132    if abs < 1_000.0 {
133        format!("{n}")
134    } else if abs < 1_000_000.0 {
135        format!("{:.1}k", n as f64 / 1_000.0)
136    } else {
137        format!("{:.2}M", n as f64 / 1_000_000.0)
138    }
139}
140
141/// Currency, with a trailing `~` when the figure is an estimate and `~+` when
142/// it is an estimate that also omits spend it could not price.
143pub fn format_money(amount: f64, estimated: bool, partial: bool) -> String {
144    match (estimated, partial) {
145        (true, true) => format!("${amount:.2} ~+"),
146        (true, false) => format!("${amount:.2} ~"),
147        (false, _) => format!("${amount:.2}"),
148    }
149}
150
151/// A headed, dynamically-sized table.
152#[derive(Debug, Clone)]
153pub struct Table {
154    headers: Vec<String>,
155    rows: Vec<Vec<Cell>>,
156}
157
158impl Table {
159    /// Headers are uppercased for you; callers write them naturally.
160    pub fn new<S: Into<String>>(headers: impl IntoIterator<Item = S>) -> Self {
161        Self {
162            headers: headers
163                .into_iter()
164                .map(|h| h.into().to_uppercase())
165                .collect(),
166            rows: Vec::new(),
167        }
168    }
169
170    pub fn push(&mut self, row: Vec<Cell>) {
171        self.rows.push(row);
172    }
173
174    pub fn with_row(mut self, row: Vec<Cell>) -> Self {
175        self.push(row);
176        self
177    }
178
179    pub fn is_empty(&self) -> bool {
180        self.rows.is_empty()
181    }
182
183    /// Render with the given style. Lines carry no trailing whitespace.
184    pub fn render(&self, style: Style) -> String {
185        let cols = self.headers.len();
186        let plain: Vec<Vec<String>> = self
187            .rows
188            .iter()
189            .map(|row| (0..cols).map(|i| cell(row, i).plain()).collect())
190            .collect();
191
192        let mut widths: Vec<usize> = self.headers.iter().map(|h| width(h)).collect();
193        for row in &plain {
194            for (i, text) in row.iter().enumerate() {
195                widths[i] = widths[i].max(width(text));
196            }
197        }
198
199        // A column is numeric if any populated cell in it is numeric.
200        let numeric: Vec<bool> = (0..cols)
201            .map(|i| self.rows.iter().any(|row| cell(row, i).is_numeric()))
202            .collect();
203
204        let mut out = String::new();
205        let mut line = String::new();
206        for (i, header) in self.headers.iter().enumerate() {
207            pad(&mut line, header, widths[i], numeric[i], i + 1 == cols);
208        }
209        push_line(&mut out, &line);
210
211        for (r, row) in plain.iter().enumerate() {
212            line.clear();
213            for (i, text) in row.iter().enumerate() {
214                let dim = style.color && matches!(cell(&self.rows[r], i), Cell::Unsupported);
215                if dim {
216                    line.push_str(DIM);
217                }
218                pad(&mut line, text, widths[i], numeric[i], i + 1 == cols);
219                if dim {
220                    line.push_str(RESET);
221                }
222            }
223            push_line(&mut out, &line);
224        }
225
226        let total: usize = widths.iter().sum::<usize>() + GUTTER * cols.saturating_sub(1);
227        let mut legend = |text: &str| {
228            let indent = total.saturating_sub(width(text));
229            let _ = writeln!(out, "{:indent$}{text}", "");
230        };
231        if self.rows.iter().flatten().any(Cell::is_estimate) {
232            legend(LEGEND);
233        }
234        if self.rows.iter().flatten().any(Cell::is_partial) {
235            legend(PARTIAL_LEGEND);
236        }
237        out
238    }
239}
240
241fn cell(row: &[Cell], i: usize) -> &Cell {
242    row.get(i).unwrap_or(&Cell::Empty)
243}
244
245fn width(s: &str) -> usize {
246    s.chars().count()
247}
248
249fn pad(line: &mut String, text: &str, w: usize, right: bool, last: bool) {
250    let fill = w.saturating_sub(width(text));
251    if right {
252        for _ in 0..fill {
253            line.push(' ');
254        }
255        line.push_str(text);
256    } else {
257        line.push_str(text);
258        if !last {
259            for _ in 0..fill {
260                line.push(' ');
261            }
262        }
263    }
264    if !last {
265        for _ in 0..GUTTER {
266            line.push(' ');
267        }
268    }
269}
270
271fn push_line(out: &mut String, line: &str) {
272    out.push_str(line.trim_end());
273    out.push('\n');
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    fn sample() -> Table {
281        Table::new(["project", "sessions", "in", "est. cost"])
282            .with_row(vec![
283                Cell::text("acme-api"),
284                Cell::Int(41),
285                Cell::Int(182_300),
286                Cell::money_est(12.40),
287            ])
288            .with_row(vec![
289                Cell::text("dotfiles"),
290                Cell::Int(3),
291                Cell::Unsupported,
292                Cell::money_est(0.42),
293            ])
294    }
295
296    #[test]
297    fn counts_switch_units_at_the_k_and_m_boundaries() {
298        assert_eq!(format_count(0), "0");
299        assert_eq!(format_count(999), "999");
300        assert_eq!(format_count(1_000), "1.0k");
301        assert_eq!(format_count(182_300), "182.3k");
302        assert_eq!(format_count(999_999), "1000.0k");
303        assert_eq!(format_count(1_000_000), "1.00M");
304        assert_eq!(format_count(3_420_000), "3.42M");
305        assert_eq!(format_count(-1_500), "-1.5k");
306    }
307
308    #[test]
309    fn money_marks_estimates() {
310        assert_eq!(format_money(12.4, true, false), "$12.40 ~");
311        assert_eq!(format_money(12.4, false, false), "$12.40");
312    }
313
314    #[test]
315    fn a_partial_total_is_marked_and_gets_its_own_legend() {
316        assert_eq!(format_money(107.36, true, true), "$107.36 ~+");
317
318        let table = Table::new(["project", "est. cost"])
319            .with_row(vec![Cell::text("acme"), Cell::money_partial(107.36)])
320            .with_row(vec![Cell::text("dotfiles"), Cell::money_est(0.42)]);
321        let rendered = table.render(Style::plain());
322        assert!(rendered.contains("$107.36 ~+"), "{rendered}");
323        assert!(rendered.contains(LEGEND), "{rendered}");
324        assert!(rendered.contains(PARTIAL_LEGEND), "{rendered}");
325
326        // No partial cell, no partial legend: the marker means something.
327        let whole = Table::new(["project", "est. cost"])
328            .with_row(vec![Cell::text("acme"), Cell::money_est(1.0)]);
329        assert!(!whole.render(Style::plain()).contains(PARTIAL_LEGEND));
330    }
331
332    #[test]
333    fn headers_are_uppercased_and_columns_line_up() {
334        let rendered = sample().render(Style::plain());
335        let lines: Vec<&str> = rendered.lines().collect();
336
337        assert_eq!(lines[0], "PROJECT   SESSIONS      IN  EST. COST");
338        assert_eq!(lines[1], "acme-api        41  182.3k   $12.40 ~");
339        assert_eq!(lines[2], "dotfiles         3       –    $0.42 ~");
340
341        // Right-aligned numerics: every row ends its money column at the same
342        // column, and the header row is exactly as wide as the widest row.
343        assert!(lines[1].ends_with("$12.40 ~"));
344        assert!(lines[2].ends_with("$0.42 ~"));
345        assert_eq!(lines[1].chars().count(), lines[2].chars().count());
346        assert_eq!(lines[0].chars().count(), lines[1].chars().count());
347    }
348
349    #[test]
350    fn unsupported_is_visually_distinct_from_zero() {
351        let rendered = sample().render(Style::plain());
352        assert!(rendered.contains(UNSUPPORTED));
353        assert_ne!(UNSUPPORTED, "0");
354        assert_eq!(Cell::Unsupported.plain(), "–");
355        assert_eq!(Cell::Int(0).plain(), "0");
356        assert_eq!(Cell::Empty.plain(), "");
357    }
358
359    #[test]
360    fn no_ansi_escapes_when_not_a_tty() {
361        let rendered = sample().render(Style::plain());
362        assert!(!rendered.contains('\x1b'), "{rendered:?}");
363    }
364
365    #[test]
366    fn unsupported_cells_are_dimmed_when_colour_is_allowed() {
367        let rendered = sample().render(Style { color: true });
368        assert!(rendered.contains(DIM));
369        assert!(rendered.contains(RESET));
370        // Nothing else picks up escapes.
371        assert_eq!(rendered.matches(DIM).count(), 1);
372    }
373
374    #[test]
375    fn legend_appears_only_when_an_estimate_is_present() {
376        assert!(sample().render(Style::plain()).ends_with("~ estimated\n"));
377
378        let exact = Table::new(["tool", "calls"]).with_row(vec![Cell::text("Read"), Cell::Int(12)]);
379        assert!(!exact.render(Style::plain()).contains(LEGEND));
380    }
381
382    #[test]
383    fn short_rows_and_empty_tables_render_without_panicking() {
384        let t = Table::new(["a", "b", "c"]).with_row(vec![Cell::text("x")]);
385        assert_eq!(t.render(Style::plain()).lines().count(), 2);
386        assert_eq!(
387            Table::new(["a"]).render(Style::plain()),
388            "A\n",
389            "an empty table still prints its header"
390        );
391    }
392
393    #[test]
394    fn no_line_has_trailing_whitespace() {
395        for line in sample().render(Style::plain()).lines() {
396            assert_eq!(line, line.trim_end());
397        }
398    }
399}