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:
+12
-120
@@ -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]")));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user