1use std::fmt::Write as _;
4
5#[derive(Clone, Copy, Debug, Eq, PartialEq)]
6pub enum TableStyle {
7 Box,
8 Pipe,
9}
10
11impl TableStyle {
12 fn box_border_chars(self) -> Option<BoxBorderChars> {
13 match self {
14 TableStyle::Box => Some(BoxBorderChars {
15 top_left: '┌',
16 top_mid: '┬',
17 top_right: '┐',
18 middle_left: '├',
19 middle_mid: '┼',
20 middle_right: '┤',
21 bottom_left: '└',
22 bottom_mid: '┴',
23 bottom_right: '┘',
24 vertical: '│',
25 horizontal: '─',
26 }),
27 TableStyle::Pipe => None,
28 }
29 }
30}
31
32struct BoxBorderChars {
33 top_left: char,
34 top_mid: char,
35 top_right: char,
36 middle_left: char,
37 middle_mid: char,
38 middle_right: char,
39 bottom_left: char,
40 bottom_mid: char,
41 bottom_right: char,
42 vertical: char,
43 horizontal: char,
44}
45
46pub fn render_table(
47 headers: &[&str],
48 rows: &[Vec<String>],
49 style: TableStyle,
50 indent: &str,
51) -> String {
52 let mut widths: Vec<usize> = headers.iter().map(|header| visible_width(header)).collect();
53 for row in rows {
54 for (index, cell) in row.iter().enumerate() {
55 if index >= widths.len() {
56 widths.push(visible_width(cell));
57 } else {
58 widths[index] = widths[index].max(visible_width(cell));
59 }
60 }
61 }
62
63 match style {
64 TableStyle::Box => render_box_table(headers, rows, &widths, indent),
65 TableStyle::Pipe => render_pipe_table(headers, rows, &widths, indent),
66 }
67}
68
69fn render_box_table(
70 headers: &[&str],
71 rows: &[Vec<String>],
72 widths: &[usize],
73 indent: &str,
74) -> String {
75 let borders = TableStyle::Box
76 .box_border_chars()
77 .expect("box border chars");
78 let mut out = String::new();
79 let header_count = headers.len();
80
81 writeln!(
82 out,
83 "{}{}",
84 indent,
85 horizontal_rule(
86 &borders,
87 widths,
88 borders.top_left,
89 borders.top_mid,
90 borders.top_right
91 )
92 )
93 .expect("write table top border");
94 writeln!(
95 out,
96 "{}{}",
97 indent,
98 render_box_row(headers, widths, &borders)
99 )
100 .expect("write table header");
101 writeln!(
102 out,
103 "{}{}",
104 indent,
105 horizontal_rule(
106 &borders,
107 widths,
108 borders.middle_left,
109 borders.middle_mid,
110 borders.middle_right
111 )
112 )
113 .expect("write table header separator");
114
115 for row in rows {
116 writeln!(
117 out,
118 "{}{}",
119 indent,
120 render_box_row_slice(row, widths, &borders, header_count)
121 )
122 .expect("write table row");
123 }
124
125 writeln!(
126 out,
127 "{}{}",
128 indent,
129 horizontal_rule(
130 &borders,
131 widths,
132 borders.bottom_left,
133 borders.bottom_mid,
134 borders.bottom_right
135 )
136 )
137 .expect("write table bottom border");
138
139 out
140}
141
142fn render_box_row(headers: &[&str], widths: &[usize], borders: &BoxBorderChars) -> String {
143 let cells: Vec<String> = headers.iter().map(|header| (*header).to_string()).collect();
144 render_box_row_slice(&cells, widths, borders, headers.len())
145}
146
147fn render_box_row_slice(
148 cells: &[String],
149 widths: &[usize],
150 borders: &BoxBorderChars,
151 column_count: usize,
152) -> String {
153 let mut row = String::new();
154 row.push(borders.vertical);
155 row.push(' ');
156 for (index, width) in widths.iter().enumerate().take(column_count) {
157 if index > 0 {
158 row.push(' ');
159 row.push(borders.vertical);
160 row.push(' ');
161 }
162 let cell = cells.get(index).map(String::as_str).unwrap_or("-");
163 let _ = write!(row, "{cell}");
164 let padding = width.saturating_sub(visible_width(cell));
165 row.push_str(&" ".repeat(padding));
166 }
167 row.push(' ');
168 row.push(borders.vertical);
169 row
170}
171
172fn horizontal_rule(
173 borders: &BoxBorderChars,
174 widths: &[usize],
175 left: char,
176 middle: char,
177 right: char,
178) -> String {
179 let mut line = String::new();
180 line.push(left);
181 for (index, width) in widths.iter().enumerate() {
182 if index > 0 {
183 line.push(middle);
184 }
185 line.push_str(&borders.horizontal.to_string().repeat(width + 2));
186 }
187 line.push(right);
188 line
189}
190
191fn render_pipe_table(
192 headers: &[&str],
193 rows: &[Vec<String>],
194 widths: &[usize],
195 indent: &str,
196) -> String {
197 let mut out = String::new();
198 let separator = pipe_separator(widths);
199
200 writeln!(out, "{indent}{separator}").expect("write pipe table top separator");
201 writeln!(out, "{indent}{}", render_pipe_row(headers, widths)).expect("write pipe table header");
202 writeln!(out, "{indent}{separator}").expect("write pipe table header separator");
203 for row in rows {
204 writeln!(out, "{indent}{}", render_pipe_row_slice(row, widths))
205 .expect("write pipe table row");
206 }
207 writeln!(out, "{indent}{separator}").expect("write pipe table bottom separator");
208 out
209}
210
211fn pipe_separator(widths: &[usize]) -> String {
212 let mut line = String::new();
213 for width in widths {
214 line.push('+');
215 line.push_str(&"-".repeat(width + 2));
216 }
217 line.push('+');
218 line
219}
220
221fn render_pipe_row(headers: &[&str], widths: &[usize]) -> String {
222 let cells: Vec<String> = headers.iter().map(|header| (*header).to_string()).collect();
223 render_pipe_row_slice(&cells, widths)
224}
225
226fn render_pipe_row_slice(cells: &[String], widths: &[usize]) -> String {
227 let mut row = String::new();
228 row.push('|');
229 for (index, width) in widths.iter().enumerate() {
230 row.push(' ');
231 let cell = cells.get(index).map(String::as_str).unwrap_or("-");
232 let _ = write!(row, "{cell}");
233 let padding = width.saturating_sub(visible_width(cell));
234 row.push_str(&" ".repeat(padding));
235 row.push(' ');
236 row.push('|');
237 }
238 row
239}
240
241fn visible_width(value: &str) -> usize {
242 strip_ansi(value).chars().count()
243}
244
245fn strip_ansi(value: &str) -> String {
246 let mut out = String::with_capacity(value.len());
247 let mut chars = value.chars().peekable();
248 while let Some(ch) = chars.next() {
249 if ch == '\u{1b}' && chars.peek() == Some(&'[') {
250 let _ = chars.next();
251 for next in chars.by_ref() {
252 if ('@'..='~').contains(&next) {
253 break;
254 }
255 }
256 continue;
257 }
258 out.push(ch);
259 }
260 out
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266
267 #[test]
268 fn box_table_renders_with_unicode_borders() {
269 let rendered = render_table(
270 &["PORT", "PID"],
271 &[vec![
272 "3000".to_string(),
273 "\u{1b}[32m42\u{1b}[0m".to_string(),
274 ]],
275 TableStyle::Box,
276 " ",
277 );
278
279 assert!(rendered.contains("┌"));
280 assert!(rendered.contains("│ PORT"));
281 assert!(rendered.contains("3000"));
282 }
283
284 #[test]
285 fn pipe_table_renders_with_dashed_separators() {
286 let rendered = render_table(&["NAME"], &[vec!["xbp".to_string()]], TableStyle::Pipe, "");
287
288 assert!(rendered.starts_with("+"));
289 assert!(rendered.contains("| NAME"));
290 assert!(rendered.contains("xbp"));
291 }
292}