fundaia 0.55.3

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

use crate::render::sanitise;
use crate::render::theme;

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: sanitise::plain(&text.into()),
            style: None,
        }
    }

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

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

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()
    }

    pub fn print(&self) {
        print!("{}", self.rendered());
    }

    pub fn print_or(&self, nothing: impl std::fmt::Display) {
        if self.is_empty() {
            println!("{nothing}");
        } else {
            self.print();
        }
    }

    pub fn rendered(&self) -> String {
        if self.rows.is_empty() {
            return String::new();
        }

        let widths = self.widths();
        let mut out = String::new();

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

        out.push_str(&format!("{}\n", header.join("  ").style(theme::muted())));

        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 {
                        Some(style) => format!("{}", padded.style(style)),
                        None => padded,
                    }
                })
                .collect();

            out.push_str(cells.join("  ").trim_end());
            out.push('\n');
        }

        out
    }

    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))
}

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}' {
            for next in characters.by_ref() {
                if next.is_ascii_alphabetic() {
                    break;
                }
            }
            continue;
        }
        width += 1;
    }

    width
}

pub fn short_sha(sha: &str) -> &str {
    sha.char_indices()
        .nth(7)
        .map_or(sha, |(end, _)| &sha[..end])
}

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_keep_seven_characters_of_a_commit_hash() {
        assert_eq!(short_sha("abcdef1234567890"), "abcdef1");
    }

    #[test]
    fn it_should_leave_a_hash_shorter_than_seven_whole() {
        assert_eq!(short_sha("abc"), "abc");
    }

    #[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());
    }

    #[test]
    fn it_should_render_an_empty_table_as_nothing_at_all() {
        assert_eq!(Table::new(&["a", "b"]).rendered(), "");
    }

    #[test]
    fn it_should_leave_no_escape_in_the_header_when_the_output_is_silenced() {
        let mut table = Table::new(&["UNO", "DOS"]);
        table.push(vec![Cell::plain("a"), Cell::plain("b")]);

        assert!(!theme::silenced(|| table.rendered()).contains('\u{1b}'));
    }

    #[test]
    fn it_should_keep_the_colours_of_a_cell_this_binary_drew_itself() {
        let mut table = Table::new(&["estado"]);
        table.push(vec![Cell::drawn(
            "\u{1b}[32m●\u{1b}[0m \u{1b}[31m●\u{1b}[0m",
        )]);
        assert!(table.rendered().contains("\u{1b}[31m●"));
    }

    #[test]
    fn it_should_render_one_line_per_row_plus_the_header() {
        let mut table = Table::new(&["a"]);
        table.push(vec![Cell::plain("uno")]);
        table.push(vec![Cell::plain("dos")]);
        assert_eq!(table.rendered().lines().count(), 3);
    }

    #[test]
    #[ignore = "performance budget — the pipeline runs it with --release"]
    fn it_should_render_ten_thousand_rows_within_the_budget() {
        let mut table = Table::new(&["id", "nombre", "estado", "detalle"]);
        for row in 0..10_000 {
            table.push(vec![
                Cell::plain(format!("srv_{row}")),
                Cell::styled(format!("servicio-{row}"), Style::new().bold()),
                Cell::styled("en marcha", Style::new().green()),
                Cell::plain("un texto lo bastante largo como para ensanchar la columna"),
            ]);
        }

        let started = std::time::Instant::now();
        let _ = table.rendered();

        assert!(started.elapsed() < std::time::Duration::from_secs(1));
    }
}