Skip to main content

rskit_cli/render/
table.rs

1//! A formatted table for terminal output.
2
3use std::fmt;
4
5/// A formatted table for terminal output.
6pub struct OutputTable {
7    title: Option<String>,
8    columns: Vec<String>,
9    rows: Vec<Vec<String>>,
10}
11
12impl OutputTable {
13    /// Create a table with the given column headings.
14    #[must_use]
15    pub fn new(columns: Vec<impl Into<String>>) -> Self {
16        Self {
17            title: None,
18            columns: columns.into_iter().map(Into::into).collect(),
19            rows: Vec::new(),
20        }
21    }
22
23    /// Set a human-readable table title.
24    #[must_use]
25    pub fn with_title(mut self, title: impl Into<String>) -> Self {
26        self.title = Some(title.into());
27        self
28    }
29
30    /// Append a row of cell values.
31    ///
32    /// The row is normalized to the column count: extra cells are dropped and
33    /// missing cells are padded with empty strings, so every rendered row lines
34    /// up with the header and borders regardless of the caller's cell count.
35    pub fn add_row(&mut self, row: Vec<impl Into<String>>) {
36        let mut cells: Vec<String> = row.into_iter().map(Into::into).collect();
37        cells.truncate(self.columns.len());
38        cells.resize(self.columns.len(), String::new());
39        self.rows.push(cells);
40    }
41}
42
43impl fmt::Display for OutputTable {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        let mut widths: Vec<usize> = self.columns.iter().map(|c| c.chars().count()).collect();
46        for row in &self.rows {
47            for (i, cell) in row.iter().enumerate() {
48                if i < widths.len() {
49                    widths[i] = widths[i].max(cell.chars().count());
50                }
51            }
52        }
53
54        if let Some(title) = &self.title {
55            writeln!(f, "\n{title}")?;
56        }
57
58        let separator: String = widths
59            .iter()
60            .map(|w| "─".repeat(w + 2))
61            .collect::<Vec<_>>()
62            .join("┬");
63        writeln!(f, "┌{separator}┐")?;
64
65        let header: String = self
66            .columns
67            .iter()
68            .enumerate()
69            .map(|(i, c)| format!(" {:width$} ", c, width = widths[i]))
70            .collect::<Vec<_>>()
71            .join("│");
72        writeln!(f, "│{header}│")?;
73
74        let separator: String = widths
75            .iter()
76            .map(|w| "─".repeat(w + 2))
77            .collect::<Vec<_>>()
78            .join("┼");
79        writeln!(f, "├{separator}┤")?;
80
81        for row in &self.rows {
82            let cells: String = row
83                .iter()
84                .enumerate()
85                .map(|(i, c)| {
86                    let w = widths.get(i).copied().unwrap_or(0);
87                    format!(" {c:w$} ")
88                })
89                .collect::<Vec<_>>()
90                .join("│");
91            writeln!(f, "│{cells}│")?;
92        }
93
94        let separator: String = widths
95            .iter()
96            .map(|w| "─".repeat(w + 2))
97            .collect::<Vec<_>>()
98            .join("┴");
99        write!(f, "└{separator}┘")?;
100
101        Ok(())
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::OutputTable;
108
109    #[test]
110    fn output_table_renders() {
111        let mut table = OutputTable::new(vec!["Name", "Count"]);
112        table.add_row(vec!["real", "500"]);
113        table.add_row(vec!["ai", "500"]);
114        let output = table.to_string();
115        assert!(output.contains("Name"));
116        assert!(output.contains("500"));
117    }
118
119    #[test]
120    fn rows_are_normalized_to_column_count() {
121        // Rows with too few or too many cells must not desync from the borders:
122        // short rows are padded and long rows are truncated to the column count.
123        let mut table = OutputTable::new(vec!["A", "B"]);
124        table.add_row(vec!["only-one"]);
125        table.add_row(vec!["x", "y", "extra"]);
126        let output = table.to_string();
127
128        // Every rendered line (borders and rows alike) shares one width.
129        let widths: Vec<usize> = output.lines().map(|l| l.chars().count()).collect();
130        assert!(
131            widths.windows(2).all(|w| w[0] == w[1]),
132            "ragged lines: {output}"
133        );
134        assert!(!output.contains("extra"));
135    }
136}