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
+12 -120
View File
@@ -722,49 +722,8 @@ impl App {
let summary = format!("[ok] {} {}", command.verb(), command.display_subject());
self.note_system(summary);
if let Some(desc) = description.as_ref() {
self.note_system(format!(" {}", desc.name));
for col in &desc.columns {
let pk = if col.primary_key { " [PK]" } else { "" };
let nn = if col.notnull { " NOT NULL" } else { "" };
// Prefer the user-facing type recovered from our
// metadata table; fall back to the SQLite type only
// if metadata is missing (only happens for tables we
// didn't create — not in the current flow).
let type_display = col
.user_type
.map_or_else(|| col.sqlite_type.to_lowercase(), |t| t.keyword().to_string());
self.note_system(format!(
" {} {}{}{}",
col.name, type_display, pk, nn
));
}
if !desc.outbound_relationships.is_empty() {
self.note_system(" References:");
for r in &desc.outbound_relationships {
self.note_system(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() {
self.note_system(" Referenced by:");
for r in &desc.inbound_relationships {
self.note_system(format!(
" {}.{}{} ({}, on delete {}, on update {})",
r.other_table,
r.other_column,
r.local_column,
r.name,
r.on_delete,
r.on_update,
));
}
for line in crate::output_render::render_structure(desc) {
self.note_system(line);
}
}
self.current_table = description;
@@ -773,7 +732,7 @@ impl App {
fn handle_dsl_query_success(&mut self, command: &Command, data: &DataResult) {
let summary = format!("[ok] {} {}", command.verb(), command.display_subject());
self.note_system(summary);
for line in render_data_view(data) {
for line in crate::output_render::render_data_table(data) {
self.note_system(line);
}
}
@@ -785,7 +744,7 @@ impl App {
command.display_subject()
));
self.note_system(format!(" {} row(s) inserted", result.rows_affected));
for line in render_data_view(&result.data) {
for line in crate::output_render::render_data_table(&result.data) {
self.note_system(line);
}
}
@@ -797,7 +756,7 @@ impl App {
command.display_subject()
));
self.note_system(format!(" {} row(s) updated", result.rows_affected));
for line in render_data_view(&result.data) {
for line in crate::output_render::render_data_table(&result.data) {
self.note_system(line);
}
}
@@ -1264,77 +1223,6 @@ fn render_cascade_effect(effect: &CascadeEffect) -> String {
)
}
/// Render a data result as a sequence of aligned-column text
/// lines suitable for the output panel. Pretty box-drawing
/// rendering is V4 territory; this version uses simple
/// pipe-and-dash separators.
fn render_data_view(data: &DataResult) -> Vec<String> {
let header = data.columns.clone();
let body: Vec<Vec<String>> = data
.rows
.iter()
.map(|row| {
row.iter()
.map(|cell| {
cell.as_ref()
.map_or_else(|| "(null)".to_string(), Clone::clone)
})
.collect()
})
.collect();
// Column widths = max(header, all cells) per column.
let mut widths: Vec<usize> = header.iter().map(String::len).collect();
for row in &body {
for (i, cell) in row.iter().enumerate() {
if i < widths.len() && cell.chars().count() > widths[i] {
widths[i] = cell.chars().count();
}
}
}
let mut out: Vec<String> = Vec::with_capacity(body.len() + 3);
out.push(format!(" {}", join_padded(&header, &widths)));
out.push(format!(" {}", separator_row(&widths)));
if body.is_empty() {
out.push(" (no rows)".to_string());
} else {
for row in &body {
out.push(format!(" {}", join_padded(row, &widths)));
}
}
out
}
fn join_padded(cells: &[String], widths: &[usize]) -> String {
let mut s = String::new();
for (i, cell) in cells.iter().enumerate() {
if i > 0 {
s.push_str(" | ");
}
let w = widths.get(i).copied().unwrap_or(0);
s.push_str(cell);
let pad = w.saturating_sub(cell.chars().count());
for _ in 0..pad {
s.push(' ');
}
}
s
}
fn separator_row(widths: &[usize]) -> String {
let mut s = String::new();
for (i, w) in widths.iter().enumerate() {
if i > 0 {
s.push_str("-+-");
}
for _ in 0..*w {
s.push('-');
}
}
s
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1601,9 +1489,13 @@ mod tests {
description: Some(desc.clone()),
});
assert_eq!(app.current_table, Some(desc));
let last = app.output.back().unwrap();
// Last line is the column row of the structure summary.
assert!(last.text.contains("id"));
// Some line in the output buffer is the structure
// table row that contains `id` (followed by border
// chars on either side).
assert!(
app.output.iter().any(|l| l.text.contains("id")),
"expected `id` somewhere in structure output",
);
// Earlier line is the [ok] header.
assert!(app.output.iter().any(|l| l.text.starts_with("[ok]")));
}