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 in 0..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 = widths[index].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 (index, width) in widths.iter().enumerate() {
214 if index == 0 {
215 line.push('+');
216 } else {
217 line.push('+');
218 }
219 line.push_str(&"-".repeat(width + 2));
220 }
221 line.push('+');
222 line
223}
224
225fn render_pipe_row(headers: &[&str], widths: &[usize]) -> String {
226 let cells: Vec<String> = headers.iter().map(|header| (*header).to_string()).collect();
227 render_pipe_row_slice(&cells, widths)
228}
229
230fn render_pipe_row_slice(cells: &[String], widths: &[usize]) -> String {
231 let mut row = String::new();
232 row.push('|');
233 for (index, width) in widths.iter().enumerate() {
234 row.push(' ');
235 let cell = cells.get(index).map(String::as_str).unwrap_or("-");
236 let _ = write!(row, "{cell}");
237 let padding = width.saturating_sub(visible_width(cell));
238 row.push_str(&" ".repeat(padding));
239 row.push(' ');
240 row.push('|');
241 }
242 row
243}
244
245fn visible_width(value: &str) -> usize {
246 strip_ansi(value).chars().count()
247}
248
249fn strip_ansi(value: &str) -> String {
250 let mut out = String::with_capacity(value.len());
251 let mut chars = value.chars().peekable();
252 while let Some(ch) = chars.next() {
253 if ch == '\u{1b}' && chars.peek() == Some(&'[') {
254 let _ = chars.next();
255 for next in chars.by_ref() {
256 if ('@'..='~').contains(&next) {
257 break;
258 }
259 }
260 continue;
261 }
262 out.push(ch);
263 }
264 out
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270
271 #[test]
272 fn box_table_renders_with_unicode_borders() {
273 let rendered = render_table(
274 &["PORT", "PID"],
275 &[vec![
276 "3000".to_string(),
277 "\u{1b}[32m42\u{1b}[0m".to_string(),
278 ]],
279 TableStyle::Box,
280 " ",
281 );
282
283 assert!(rendered.contains("┌"));
284 assert!(rendered.contains("│ PORT"));
285 assert!(rendered.contains("3000"));
286 }
287
288 #[test]
289 fn pipe_table_renders_with_dashed_separators() {
290 let rendered = render_table(&["NAME"], &[vec!["xbp".to_string()]], TableStyle::Pipe, "");
291
292 assert!(rendered.starts_with("+"));
293 assert!(rendered.contains("| NAME"));
294 assert!(rendered.contains("xbp"));
295 }
296}