1use std::fmt::Write as _;
13use std::io::IsTerminal;
14
15pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct Style {
30 pub color: bool,
31}
32
33impl Style {
34 pub fn plain() -> Self {
36 Self { color: false }
37 }
38
39 pub fn auto() -> Self {
41 Self {
42 color: std::io::stdout().is_terminal(),
43 }
44 }
45}
46
47#[derive(Debug, Clone, PartialEq)]
49pub enum Cell {
50 Text(String),
51 Int(i64),
53 Float(f64, usize),
55 Money {
58 amount: f64,
59 estimated: bool,
60 partial: bool,
63 },
64 Unsupported,
66 Empty,
68}
69
70impl Cell {
71 pub fn text(s: impl Into<String>) -> Self {
72 Cell::Text(s.into())
73 }
74
75 pub fn money_est(amount: f64) -> Self {
77 Cell::Money {
78 amount,
79 estimated: true,
80 partial: false,
81 }
82 }
83
84 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 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
129pub 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
141pub 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#[derive(Debug, Clone)]
153pub struct Table {
154 headers: Vec<String>,
155 rows: Vec<Vec<Cell>>,
156}
157
158impl Table {
159 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 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 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 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 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 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}