1use std::borrow::Cow;
2use std::fmt::Write as _;
3
4pub fn render_table(headers: &[String], rows: &[Vec<String>]) -> String {
5 let column_count = headers.len();
6 let mut widths = headers.iter().map(|h| display_width(h)).collect::<Vec<_>>();
7
8 for row in rows {
9 for (idx, cell) in row.iter().enumerate().take(column_count) {
10 widths[idx] = widths[idx].max(display_width(cell));
11 }
12 }
13
14 for width in &mut widths {
15 *width = (*width).max(1);
16 }
17
18 let mut output = String::new();
19
20 let header_line = format_row(headers, &widths);
22 let _ = writeln!(output, "{header_line}");
23
24 let separator_widths = widths.iter().map(|w| (*w).max(3)).collect::<Vec<usize>>();
26 let separator_cells = separator_widths
27 .iter()
28 .map(|w| "-".repeat(*w))
29 .collect::<Vec<_>>();
30 let separator_line = format_row(&separator_cells, &separator_widths);
31 let _ = writeln!(output, "{separator_line}");
32
33 for row in rows {
35 let row_line = format_row(row, &widths);
36 let _ = writeln!(output, "{row_line}");
37 }
38
39 output
40}
41
42pub fn print_table(headers: &[String], rows: &[Vec<String>]) {
43 let rendered = render_table(headers, rows);
44 print!("{rendered}");
45}
46
47fn format_row(values: &[String], widths: &[usize]) -> String {
48 let mut cells = Vec::with_capacity(values.len());
49 for (idx, value) in values.iter().enumerate() {
50 if idx >= widths.len() {
51 break;
52 }
53 let sanitized = sanitize_cell(value);
54 let display = display_width(sanitized.as_ref());
55 let mut cell = sanitized.into_owned();
56 let padding = widths
57 .get(idx)
58 .copied()
59 .unwrap_or_default()
60 .saturating_sub(display);
61 if padding > 0 {
62 cell.push_str(&" ".repeat(padding));
63 }
64 cells.push(cell);
65 }
66 let mut line = cells.join(" ");
67 while line.ends_with(' ') {
68 line.pop();
69 }
70 line
71}
72
73fn display_width(value: &str) -> usize {
74 let mut width = 0usize;
75 let mut chars = value.chars();
76 while let Some(ch) = chars.next() {
77 if ch == '\u{1b}' {
78 for next in chars.by_ref() {
80 if next == 'm' {
81 break;
82 }
83 }
84 } else {
85 width += 1;
86 }
87 }
88 width
89}
90
91fn sanitize_cell(value: &str) -> Cow<'_, str> {
92 if value.contains(['\n', '\r', '\t']) {
93 let mut sanitized = String::with_capacity(value.len());
94 for ch in value.chars() {
95 match ch {
96 '\n' | '\r' | '\t' => sanitized.push(' '),
97 other => sanitized.push(other),
98 }
99 }
100 Cow::Owned(sanitized)
101 } else {
102 Cow::Borrowed(value)
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109
110 #[test]
111 fn render_table_aligns_columns() {
112 let headers = vec!["id".to_string(), "name".to_string()];
113 let rows = vec![
114 vec!["1".to_string(), "Alice".to_string()],
115 vec!["2".to_string(), "Bob".to_string()],
116 ];
117
118 let rendered = render_table(&headers, &rows);
119 let lines: Vec<&str> = rendered.lines().collect();
120
121 assert_eq!(lines.len(), 4);
122 assert_eq!(lines[0], "id name");
123 assert_eq!(lines[1], "--- -----");
124 assert_eq!(lines[2], "1 Alice");
125 assert_eq!(lines[3], "2 Bob");
126 }
127
128 #[test]
129 fn display_width_counts_characters() {
130 assert_eq!(display_width("abc"), 3);
131 assert_eq!(display_width(""), 0);
132 assert_eq!(display_width("résumé"), 6);
133 }
134
135 #[test]
136 fn display_width_ignores_ansi_sequences() {
137 let value = "\u{1b}[31minvalid\u{1b}[0m";
138 assert_eq!(display_width(value), "invalid".len());
139 }
140}