csv_managed/
table.rs

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    // Header
21    let header_line = format_row(headers, &widths);
22    let _ = writeln!(output, "{header_line}");
23
24    // Separator
25    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    // Rows
34    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            // Skip ANSI escape sequence (e.g. \x1b[31m)
79            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}