use std::fmt;
pub struct OutputTable {
title: Option<String>,
columns: Vec<String>,
rows: Vec<Vec<String>>,
}
impl OutputTable {
#[must_use]
pub fn new(columns: Vec<impl Into<String>>) -> Self {
Self {
title: None,
columns: columns.into_iter().map(Into::into).collect(),
rows: Vec::new(),
}
}
#[must_use]
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn add_row(&mut self, row: Vec<impl Into<String>>) {
let mut cells: Vec<String> = row.into_iter().map(Into::into).collect();
cells.truncate(self.columns.len());
cells.resize(self.columns.len(), String::new());
self.rows.push(cells);
}
}
impl fmt::Display for OutputTable {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut widths: Vec<usize> = self.columns.iter().map(|c| c.chars().count()).collect();
for row in &self.rows {
for (i, cell) in row.iter().enumerate() {
if i < widths.len() {
widths[i] = widths[i].max(cell.chars().count());
}
}
}
if let Some(title) = &self.title {
writeln!(f, "\n{title}")?;
}
let separator: String = widths
.iter()
.map(|w| "─".repeat(w + 2))
.collect::<Vec<_>>()
.join("┬");
writeln!(f, "┌{separator}┐")?;
let header: String = self
.columns
.iter()
.enumerate()
.map(|(i, c)| format!(" {:width$} ", c, width = widths[i]))
.collect::<Vec<_>>()
.join("│");
writeln!(f, "│{header}│")?;
let separator: String = widths
.iter()
.map(|w| "─".repeat(w + 2))
.collect::<Vec<_>>()
.join("┼");
writeln!(f, "├{separator}┤")?;
for row in &self.rows {
let cells: String = row
.iter()
.enumerate()
.map(|(i, c)| {
let w = widths.get(i).copied().unwrap_or(0);
format!(" {c:w$} ")
})
.collect::<Vec<_>>()
.join("│");
writeln!(f, "│{cells}│")?;
}
let separator: String = widths
.iter()
.map(|w| "─".repeat(w + 2))
.collect::<Vec<_>>()
.join("┴");
write!(f, "└{separator}┘")?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::OutputTable;
#[test]
fn output_table_renders() {
let mut table = OutputTable::new(vec!["Name", "Count"]);
table.add_row(vec!["real", "500"]);
table.add_row(vec!["ai", "500"]);
let output = table.to_string();
assert!(output.contains("Name"));
assert!(output.contains("500"));
}
#[test]
fn rows_are_normalized_to_column_count() {
let mut table = OutputTable::new(vec!["A", "B"]);
table.add_row(vec!["only-one"]);
table.add_row(vec!["x", "y", "extra"]);
let output = table.to_string();
let widths: Vec<usize> = output.lines().map(|l| l.chars().count()).collect();
assert!(
widths.windows(2).all(|w| w[0] == w[1]),
"ragged lines: {output}"
);
assert!(!output.contains("extra"));
}
}