Skip to main content

vantage_cli_util/
table_display.rs

1use comfy_table::{
2    Attribute, Cell, CellAlignment, ContentArrangement, Table as ComfyTable, TableComponent,
3    presets,
4};
5use indexmap::IndexMap;
6use owo_colors::OwoColorize;
7use vantage_dataset::prelude::ReadableValueSet;
8use vantage_table::prelude::ColumnLike;
9use vantage_table::table::Table;
10use vantage_table::traits::table_source::TableSource;
11use vantage_types::{Entity, Record, RichText, Span, Style, TerminalRender};
12
13/// Convert a [`RichText`] to an ANSI-styled string.
14///
15/// `owo-colors` honors `NO_COLOR` and auto-detects whether stdout is
16/// a TTY, so this is safe to embed in cells regardless of context.
17fn rich_to_ansi(rich: &RichText) -> String {
18    let mut out = String::with_capacity(rich.spans.iter().map(|s| s.text.len()).sum::<usize>() + 8);
19    for span in &rich.spans {
20        out.push_str(&span_to_ansi(span));
21    }
22    out
23}
24
25fn span_to_ansi(span: &Span) -> String {
26    let t = span.text.as_str();
27    match span.style {
28        Style::Default => t.to_string(),
29        Style::Dim => t.dimmed().to_string(),
30        Style::Muted => t.bright_black().to_string(),
31        Style::Strong => t.bold().to_string(),
32        Style::Success => t.green().to_string(),
33        Style::Error => t.red().to_string(),
34        Style::Warning => t.yellow().to_string(),
35        Style::Info => t.cyan().to_string(),
36    }
37}
38
39/// Pick alignment from the column's declared Rust type name.
40fn alignment_for(type_name: &str) -> CellAlignment {
41    let last = type_name.rsplit("::").next().unwrap_or(type_name);
42    if matches!(
43        last,
44        "i8" | "i16"
45            | "i32"
46            | "i64"
47            | "i128"
48            | "isize"
49            | "u8"
50            | "u16"
51            | "u32"
52            | "u64"
53            | "u128"
54            | "usize"
55            | "f32"
56            | "f64"
57    ) {
58        CellAlignment::Right
59    } else {
60        CellAlignment::Left
61    }
62}
63
64fn header_cell(name: &str) -> Cell {
65    Cell::new(name.to_uppercase().cyan().bold().to_string())
66}
67
68/// Fetch records from a table and print them as a styled table.
69pub async fn print_table<T, E>(table: &Table<T, E>) -> vantage_core::Result<()>
70where
71    T: TableSource,
72    T::Value: TerminalRender,
73    T::Id: std::fmt::Display,
74    E: Entity<T::Value>,
75{
76    let id_field = table.id_field().map(|c| c.name().to_string());
77
78    let column_types: IndexMap<String, &'static str> = table
79        .columns()
80        .iter()
81        .map(|(name, col)| (name.clone(), col.get_type()))
82        .collect();
83
84    let records = table.list_values().await?;
85    render_records_typed(&records, id_field.as_deref(), &column_types);
86    Ok(())
87}
88
89/// Render records as a styled table without per-column type metadata.
90///
91/// Convenience wrapper around [`render_records_typed`] for ad-hoc maps
92/// where column types aren't available.
93pub fn render_records<Id, V>(records: &IndexMap<Id, Record<V>>, id_field: Option<&str>)
94where
95    Id: std::fmt::Display,
96    V: TerminalRender,
97{
98    render_records_typed(records, id_field, &IndexMap::new());
99}
100
101/// Render records with an explicit column list — no auto-prepended
102/// id column. `column_types` is consulted for per-column alignment but
103/// doesn't drive which columns appear; `columns` does.
104///
105/// Used by the model-driven CLI when the caller passes `=col1,col2`:
106/// the user spelled out exactly what they want to see, and an extra
107/// id column would just be noise.
108pub fn render_records_columns<Id, V>(
109    records: &IndexMap<Id, Record<V>>,
110    columns: &[String],
111    column_types: &IndexMap<String, &'static str>,
112) where
113    Id: std::fmt::Display,
114    V: TerminalRender,
115{
116    if records.is_empty() {
117        println!("{}", "No records.".dimmed());
118        return;
119    }
120
121    let mut table = ComfyTable::new();
122    table
123        .load_preset(presets::UTF8_HORIZONTAL_ONLY)
124        .remove_style(TableComponent::HorizontalLines)
125        .remove_style(TableComponent::MiddleIntersections)
126        .remove_style(TableComponent::LeftBorderIntersections)
127        .remove_style(TableComponent::RightBorderIntersections)
128        .set_content_arrangement(ContentArrangement::Disabled);
129
130    let header: Vec<Cell> = columns.iter().map(|c| header_cell(c)).collect();
131    table.set_header(header);
132
133    for (idx, name) in columns.iter().enumerate() {
134        if let Some(type_name) = column_types.get(name) {
135            let align = alignment_for(type_name);
136            if let Some(col) = table.column_mut(idx) {
137                col.set_cell_alignment(align);
138            }
139        }
140    }
141
142    for (_id, record) in records {
143        let row: Vec<Cell> = columns
144            .iter()
145            .map(|col| match record.get(col.as_str()) {
146                Some(value) => Cell::new(rich_to_ansi(&value.render())),
147                None => Cell::new("—".bright_black().to_string()),
148            })
149            .collect();
150        table.add_row(row);
151    }
152
153    println!("{table}");
154    let n = records.len();
155    let label = if n == 1 { "record" } else { "records" };
156    println!("{}", format!("{n} {label}").dimmed());
157}
158
159/// Render records as a styled table.
160///
161/// `id_field` names the column used as the record key — printed as the
162/// leftmost column and skipped from the data section.
163///
164/// `column_types` maps column name → declared Rust type name (from
165/// `column.get_type()`). Drives per-column alignment and column order.
166pub fn render_records_typed<Id, V>(
167    records: &IndexMap<Id, Record<V>>,
168    id_field: Option<&str>,
169    column_types: &IndexMap<String, &'static str>,
170) where
171    Id: std::fmt::Display,
172    V: TerminalRender,
173{
174    if records.is_empty() {
175        println!("{}", "No records.".dimmed());
176        return;
177    }
178
179    let columns: Vec<String> = if !column_types.is_empty() {
180        column_types
181            .keys()
182            .filter(|k| Some(k.as_str()) != id_field)
183            .cloned()
184            .collect()
185    } else {
186        records
187            .values()
188            .next()
189            .unwrap()
190            .keys()
191            .filter(|k| k.as_str() != "id" && Some(k.as_str()) != id_field)
192            .cloned()
193            .collect()
194    };
195
196    let mut table = ComfyTable::new();
197    table
198        .load_preset(presets::UTF8_HORIZONTAL_ONLY)
199        // Drop inter-row separators — keep only top, header, and bottom rules.
200        .remove_style(TableComponent::HorizontalLines)
201        .remove_style(TableComponent::MiddleIntersections)
202        .remove_style(TableComponent::LeftBorderIntersections)
203        .remove_style(TableComponent::RightBorderIntersections)
204        // Size columns to their content. `Dynamic` stretches to fill
205        // terminal width, which looks bad when piped or when the
206        // terminal can't be detected.
207        .set_content_arrangement(ContentArrangement::Disabled);
208
209    let mut header = vec![header_cell("id")];
210    header.extend(columns.iter().map(|c| header_cell(c)));
211    table.set_header(header);
212
213    for (idx, name) in columns.iter().enumerate() {
214        if let Some(type_name) = column_types.get(name) {
215            let align = alignment_for(type_name);
216            if let Some(col) = table.column_mut(idx + 1) {
217                col.set_cell_alignment(align);
218            }
219        }
220    }
221
222    for (id, record) in records {
223        let id_cell = Cell::new(id.to_string()).add_attribute(Attribute::Bold);
224        let mut row = vec![id_cell];
225        for col in &columns {
226            let cell = match record.get(col.as_str()) {
227                Some(value) => Cell::new(rich_to_ansi(&value.render())),
228                None => Cell::new("—".bright_black().to_string()),
229            };
230            row.push(cell);
231        }
232        table.add_row(row);
233    }
234
235    println!("{table}");
236
237    let n = records.len();
238    let label = if n == 1 { "record" } else { "records" };
239    println!("{}", format!("{n} {label}").dimmed());
240}