const ELLIPSIS: char = '…';
pub fn fit_cell(cell: &str, width: usize) -> String {
if width == 0 {
return cell.to_string();
}
let len = cell.chars().count();
if len <= width {
return format!("{cell:<width$}");
}
let mut out: String = cell.chars().take(width - 1).collect();
out.push(ELLIPSIS);
out
}
#[derive(Default)]
pub struct Table {
headers: Vec<String>,
caps: Vec<Option<usize>>,
status_col: Option<usize>,
rows: Vec<Vec<String>>,
}
impl Table {
pub fn new(headers: &[&str]) -> Self {
Self {
headers: headers.iter().map(|h| (*h).to_string()).collect(),
caps: vec![None; headers.len()],
status_col: None,
rows: Vec::new(),
}
}
pub fn cap(mut self, col: usize, max: usize) -> Self {
if let Some(slot) = self.caps.get_mut(col) {
*slot = Some(max);
}
self
}
pub fn status_col(mut self, col: usize) -> Self {
self.status_col = Some(col);
self
}
pub fn push(&mut self, cells: Vec<String>) {
self.rows.push(cells);
}
fn widths(&self) -> Vec<usize> {
self.headers
.iter()
.enumerate()
.map(|(col, header)| {
let header_w = header.chars().count();
let content = self
.rows
.iter()
.filter_map(|r| r.get(col))
.map(|c| c.chars().count())
.max()
.unwrap_or(0);
let mut w = header_w.max(content);
if let Some(cap) = self.caps[col] {
w = w.min(cap.max(header_w));
}
w
})
.collect()
}
fn format_row(&self, cells: &[String], widths: &[usize], colour: bool) -> String {
let last = self.headers.len().saturating_sub(1);
(0..self.headers.len())
.map(|i| {
let cell = cells.get(i).map(String::as_str).unwrap_or("");
let w = if i == last { 0 } else { widths[i] };
let padded = fit_cell(cell, w);
if colour && Some(i) == self.status_col {
if let Some(style) = super::status_style(cell) {
return super::paint(style, &padded, true);
}
}
padded
})
.collect::<Vec<_>>()
.join(" ")
}
pub fn render(&self) -> Vec<String> {
let widths = self.widths();
let mut out = Vec::with_capacity(self.rows.len() + 1);
out.push(self.format_row(&self.headers, &widths, false));
for row in &self.rows {
out.push(self.format_row(row, &widths, false));
}
out
}
pub fn print(&self) {
let widths = self.widths();
crate::ui::print_bold_header(&self.format_row(&self.headers, &widths, false));
let colour = self.status_col.is_some() && super::stdout_colored();
for row in &self.rows {
println!("{}", self.format_row(row, &widths, colour));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fit_cell_pads_short_values_to_width() {
assert_eq!(fit_cell("web", 6), "web ");
assert_eq!(fit_cell("alpine", 6), "alpine");
}
#[test]
fn fit_cell_truncates_with_an_ellipsis() {
let out = fit_cell("docker.io/library/alpine", 10);
assert_eq!(out.chars().count(), 10);
assert!(out.ends_with(ELLIPSIS));
assert!(out.starts_with("docker.io"));
}
#[test]
fn fit_cell_counts_chars_not_bytes() {
let out = fit_cell("café-service-name", 6);
assert_eq!(out.chars().count(), 6);
assert!(out.ends_with(ELLIPSIS));
}
#[test]
fn fit_cell_width_zero_returns_cell_unchanged() {
assert_eq!(fit_cell("anything-at-all", 0), "anything-at-all");
assert_eq!(fit_cell("", 0), "");
}
#[test]
fn columns_size_to_their_widest_cell() {
let mut t = Table::new(&["NAME", "STATUS"]);
t.push(vec!["a-very-long-project-name".into(), "running(1)".into()]);
t.push(vec!["x".into(), "exited(1)".into()]);
let lines = t.render();
let name_w = "a-very-long-project-name".chars().count();
assert!(lines[0].starts_with(&format!("{:<width$} ", "NAME", width = name_w)));
assert!(lines[2].starts_with(&format!("{:<width$} ", "x", width = name_w)));
assert_eq!(name_w, 24);
}
#[test]
fn over_cap_cells_truncate_and_stay_aligned() {
let mut t = Table::new(&["NAME", "STATUS"]).cap(0, 10).status_col(1);
t.push(vec!["this-name-is-far-too-long".into(), "running".into()]);
t.push(vec!["short".into(), "exited".into()]);
let lines = t.render();
for line in &lines {
assert_eq!(
line.chars().nth(10),
Some(' '),
"gap at the cap on {line:?}"
);
}
assert!(lines[1].contains(ELLIPSIS));
}
#[test]
fn cap_never_shrinks_below_the_header() {
let mut t = Table::new(&["REPOSITORY"]).cap(0, 3);
t.push(vec!["x".into()]);
let lines = t.render();
assert_eq!(lines[0], "REPOSITORY");
}
#[test]
fn trailing_column_is_emitted_raw() {
let mut t = Table::new(&["NAME", "PORTS"]).cap(0, 8);
let ports = "0.0.0.0:8080->80/tcp, 0.0.0.0:8443->443/tcp";
t.push(vec!["web".into(), ports.into()]);
let lines = t.render();
assert!(lines[1].ends_with(ports));
}
#[test]
fn missing_cells_render_blank() {
let mut t = Table::new(&["A", "B"]);
t.push(vec!["only-a".into()]);
let lines = t.render();
assert!(lines[1].starts_with("only-a"));
}
}