fundaia 0.5.0

Command line for the Fundaia deployment platform: projects, services, variables, deployments, logs and metrics
use owo_colors::{OwoColorize, Style};

/// A column-aligned table, without a table crate.
///
/// Two reasons it is hand-written. The widths have to be measured on the text
/// *without* its colour escapes, and a generic table either does not know about
/// them or asks every caller to strip them first; and the only border this
/// output ever wants is none, because a CLI whose output is piped into `grep`
/// should not be printing box drawing characters around the answer.
pub struct Table {
    headers: Vec<String>,
    rows: Vec<Vec<Cell>>,
}

pub struct Cell {
    text: String,
    style: Option<Style>,
}

impl Cell {
    pub fn plain(text: impl Into<String>) -> Self {
        Self {
            text: text.into(),
            style: None,
        }
    }

    pub fn styled(text: impl Into<String>, style: Style) -> Self {
        Self {
            text: text.into(),
            style: Some(style),
        }
    }
}

impl Table {
    pub fn new(headers: &[&str]) -> Self {
        Self {
            headers: headers.iter().map(|header| (*header).to_owned()).collect(),
            rows: Vec::new(),
        }
    }

    pub fn push(&mut self, row: Vec<Cell>) {
        self.rows.push(row);
    }

    pub fn is_empty(&self) -> bool {
        self.rows.is_empty()
    }

    /// Prints the table, or nothing at all when there are no rows.
    ///
    /// Silence rather than an empty frame: the caller says what "nothing here"
    /// means in words, and it is never "a header with no rows under it".
    pub fn print(&self) {
        if self.rows.is_empty() {
            return;
        }

        let widths = self.widths();

        let header: Vec<String> = self
            .headers
            .iter()
            .enumerate()
            .map(|(index, header)| pad(&header.to_uppercase(), widths[index]))
            .collect();

        println!("{}", header.join("  ").style(Style::new().bright_black()));

        for row in &self.rows {
            let cells: Vec<String> = row
                .iter()
                .enumerate()
                .map(|(index, cell)| {
                    let padded = pad(&cell.text, widths.get(index).copied().unwrap_or(0));
                    match cell.style {
                        // Padded first, then styled: styling first would put the
                        // reset escape before the spaces and colour them too.
                        Some(style) => format!("{}", padded.style(style)),
                        None => padded,
                    }
                })
                .collect();

            println!("{}", cells.join("  ").trim_end());
        }
    }

    fn widths(&self) -> Vec<usize> {
        let mut widths: Vec<usize> = self
            .headers
            .iter()
            .map(|header| header.chars().count())
            .collect();

        for row in &self.rows {
            for (index, cell) in row.iter().enumerate() {
                let width = display_width(&cell.text);
                match widths.get_mut(index) {
                    Some(current) => *current = (*current).max(width),
                    None => widths.push(width),
                }
            }
        }

        widths
    }
}

fn pad(text: &str, width: usize) -> String {
    let current = display_width(text);
    if current >= width {
        return text.to_owned();
    }
    format!("{text}{}", " ".repeat(width - current))
}

/// Character count, ignoring any ANSI escapes the caller embedded.
///
/// Columns are aligned by what is *seen*, and an escape sequence occupies no
/// columns. Counting bytes or counting escapes both produce a table that looks
/// straight until the first coloured cell.
pub fn display_width(text: &str) -> usize {
    let mut width = 0;
    let mut characters = text.chars();

    while let Some(character) = characters.next() {
        if character == '\u{1b}' {
            // Skip to the end of the sequence: SGR ends at `m`, and every other
            // form this could meet ends at a letter too.
            for next in characters.by_ref() {
                if next.is_ascii_alphabetic() {
                    break;
                }
            }
            continue;
        }
        width += 1;
    }

    width
}

/// Shortens to `width`, ending in an ellipsis when it had to cut.
pub fn truncate(text: &str, width: usize) -> String {
    if text.chars().count() <= width {
        return text.to_owned();
    }
    if width <= 1 {
        return "".to_owned();
    }

    let kept: String = text.chars().take(width - 1).collect();
    format!("{kept}")
}

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

    #[test]
    fn it_should_measure_plain_text_by_its_characters() {
        assert_eq!(display_width("hola"), 4);
    }

    #[test]
    fn it_should_ignore_colour_escapes_when_measuring() {
        assert_eq!(display_width("\u{1b}[32mhola\u{1b}[0m"), 4);
    }

    #[test]
    fn it_should_measure_accented_characters_as_one_each() {
        assert_eq!(display_width("día"), 3);
    }

    #[test]
    fn it_should_leave_short_text_alone_when_truncating() {
        assert_eq!(truncate("hola", 10), "hola");
    }

    #[test]
    fn it_should_end_truncated_text_with_an_ellipsis() {
        assert_eq!(truncate("abcdefgh", 4), "abc…");
    }

    #[test]
    fn it_should_pad_to_the_requested_width() {
        assert_eq!(pad("ab", 5), "ab   ");
    }

    #[test]
    fn it_should_not_shorten_text_wider_than_the_column() {
        assert_eq!(pad("abcdef", 3), "abcdef");
    }

    #[test]
    fn it_should_report_a_table_with_no_rows_as_empty() {
        assert!(Table::new(&["a"]).is_empty());
    }
}