xbp 10.39.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
//! Shared terminal table rendering helpers.

use std::fmt::Write as _;

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TableStyle {
    Box,
    Pipe,
}

impl TableStyle {
    fn box_border_chars(self) -> Option<BoxBorderChars> {
        match self {
            TableStyle::Box => Some(BoxBorderChars {
                top_left: '',
                top_mid: '',
                top_right: '',
                middle_left: '',
                middle_mid: '',
                middle_right: '',
                bottom_left: '',
                bottom_mid: '',
                bottom_right: '',
                vertical: '',
                horizontal: '',
            }),
            TableStyle::Pipe => None,
        }
    }
}

struct BoxBorderChars {
    top_left: char,
    top_mid: char,
    top_right: char,
    middle_left: char,
    middle_mid: char,
    middle_right: char,
    bottom_left: char,
    bottom_mid: char,
    bottom_right: char,
    vertical: char,
    horizontal: char,
}

pub fn render_table(
    headers: &[&str],
    rows: &[Vec<String>],
    style: TableStyle,
    indent: &str,
) -> String {
    let mut widths: Vec<usize> = headers.iter().map(|header| visible_width(header)).collect();
    for row in rows {
        for (index, cell) in row.iter().enumerate() {
            if index >= widths.len() {
                widths.push(visible_width(cell));
            } else {
                widths[index] = widths[index].max(visible_width(cell));
            }
        }
    }

    match style {
        TableStyle::Box => render_box_table(headers, rows, &widths, indent),
        TableStyle::Pipe => render_pipe_table(headers, rows, &widths, indent),
    }
}

fn render_box_table(
    headers: &[&str],
    rows: &[Vec<String>],
    widths: &[usize],
    indent: &str,
) -> String {
    let borders = TableStyle::Box
        .box_border_chars()
        .expect("box border chars");
    let mut out = String::new();
    let header_count = headers.len();

    writeln!(
        out,
        "{}{}",
        indent,
        horizontal_rule(
            &borders,
            widths,
            borders.top_left,
            borders.top_mid,
            borders.top_right
        )
    )
    .expect("write table top border");
    writeln!(
        out,
        "{}{}",
        indent,
        render_box_row(headers, widths, &borders)
    )
    .expect("write table header");
    writeln!(
        out,
        "{}{}",
        indent,
        horizontal_rule(
            &borders,
            widths,
            borders.middle_left,
            borders.middle_mid,
            borders.middle_right
        )
    )
    .expect("write table header separator");

    for row in rows {
        writeln!(
            out,
            "{}{}",
            indent,
            render_box_row_slice(row, widths, &borders, header_count)
        )
        .expect("write table row");
    }

    writeln!(
        out,
        "{}{}",
        indent,
        horizontal_rule(
            &borders,
            widths,
            borders.bottom_left,
            borders.bottom_mid,
            borders.bottom_right
        )
    )
    .expect("write table bottom border");

    out
}

fn render_box_row(headers: &[&str], widths: &[usize], borders: &BoxBorderChars) -> String {
    let cells: Vec<String> = headers.iter().map(|header| (*header).to_string()).collect();
    render_box_row_slice(&cells, widths, borders, headers.len())
}

fn render_box_row_slice(
    cells: &[String],
    widths: &[usize],
    borders: &BoxBorderChars,
    column_count: usize,
) -> String {
    let mut row = String::new();
    row.push(borders.vertical);
    row.push(' ');
    for (index, width) in widths.iter().enumerate().take(column_count) {
        if index > 0 {
            row.push(' ');
            row.push(borders.vertical);
            row.push(' ');
        }
        let cell = cells.get(index).map(String::as_str).unwrap_or("-");
        let _ = write!(row, "{cell}");
        let padding = width.saturating_sub(visible_width(cell));
        row.push_str(&" ".repeat(padding));
    }
    row.push(' ');
    row.push(borders.vertical);
    row
}

fn horizontal_rule(
    borders: &BoxBorderChars,
    widths: &[usize],
    left: char,
    middle: char,
    right: char,
) -> String {
    let mut line = String::new();
    line.push(left);
    for (index, width) in widths.iter().enumerate() {
        if index > 0 {
            line.push(middle);
        }
        line.push_str(&borders.horizontal.to_string().repeat(width + 2));
    }
    line.push(right);
    line
}

fn render_pipe_table(
    headers: &[&str],
    rows: &[Vec<String>],
    widths: &[usize],
    indent: &str,
) -> String {
    let mut out = String::new();
    let separator = pipe_separator(widths);

    writeln!(out, "{indent}{separator}").expect("write pipe table top separator");
    writeln!(out, "{indent}{}", render_pipe_row(headers, widths)).expect("write pipe table header");
    writeln!(out, "{indent}{separator}").expect("write pipe table header separator");
    for row in rows {
        writeln!(out, "{indent}{}", render_pipe_row_slice(row, widths))
            .expect("write pipe table row");
    }
    writeln!(out, "{indent}{separator}").expect("write pipe table bottom separator");
    out
}

fn pipe_separator(widths: &[usize]) -> String {
    let mut line = String::new();
    for width in widths {
        line.push('+');
        line.push_str(&"-".repeat(width + 2));
    }
    line.push('+');
    line
}

fn render_pipe_row(headers: &[&str], widths: &[usize]) -> String {
    let cells: Vec<String> = headers.iter().map(|header| (*header).to_string()).collect();
    render_pipe_row_slice(&cells, widths)
}

fn render_pipe_row_slice(cells: &[String], widths: &[usize]) -> String {
    let mut row = String::new();
    row.push('|');
    for (index, width) in widths.iter().enumerate() {
        row.push(' ');
        let cell = cells.get(index).map(String::as_str).unwrap_or("-");
        let _ = write!(row, "{cell}");
        let padding = width.saturating_sub(visible_width(cell));
        row.push_str(&" ".repeat(padding));
        row.push(' ');
        row.push('|');
    }
    row
}

fn visible_width(value: &str) -> usize {
    strip_ansi(value).chars().count()
}

fn strip_ansi(value: &str) -> String {
    let mut out = String::with_capacity(value.len());
    let mut chars = value.chars().peekable();
    while let Some(ch) = chars.next() {
        if ch == '\u{1b}' && chars.peek() == Some(&'[') {
            let _ = chars.next();
            for next in chars.by_ref() {
                if ('@'..='~').contains(&next) {
                    break;
                }
            }
            continue;
        }
        out.push(ch);
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn box_table_renders_with_unicode_borders() {
        let rendered = render_table(
            &["PORT", "PID"],
            &[vec![
                "3000".to_string(),
                "\u{1b}[32m42\u{1b}[0m".to_string(),
            ]],
            TableStyle::Box,
            "  ",
        );

        assert!(rendered.contains(""));
        assert!(rendered.contains("│ PORT"));
        assert!(rendered.contains("3000"));
    }

    #[test]
    fn pipe_table_renders_with_dashed_separators() {
        let rendered = render_table(&["NAME"], &[vec!["xbp".to_string()]], TableStyle::Pipe, "");

        assert!(rendered.starts_with("+"));
        assert!(rendered.contains("| NAME"));
        assert!(rendered.contains("xbp"));
    }
}