airs-cli 0.5.0

Native Rust CLI for Prisma AIRS (executable airs-cli-rs until 1.0), built on the scm-sdk crate
Documentation
//! The TypeScript table (`formatOutput(rows, columns, 'table')`, R§2.3), padded by
//! display width instead of UTF-16 units (D53). Never coloured, never wrapped.

use super::width;

/// The table for `labels` over `rows` (cell texts from [`super::cell_text`]), without
/// a final newline. No rows → the empty string.
pub fn table(labels: &[&str], rows: &[Vec<String>]) -> String {
    if rows.is_empty() {
        return String::new();
    }
    let widths: Vec<usize> = labels
        .iter()
        .enumerate()
        .map(|(index, label)| {
            rows.iter()
                .map(|row| row.get(index).map_or(0, |cell| width(cell)))
                .fold(width(label), usize::max)
        })
        .collect();
    let line = |cells: &mut dyn Iterator<Item = &str>| -> String {
        cells
            .zip(&widths)
            .map(|(cell, &column)| {
                let pad = column.saturating_sub(width(cell));
                format!(" {cell}{} ", " ".repeat(pad))
            })
            .collect::<Vec<_>>()
            .join("│")
    };
    let mut lines = Vec::with_capacity(rows.len() + 2);
    lines.push(line(&mut labels.iter().copied()));
    lines.push(
        widths
            .iter()
            .map(|column| "─".repeat(column + 2))
            .collect::<Vec<_>>()
            .join("┼"),
    );
    for row in rows {
        let mut cells = (0..labels.len()).map(|index| row.get(index).map_or("", String::as_str));
        lines.push(line(&mut cells));
    }
    lines.join("\n")
}

/// A `Key │ Value` table of `(key, cell text)` rows (detail tables, `tenant read`).
pub fn key_value_table(rows: Vec<(String, String)>) -> String {
    let rows: Vec<Vec<String>> = rows
        .into_iter()
        .map(|(key, value)| vec![key, value])
        .collect();
    table(&["Key", "Value"], &rows)
}

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

    fn rows(cells: &[&[&str]]) -> Vec<Vec<String>> {
        cells
            .iter()
            .map(|row| row.iter().map(|cell| (*cell).to_owned()).collect())
            .collect()
    }

    const PROFILE_COLUMNS: [&str; 4] = ["ID", "Name", "Active", "Revision"];

    /// R§3.1, captured from the TypeScript CLI.
    #[test]
    fn golden_profile_list() {
        let text = table(
            &PROFILE_COLUMNS,
            &rows(&[
                &["00000000-0000-4000-8000-000000000002", "alpha", "true", "2"],
                &["00000000-0000-4000-8000-000000000003", "beta", "true", "1"],
            ]),
        );
        assert_eq!(
            format!("{text}\n"),
            " ID                                   │ Name  │ Active │ Revision \n──────────────────────────────────────┼───────┼────────┼──────────\n 00000000-0000-4000-8000-000000000002 │ alpha │ true   │ 2        \n 00000000-0000-4000-8000-000000000003 │ beta  │ true   │ 1        \n"
        );
    }

    /// R§3.1 number formatting; the control-character row is D52 (the caller's
    /// `cell_text` replaces them, so the widths stay those of TS).
    #[test]
    fn golden_profile_numbers_and_replaced_controls() {
        let text = table(
            &PROFILE_COLUMNS,
            &rows(&[
                &[
                    "00000000-0000-4000-8000-000000000031",
                    "frac-rev",
                    "true",
                    "2.5",
                ],
                &[
                    "00000000-0000-4000-8000-000000000032",
                    "huge-rev",
                    "true",
                    "1e+21",
                ],
                &[
                    "00000000-0000-4000-8000-000000000033",
                    "ctl\u{fffd}[31mred\u{fffd}tab",
                    "true",
                    "1",
                ],
            ]),
        );
        assert_eq!(
            format!("{text}\n"),
            " ID                                   │ Name            │ Active │ Revision \n──────────────────────────────────────┼─────────────────┼────────┼──────────\n 00000000-0000-4000-8000-000000000031 │ frac-rev        │ true   │ 2.5      \n 00000000-0000-4000-8000-000000000032 │ huge-rev        │ true   │ 1e+21    \n 00000000-0000-4000-8000-000000000033 │ ctl\u{fffd}[31mred\u{fffd}tab │ true   │ 1        \n"
        );
    }

    /// R§3.1 edge rows: an empty ID cell and an empty Active cell (TS bytes); the
    /// wide row is padded by display width (D53).
    #[test]
    fn golden_profile_edges_and_display_width() {
        let long = "a-very-long-security-profile-name-that-goes-on-and-on-for-a-while-0123456789";
        let text = table(
            &PROFILE_COLUMNS,
            &rows(&[
                &["", "no-id-inactive", "false", ""],
                &[
                    "00000000-0000-4000-8000-000000000010",
                    "no-active-field",
                    "",
                    "0",
                ],
                &["00000000-0000-4000-8000-000000000005", long, "true", "3"],
                &[
                    "00000000-0000-4000-8000-000000000006",
                    "prøfile-日本語-🚀",
                    "true",
                    "1",
                ],
            ]),
        );
        let lines: Vec<&str> = text.split('\n').collect();
        assert_eq!(
            lines[2],
            "                                      │ no-id-inactive                                                               │ false  │          "
        );
        assert_eq!(
            lines[3],
            " 00000000-0000-4000-8000-000000000010 │ no-active-field                                                              │        │ 0        "
        );
        // 76 columns wide: 17 visible columns of text plus 59 spaces.
        assert_eq!(
            lines[5],
            format!(
                " 00000000-0000-4000-8000-000000000006 │ prøfile-日本語-🚀{} │ true   │ 1        ",
                " ".repeat(76 - 17)
            )
        );
        let header_width = width(lines[0]);
        assert!(
            lines.iter().all(|line| width(line) == header_width),
            "aligned"
        );
    }

    /// R§3.5: `tenant list`.
    #[test]
    fn golden_tenant_list() {
        let text = table(
            &["Tenant", "Selected", "TSG ID", "Config file"],
            &rows(&[
                &["prod", "true", "100", "/r/configs/prod.json"],
                &[
                    "a-much-longer-tenant-name",
                    "false",
                    "7",
                    "/r/a/much/longer/path/to/configs/staging.json",
                ],
            ]),
        );
        assert_eq!(
            format!("{text}\n"),
            " Tenant                    │ Selected │ TSG ID │ Config file                                   \n───────────────────────────┼──────────┼────────┼───────────────────────────────────────────────\n prod                      │ true     │ 100    │ /r/configs/prod.json                          \n a-much-longer-tenant-name │ false    │ 7      │ /r/a/much/longer/path/to/configs/staging.json \n"
        );
    }

    /// R§3.7: `tenant get --output table`, including the unset key's empty cell.
    #[test]
    fn golden_key_value_tables() {
        assert_eq!(
            key_value_table(vec![("mgmtTsgId".into(), "100".into())]),
            " Key       │ Value \n───────────┼───────\n mgmtTsgId │ 100   "
        );
        assert_eq!(
            key_value_table(vec![("mgmtClientSecret".into(), "[REDACTED]".into())]),
            " Key              │ Value      \n──────────────────┼────────────\n mgmtClientSecret │ [REDACTED] "
        );
        assert_eq!(
            key_value_table(vec![("defaultOutput".into(), String::new())]),
            " Key           │ Value \n───────────────┼───────\n defaultOutput │       "
        );
    }

    /// R§3.4: the topic detail table.
    #[test]
    fn golden_topic_detail() {
        let text = key_value_table(
            [
                ("topicId", "00000000-0000-4000-8000-000000000022"),
                ("topicName", "no-examples"),
                ("revision", "1"),
                ("active", ""),
                ("description", ""),
                ("examples", "[]"),
                ("createdBy", ""),
                ("updatedBy", ""),
                ("lastModifiedTs", ""),
                ("createdTs", ""),
            ]
            .into_iter()
            .map(|(k, v)| (k.to_owned(), v.to_owned()))
            .collect(),
        );
        assert_eq!(
            format!("{text}\n"),
            " Key            │ Value                                \n────────────────┼──────────────────────────────────────\n topicId        │ 00000000-0000-4000-8000-000000000022 \n topicName      │ no-examples                          \n revision       │ 1                                    \n active         │                                      \n description    │                                      \n examples       │ []                                   \n createdBy      │                                      \n updatedBy      │                                      \n lastModifiedTs │                                      \n createdTs      │                                      \n"
        );
    }

    #[test]
    fn empty_rows_render_nothing_and_lines_end_with_a_space() {
        assert_eq!(table(&["A"], &[]), "");
        let text = table(&["A", "Bee"], &rows(&[&["x", ""]]));
        assert_eq!(text, " A │ Bee \n───┼─────\n x │     ");
        let lines: Vec<&str> = text.split('\n').collect();
        assert!(lines[0].starts_with(' ') && lines[0].ends_with(' '));
        assert!(!lines[1].starts_with(' ') && !lines[1].ends_with(' '));
        assert_eq!(lines[1].chars().count(), lines[0].chars().count());
    }
}