Files
rdbms-playground/src/output_render.rs
T
claude@clouddev1 5b5e08d852 ADR-0016 + Iter 5/6 follow-up: pretty table rendering
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.
2026-05-08 09:06:02 +00:00

596 lines
19 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 (550)
//! 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}");
}
}