5b5e08d852
Replaces the placeholder pipe-and-dash output with Unicode box-drawing tables for both data results and table-structure listings, per ADR-0016. * New `src/output_render.rs` module with `render_data_table` and `render_structure`. Hand-rolled to match the project's existing CSV/YAML pattern; ~300 lines. * Header-only outer-frame border style: outer ┌─┐│└─┘ box + ├─┤ header underline, no per-row separators. NULL renders as `(null)`; cell newlines/tabs/control chars become `↵`/`→`/`·` as display-only substitutions. * Type-aware column alignment: numeric types right-aligned, everything else left. `DataResult` gains a `column_types: Vec<Option<Type>>` field, populated from the existing metadata lookup at the two query sites in db.rs (no new query paths). * Structure view shows Name | Type | Constraints columns; References / Referenced-by sections retain plain-text format, leaving room for the future relationship-rendering ADR. * 18 new unit tests in output_render.rs (plus 4 insta snapshots for the canonical layouts). Existing assertions in app.rs and walking_skeleton.rs updated to match the new format. Total: 426 passing, 0 failing, 0 skipped (up from 408). Clippy clean.
596 lines
19 KiB
Rust
596 lines
19 KiB
Rust
//! Pretty-table rendering for the output panel
|
||
//! (ADR-0016).
|
||
//!
|
||
//! Two public entry points: [`render_data_table`] for query
|
||
//! / show-data / auto-show outputs, and [`render_structure`]
|
||
//! for table-structure listings produced after DDL or
|
||
//! `show table`.
|
||
//!
|
||
//! Both return a `Vec<String>` — one display row per element
|
||
//! — to match the existing `OutputLine`-per-line discipline
|
||
//! in `app.rs`. Border chars are Unicode box-drawing
|
||
//! (UTF-8); no ASCII fallback (ADR-0016 OOS-5).
|
||
//!
|
||
//! Layout: outer frame + header underline only. No per-row
|
||
//! horizontal rules, which keeps typical row counts (5–50)
|
||
//! readable without visual noise.
|
||
//!
|
||
//! NULL renders as the literal `(null)`; cell newlines, tabs
|
||
//! and control characters render as `↵`, `→`, `·`
|
||
//! respectively (display-only — underlying data is
|
||
//! untouched).
|
||
|
||
use crate::db::{ColumnDescription, DataResult, TableDescription};
|
||
use crate::dsl::Type;
|
||
|
||
const NULL_DISPLAY: &str = "(null)";
|
||
|
||
/// Render a query / data result as a Vec of display rows.
|
||
///
|
||
/// Empty result sets yield the header + a `(no rows)` line
|
||
/// inside the frame — matches the existing UX hint that "the
|
||
/// query ran but there was nothing to show."
|
||
#[must_use]
|
||
pub fn render_data_table(data: &DataResult) -> Vec<String> {
|
||
let header_cells: Vec<String> = data.columns.clone();
|
||
let alignments: Vec<Alignment> = data
|
||
.column_types
|
||
.iter()
|
||
.map(|t| alignment_for(*t))
|
||
.collect();
|
||
|
||
let body: Vec<Vec<String>> = if data.rows.is_empty() {
|
||
// For empty tables, still render the header band so
|
||
// the user sees the column shape, then a single
|
||
// `(no rows)` row spanning all columns. We achieve
|
||
// the spanning effect with a left-aligned cell in
|
||
// the first column and empty cells elsewhere.
|
||
let mut row = vec![String::new(); header_cells.len().max(1)];
|
||
if let Some(first) = row.first_mut() {
|
||
*first = "(no rows)".to_string();
|
||
}
|
||
vec![row]
|
||
} else {
|
||
data.rows
|
||
.iter()
|
||
.map(|r| {
|
||
r.iter()
|
||
.map(|c| {
|
||
c.as_ref()
|
||
.map_or_else(|| NULL_DISPLAY.to_string(), |s| sanitize_cell(s))
|
||
})
|
||
.collect()
|
||
})
|
||
.collect()
|
||
};
|
||
|
||
render_table(&header_cells, &body, &alignments)
|
||
}
|
||
|
||
/// Render a table-structure listing.
|
||
///
|
||
/// Produces a header line (`<TableName>`), the schema table
|
||
/// itself, and — for a structure that has FK relationships
|
||
/// — `References:` / `Referenced by:` blocks below as plain
|
||
/// indented text (relationship visualization is its own
|
||
/// future ADR per §5 OOS-1).
|
||
#[must_use]
|
||
pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
||
let mut out: Vec<String> = Vec::new();
|
||
out.push(desc.name.clone());
|
||
|
||
let header_cells = vec![
|
||
"Name".to_string(),
|
||
"Type".to_string(),
|
||
"Constraints".to_string(),
|
||
];
|
||
let body: Vec<Vec<String>> = desc
|
||
.columns
|
||
.iter()
|
||
.map(|c| {
|
||
vec![
|
||
c.name.clone(),
|
||
type_display(c),
|
||
constraints_display(c),
|
||
]
|
||
})
|
||
.collect();
|
||
// Type column gets the same numeric/text rule as data
|
||
// columns by virtue of consistency, but every entry is
|
||
// a keyword string ("text", "serial", …) so left-align
|
||
// is correct in every case. Constraints are similarly
|
||
// textual.
|
||
let alignments = vec![Alignment::Left, Alignment::Left, Alignment::Left];
|
||
out.extend(render_table(&header_cells, &body, &alignments));
|
||
|
||
if !desc.outbound_relationships.is_empty() {
|
||
out.push("References:".to_string());
|
||
for r in &desc.outbound_relationships {
|
||
out.push(format!(
|
||
" {} → {}.{} ({}, on delete {}, on update {})",
|
||
r.local_column,
|
||
r.other_table,
|
||
r.other_column,
|
||
r.name,
|
||
r.on_delete,
|
||
r.on_update,
|
||
));
|
||
}
|
||
}
|
||
if !desc.inbound_relationships.is_empty() {
|
||
out.push("Referenced by:".to_string());
|
||
for r in &desc.inbound_relationships {
|
||
out.push(format!(
|
||
" {}.{} → {} ({}, on delete {}, on update {})",
|
||
r.other_table,
|
||
r.other_column,
|
||
r.local_column,
|
||
r.name,
|
||
r.on_delete,
|
||
r.on_update,
|
||
));
|
||
}
|
||
}
|
||
|
||
out
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum Alignment {
|
||
Left,
|
||
Right,
|
||
}
|
||
|
||
/// Map a user-facing type to its column alignment per
|
||
/// ADR-0016 §2. `None` (foreign-attached, no metadata)
|
||
/// falls back to Left.
|
||
const fn alignment_for(ty: Option<Type>) -> Alignment {
|
||
match ty {
|
||
Some(Type::Int | Type::Real | Type::Decimal | Type::Serial) => Alignment::Right,
|
||
Some(Type::Text)
|
||
| Some(Type::Bool)
|
||
| Some(Type::Date)
|
||
| Some(Type::DateTime)
|
||
| Some(Type::Blob)
|
||
| Some(Type::ShortId)
|
||
| None => Alignment::Left,
|
||
}
|
||
}
|
||
|
||
fn type_display(c: &ColumnDescription) -> String {
|
||
c.user_type
|
||
.map_or_else(|| c.sqlite_type.to_lowercase(), |t| t.keyword().to_string())
|
||
}
|
||
|
||
fn constraints_display(c: &ColumnDescription) -> String {
|
||
let mut parts: Vec<&str> = Vec::new();
|
||
if c.primary_key {
|
||
parts.push("PK");
|
||
}
|
||
if c.notnull {
|
||
parts.push("NOT NULL");
|
||
}
|
||
parts.join(", ")
|
||
}
|
||
|
||
/// Replace newlines / tabs / other control characters with
|
||
/// visible display markers per ADR-0016 §3. Display-only;
|
||
/// underlying data is untouched.
|
||
fn sanitize_cell(s: &str) -> String {
|
||
let mut out = String::with_capacity(s.len());
|
||
for ch in s.chars() {
|
||
match ch {
|
||
'\n' => out.push('↵'),
|
||
'\t' => out.push('→'),
|
||
c if (c as u32) < 0x20 => out.push('·'),
|
||
other => out.push(other),
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Width in Unicode codepoints. Matches existing
|
||
/// width-counting throughout the codebase; deferring true
|
||
/// Unicode-Width handling to a later pass.
|
||
fn cell_width(s: &str) -> usize {
|
||
s.chars().count()
|
||
}
|
||
|
||
/// Render a single bordered table given header cells, body
|
||
/// rows, and per-column alignment. Outer frame +
|
||
/// header-underline only.
|
||
fn render_table(
|
||
headers: &[String],
|
||
body: &[Vec<String>],
|
||
alignments: &[Alignment],
|
||
) -> Vec<String> {
|
||
debug_assert_eq!(headers.len(), alignments.len());
|
||
|
||
// Compute column widths: max(header, all body cells).
|
||
// Empty headers + empty body produces an empty table,
|
||
// which we still want to render as a single horizontal
|
||
// line — easier to reason about than a missing one.
|
||
let column_count = headers.len();
|
||
let mut widths: Vec<usize> = headers.iter().map(|s| cell_width(s)).collect();
|
||
for row in body {
|
||
for (i, cell) in row.iter().enumerate() {
|
||
if i < widths.len() {
|
||
let w = cell_width(cell);
|
||
if w > widths[i] {
|
||
widths[i] = w;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let mut out: Vec<String> = Vec::with_capacity(body.len() + 3);
|
||
|
||
out.push(border_row(&widths, BorderRow::Top));
|
||
out.push(content_row(headers, &widths, alignments));
|
||
out.push(border_row(&widths, BorderRow::HeaderUnderline));
|
||
if column_count == 0 {
|
||
// Nothing more to render; the bottom border closes
|
||
// an empty box. Unusual but well-defined.
|
||
} else {
|
||
for row in body {
|
||
out.push(content_row(row, &widths, alignments));
|
||
}
|
||
}
|
||
out.push(border_row(&widths, BorderRow::Bottom));
|
||
out
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy)]
|
||
enum BorderRow {
|
||
Top,
|
||
HeaderUnderline,
|
||
Bottom,
|
||
}
|
||
|
||
fn border_row(widths: &[usize], kind: BorderRow) -> String {
|
||
let (left, mid, right) = match kind {
|
||
BorderRow::Top => ('┌', '┬', '┐'),
|
||
BorderRow::HeaderUnderline => ('├', '┼', '┤'),
|
||
BorderRow::Bottom => ('└', '┴', '┘'),
|
||
};
|
||
let mut s = String::new();
|
||
s.push(left);
|
||
if widths.is_empty() {
|
||
s.push(right);
|
||
return s;
|
||
}
|
||
for (i, w) in widths.iter().enumerate() {
|
||
if i > 0 {
|
||
s.push(mid);
|
||
}
|
||
// One space of padding on each side of the cell, so
|
||
// a width-w cell occupies w + 2 box columns.
|
||
for _ in 0..(w + 2) {
|
||
s.push('─');
|
||
}
|
||
}
|
||
s.push(right);
|
||
s
|
||
}
|
||
|
||
fn content_row(cells: &[String], widths: &[usize], alignments: &[Alignment]) -> String {
|
||
let mut s = String::new();
|
||
s.push('│');
|
||
for (i, w) in widths.iter().enumerate() {
|
||
let cell = cells.get(i).cloned().unwrap_or_default();
|
||
let align = alignments.get(i).copied().unwrap_or(Alignment::Left);
|
||
s.push(' ');
|
||
let pad_total = w.saturating_sub(cell_width(&cell));
|
||
match align {
|
||
Alignment::Left => {
|
||
s.push_str(&cell);
|
||
for _ in 0..pad_total {
|
||
s.push(' ');
|
||
}
|
||
}
|
||
Alignment::Right => {
|
||
for _ in 0..pad_total {
|
||
s.push(' ');
|
||
}
|
||
s.push_str(&cell);
|
||
}
|
||
}
|
||
s.push(' ');
|
||
s.push('│');
|
||
}
|
||
s
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::db::{ColumnDescription, RelationshipEnd};
|
||
use crate::dsl::ReferentialAction;
|
||
use insta::assert_snapshot;
|
||
|
||
fn col(name: &str, ty: Type, pk: bool, notnull: bool) -> ColumnDescription {
|
||
ColumnDescription {
|
||
name: name.to_string(),
|
||
user_type: Some(ty),
|
||
sqlite_type: match ty {
|
||
Type::Int | Type::Serial | Type::Bool => "INTEGER".to_string(),
|
||
Type::Real => "REAL".to_string(),
|
||
Type::Blob => "BLOB".to_string(),
|
||
_ => "TEXT".to_string(),
|
||
},
|
||
notnull,
|
||
primary_key: pk,
|
||
}
|
||
}
|
||
|
||
// --- Width / alignment helpers ----------------------
|
||
|
||
#[test]
|
||
fn alignment_for_numeric_types_is_right() {
|
||
for ty in [Type::Int, Type::Real, Type::Decimal, Type::Serial] {
|
||
assert_eq!(alignment_for(Some(ty)), Alignment::Right, "{ty:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn alignment_for_other_types_is_left() {
|
||
for ty in [
|
||
Type::Text,
|
||
Type::Bool,
|
||
Type::Date,
|
||
Type::DateTime,
|
||
Type::Blob,
|
||
Type::ShortId,
|
||
] {
|
||
assert_eq!(alignment_for(Some(ty)), Alignment::Left, "{ty:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn alignment_for_unknown_type_is_left() {
|
||
assert_eq!(alignment_for(None), Alignment::Left);
|
||
}
|
||
|
||
// --- sanitize_cell ----------------------------------
|
||
|
||
#[test]
|
||
fn sanitize_replaces_newline_with_return_arrow() {
|
||
assert_eq!(sanitize_cell("a\nb"), "a↵b");
|
||
}
|
||
|
||
#[test]
|
||
fn sanitize_replaces_tab_with_arrow() {
|
||
assert_eq!(sanitize_cell("a\tb"), "a→b");
|
||
}
|
||
|
||
#[test]
|
||
fn sanitize_replaces_other_control_chars_with_dot() {
|
||
// \x07 is BEL; should become a middle dot.
|
||
assert_eq!(sanitize_cell("a\x07b"), "a·b");
|
||
}
|
||
|
||
#[test]
|
||
fn sanitize_passes_through_normal_text() {
|
||
assert_eq!(sanitize_cell("Alice & Bob"), "Alice & Bob");
|
||
}
|
||
|
||
// --- cell_width -------------------------------------
|
||
|
||
#[test]
|
||
fn cell_width_counts_codepoints_not_bytes() {
|
||
// 'é' is 2 bytes in UTF-8 but counts as 1 codepoint.
|
||
assert_eq!(cell_width("héllo"), 5);
|
||
assert_eq!("héllo".len(), 6);
|
||
}
|
||
|
||
// --- render_data_table snapshots --------------------
|
||
|
||
#[test]
|
||
fn render_data_table_basic_shape() {
|
||
let data = DataResult {
|
||
table_name: "Customers".to_string(),
|
||
columns: vec!["id".to_string(), "Name".to_string(), "Email".to_string()],
|
||
column_types: vec![
|
||
Some(Type::Serial),
|
||
Some(Type::Text),
|
||
Some(Type::Text),
|
||
],
|
||
rows: vec![
|
||
vec![
|
||
Some("1".to_string()),
|
||
Some("Alice".to_string()),
|
||
Some("a@example.io".to_string()),
|
||
],
|
||
vec![
|
||
Some("2".to_string()),
|
||
Some("Bob".to_string()),
|
||
Some("b@example.io".to_string()),
|
||
],
|
||
],
|
||
};
|
||
assert_snapshot!(render_data_table(&data).join("\n"));
|
||
}
|
||
|
||
#[test]
|
||
fn render_data_table_empty_rows_shows_no_rows_marker() {
|
||
let data = DataResult {
|
||
table_name: "Customers".to_string(),
|
||
columns: vec!["id".to_string(), "Name".to_string()],
|
||
column_types: vec![Some(Type::Serial), Some(Type::Text)],
|
||
rows: Vec::new(),
|
||
};
|
||
let out = render_data_table(&data).join("\n");
|
||
assert!(out.contains("(no rows)"), "got:\n{out}");
|
||
assert_snapshot!(out);
|
||
}
|
||
|
||
#[test]
|
||
fn render_data_table_nulls_render_as_null_marker() {
|
||
let data = DataResult {
|
||
table_name: "T".to_string(),
|
||
columns: vec!["a".to_string(), "b".to_string()],
|
||
column_types: vec![Some(Type::Int), Some(Type::Text)],
|
||
rows: vec![
|
||
vec![Some("1".to_string()), None],
|
||
vec![None, Some("hi".to_string())],
|
||
],
|
||
};
|
||
let out = render_data_table(&data).join("\n");
|
||
assert!(out.contains("(null)"), "got:\n{out}");
|
||
}
|
||
|
||
#[test]
|
||
fn render_data_table_right_aligns_numerics() {
|
||
// A numeric column with mixed-width values should
|
||
// right-align so the digits' ones place stacks.
|
||
// Single-char header keeps column width = max body
|
||
// width = 3 (i.e. "999"), making the assertions
|
||
// explicit.
|
||
let data = DataResult {
|
||
table_name: "T".to_string(),
|
||
columns: vec!["n".to_string()],
|
||
column_types: vec![Some(Type::Int)],
|
||
rows: vec![
|
||
vec![Some("1".to_string())],
|
||
vec![Some("42".to_string())],
|
||
vec![Some("999".to_string())],
|
||
],
|
||
};
|
||
let out = render_data_table(&data).join("\n");
|
||
// The narrowest value ("1") must have leading
|
||
// padding so its rightmost digit lines up with "9"
|
||
// and "2" in the wider rows.
|
||
assert!(
|
||
out.contains("│ 1 │"),
|
||
"expected right-aligned `1`:\n{out}",
|
||
);
|
||
assert!(
|
||
out.contains("│ 42 │"),
|
||
"expected right-aligned `42`:\n{out}",
|
||
);
|
||
assert!(
|
||
out.contains("│ 999 │"),
|
||
"expected right-aligned `999`:\n{out}",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn render_data_table_left_aligns_text() {
|
||
let data = DataResult {
|
||
table_name: "T".to_string(),
|
||
columns: vec!["Name".to_string()],
|
||
column_types: vec![Some(Type::Text)],
|
||
rows: vec![
|
||
vec![Some("Alice".to_string())],
|
||
vec![Some("Bo".to_string())],
|
||
],
|
||
};
|
||
let out = render_data_table(&data).join("\n");
|
||
assert!(
|
||
out.contains("│ Alice │"),
|
||
"expected left-aligned `Alice`:\n{out}",
|
||
);
|
||
assert!(
|
||
out.contains("│ Bo │"),
|
||
"expected left-aligned `Bo` with trailing pad:\n{out}",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn render_data_table_handles_newline_in_cell() {
|
||
let data = DataResult {
|
||
table_name: "T".to_string(),
|
||
columns: vec!["note".to_string()],
|
||
column_types: vec![Some(Type::Text)],
|
||
rows: vec![vec![Some("Hello\nWorld".to_string())]],
|
||
};
|
||
let out = render_data_table(&data).join("\n");
|
||
assert!(out.contains("Hello↵World"), "got:\n{out}");
|
||
}
|
||
|
||
// --- render_structure snapshots ---------------------
|
||
|
||
#[test]
|
||
fn render_structure_basic() {
|
||
let desc = TableDescription {
|
||
name: "Customers".to_string(),
|
||
columns: vec![
|
||
col("id", Type::Serial, true, false),
|
||
col("Name", Type::Text, false, true),
|
||
col("Email", Type::Text, false, false),
|
||
],
|
||
outbound_relationships: Vec::new(),
|
||
inbound_relationships: Vec::new(),
|
||
};
|
||
assert_snapshot!(render_structure(&desc).join("\n"));
|
||
}
|
||
|
||
#[test]
|
||
fn render_structure_with_relationships() {
|
||
let desc = TableDescription {
|
||
name: "Customers".to_string(),
|
||
columns: vec![col("id", Type::Serial, true, false)],
|
||
outbound_relationships: Vec::new(),
|
||
inbound_relationships: vec![RelationshipEnd {
|
||
name: "cust_orders".to_string(),
|
||
other_table: "Orders".to_string(),
|
||
other_column: "cust_id".to_string(),
|
||
local_column: "id".to_string(),
|
||
on_delete: ReferentialAction::Cascade,
|
||
on_update: ReferentialAction::NoAction,
|
||
}],
|
||
};
|
||
let out = render_structure(&desc).join("\n");
|
||
assert!(
|
||
out.contains("Referenced by:"),
|
||
"expected inbound relationship section:\n{out}",
|
||
);
|
||
assert!(
|
||
out.contains("Orders.cust_id → id"),
|
||
"expected inbound relationship line:\n{out}",
|
||
);
|
||
assert_snapshot!(out);
|
||
}
|
||
|
||
#[test]
|
||
fn render_structure_pk_and_notnull_render_in_constraints() {
|
||
let desc = TableDescription {
|
||
name: "T".to_string(),
|
||
columns: vec![
|
||
col("id", Type::Serial, true, false),
|
||
col("name", Type::Text, false, true),
|
||
col("nick", Type::Text, false, false),
|
||
],
|
||
outbound_relationships: Vec::new(),
|
||
inbound_relationships: Vec::new(),
|
||
};
|
||
let out = render_structure(&desc).join("\n");
|
||
// PK appears for id, NOT NULL for name, blank for nick.
|
||
assert!(out.contains("│ id │ serial │ PK"), "got:\n{out}");
|
||
assert!(out.contains("│ name │ text │ NOT NULL"), "got:\n{out}");
|
||
}
|
||
|
||
#[test]
|
||
fn render_structure_falls_back_to_sqlite_type_when_user_type_missing() {
|
||
let mut desc = TableDescription {
|
||
name: "T".to_string(),
|
||
columns: vec![ColumnDescription {
|
||
name: "x".to_string(),
|
||
user_type: None,
|
||
sqlite_type: "INTEGER".to_string(),
|
||
notnull: false,
|
||
primary_key: false,
|
||
}],
|
||
outbound_relationships: Vec::new(),
|
||
inbound_relationships: Vec::new(),
|
||
};
|
||
let out = render_structure(&desc).join("\n");
|
||
// The lowercase form of the SQLite type should appear.
|
||
assert!(out.contains("integer"), "got:\n{out}");
|
||
// And the name column should still align correctly with no user_type.
|
||
desc.columns[0].user_type = Some(Type::Int);
|
||
let with_type = render_structure(&desc).join("\n");
|
||
assert!(with_type.contains("int"), "got:\n{with_type}");
|
||
}
|
||
}
|