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.
This commit is contained in:
claude@clouddev1
2026-05-08 09:06:02 +00:00
parent 67d68db5f8
commit 5b5e08d852
11 changed files with 965 additions and 125 deletions
+595
View File
@@ -0,0 +1,595 @@
//! 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}");
}
}