toko-feed-cli 0.3.2

Operator CLI for Toko Feed canister ingestion and catalog queries
Documentation
//! Deterministic plain-text tables shared by all human CLI reports.

use std::fmt::Write as _;
use unicode_width::UnicodeWidthStr;

const COLUMN_GAP: &str = "   ";

/// Alignment applied to one rendered column.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ColumnAlign {
    Left,
    Right,
}

/// Render headers, a separator, and aligned rows.
pub fn render_table(
    headers: &[String],
    rows: &[Vec<String>],
    alignments: &[ColumnAlign],
) -> String {
    debug_assert_eq!(headers.len(), alignments.len());
    debug_assert!(rows.iter().all(|row| row.len() == headers.len()));
    let widths = table_widths(headers, rows);
    let mut lines = Vec::with_capacity(rows.len() + 2);
    lines.push(render_row(headers, &widths, alignments));
    lines.push(
        widths
            .iter()
            .map(|width| "-".repeat(*width))
            .collect::<Vec<_>>()
            .join(COLUMN_GAP),
    );
    lines.extend(rows.iter().map(|row| render_row(row, &widths, alignments)));
    lines.join("\n")
}

/// Escape terminal control characters without changing printable text.
pub fn sanitize_text(value: &str) -> String {
    let mut sanitized = String::with_capacity(value.len());
    for character in value.chars() {
        match character {
            '\n' => sanitized.push_str("\\n"),
            '\r' => sanitized.push_str("\\r"),
            '\t' => sanitized.push_str("\\t"),
            character if character.is_control() => {
                write!(sanitized, "\\u{{{:x}}}", u32::from(character))
                    .expect("writing to a String cannot fail");
            }
            character => sanitized.push(character),
        }
    }
    sanitized
}

fn table_widths(headers: &[String], rows: &[Vec<String>]) -> Vec<usize> {
    let mut widths = headers
        .iter()
        .map(|header| UnicodeWidthStr::width(sanitize_text(header).as_str()))
        .collect::<Vec<_>>();
    for row in rows {
        for (index, cell) in row.iter().enumerate() {
            if let Some(width) = widths.get_mut(index) {
                *width = (*width).max(UnicodeWidthStr::width(sanitize_text(cell).as_str()));
            }
        }
    }
    widths
}

fn render_row(row: &[String], widths: &[usize], alignments: &[ColumnAlign]) -> String {
    widths
        .iter()
        .zip(alignments)
        .enumerate()
        .map(|(index, (width, alignment))| {
            let value = sanitize_text(row.get(index).map_or("", String::as_str));
            let padding = " ".repeat(width.saturating_sub(UnicodeWidthStr::width(value.as_str())));
            match alignment {
                ColumnAlign::Left => format!("{value}{padding}"),
                ColumnAlign::Right => format!("{padding}{value}"),
            }
        })
        .collect::<Vec<_>>()
        .join(COLUMN_GAP)
        .trim_end()
        .to_owned()
}

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

    #[test]
    fn tables_align_numbers_and_escape_control_characters() {
        let headers = vec!["NAME".to_owned(), "COUNT".to_owned()];
        let rows = vec![
            vec!["Aquapolis".to_owned(), "545".to_owned()],
            vec!["The\nDark".to_owned(), "2".to_owned()],
        ];
        let rendered = render_table(&headers, &rows, &[ColumnAlign::Left, ColumnAlign::Right]);

        let aquapolis = rendered
            .lines()
            .find(|line| line.starts_with("Aquapolis"))
            .expect("Aquapolis row");
        let dark = rendered
            .lines()
            .find(|line| line.starts_with("The\\nDark"))
            .expect("escaped Dark row");
        assert!(aquapolis.ends_with("  545"));
        assert!(dark.ends_with("    2"));
        assert!(!rendered.contains("The\nDark"));
    }
}