Skip to main content

xbp_cli/commands/
terminal_table.rs

1//! Shared terminal table rendering helpers.
2
3use 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}