From 5b5e08d852254d2eff808ab8c2a9a2079379f9bd Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 8 May 2026 09:06:02 +0000 Subject: [PATCH] ADR-0016 + Iter 5/6 follow-up: pretty table rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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>` 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. --- docs/adr/0016-pretty-table-rendering.md | 289 +++++++++ docs/adr/README.md | 1 + src/app.rs | 132 +--- src/db.rs | 15 +- src/lib.rs | 1 + src/output_render.rs | 595 ++++++++++++++++++ ..._tests__render_data_table_basic_shape.snap | 10 + ...table_empty_rows_shows_no_rows_marker.snap | 9 + ...render__tests__render_structure_basic.snap | 12 + ...__render_structure_with_relationships.snap | 12 + tests/walking_skeleton.rs | 14 +- 11 files changed, 965 insertions(+), 125 deletions(-) create mode 100644 docs/adr/0016-pretty-table-rendering.md create mode 100644 src/output_render.rs create mode 100644 src/snapshots/rdbms_playground__output_render__tests__render_data_table_basic_shape.snap create mode 100644 src/snapshots/rdbms_playground__output_render__tests__render_data_table_empty_rows_shows_no_rows_marker.snap create mode 100644 src/snapshots/rdbms_playground__output_render__tests__render_structure_basic.snap create mode 100644 src/snapshots/rdbms_playground__output_render__tests__render_structure_with_relationships.snap diff --git a/docs/adr/0016-pretty-table-rendering.md b/docs/adr/0016-pretty-table-rendering.md new file mode 100644 index 0000000..3a2b3d8 --- /dev/null +++ b/docs/adr/0016-pretty-table-rendering.md @@ -0,0 +1,289 @@ +# ADR-0016: Pretty table rendering for data and structure views + +## Status + +Accepted + +## Context + +The output panel today renders tabular content with hand-rolled +pipe-and-dash separators (`render_data_view` in `app.rs`) and +plain indented lines for table-structure summaries. Both work, +both are clearly placeholders. The result is functional but +unmistakably "first pass": a learner pulling up the app for the +first time sees a default-looking TUI rather than a polished +teaching tool. + +The walking skeleton's NFR-4 commits to "distinctive design" +— a reviewer should be able to identify the app from a +screenshot of any view — and NFR-5 requires colour to convey +information. Neither bar is met by the current rendering. + +This ADR commits to a focused upgrade of two specific output +surfaces: + +1. **Data tables.** Output of `show data `, `insert` / + `update` / `delete` auto-show, and the future query DSL. +2. **Table structure.** Output of `create table`, `add column`, + `add 1:n relationship`, and `show table `. + +Relationship visualization (two structures side-by-side with +an arrow indicating the relationship) is **deliberately out +of scope**; the structure renderer here is sized so the future +relationship view can compose two of them. + +The bigger UX project — V4's scrollable session log, smart +structure rendering tied to the items panel selection, +Markdown export — also remains out of scope. This ADR is a +narrow visual-quality pass on the existing output panel. + +## Decision + +### 1. Border style + +**Header-only Unicode borders.** Outer frame plus a header +underline; data rows are separated only by the column +dividers, no per-row horizontal rule. + +``` +┌────┬───────┬──────────────┐ +│ id │ Name │ Email │ +├────┼───────┼──────────────┤ +│ 1 │ Alice │ a@example.io │ +│ 2 │ Bob │ b@example.io │ +└────┴───────┴──────────────┘ +``` + +Box-drawing characters: `┌─┐│└─┘├┤┬┴┼`. UTF-8 only; we +already require UTF-8 throughout the app and crossterm +handles the encoding. No ASCII fallback in v1: if a +terminal can't render box-drawing, the user has bigger +problems than table aesthetics. + +Rejected: + +- **Full grid (per-row separators).** Visually noisy for + typical row counts (5–50). The header underline alone is + enough to anchor the eye. +- **Minimal (no outer frame).** Cleaner, but the lack of + containment makes the table blend into surrounding output + in a cluttered session. + +### 2. Column alignment + +- **Numeric** (`int`, `real`, `decimal`, `serial`): right-aligned. +- **Boolean**: left-aligned (`true` / `false` are roughly + equal width and read better left-aligned next to text). +- **Everything else** (`text`, `date`, `datetime`, `blob`, + `shortid`): left-aligned. + +Type is the source of truth: `DataResult` carries the +user-facing `Type` per column, looked up from the +column-metadata table (the same lookup `describe_table` +already does for `ColumnDescription.user_type`). The +existing `query_data` / `insert` / `update` / `delete` +paths in `db.rs` populate this alongside the column names +and rows so the renderer doesn't have to guess and the +display is deterministic regardless of cell content. + +`DataResult` extends to: + +```rust +pub struct DataResult { + pub table_name: String, + pub columns: Vec, + /// User-facing type per column. Same semantics as + /// `ColumnDescription.user_type`: always populated for + /// tables created through the DSL; `None` only for the + /// edge case of a foreign-attached database we did not + /// create. The renderer falls back to left-alignment + /// when `None`. + pub column_types: Vec>, + pub rows: Vec>>, +} +``` + +Structure rendering uses the same `Type` value directly +from `ColumnDescription.user_type`. + +### 3. NULL and special values + +- **NULL** renders as the literal text `(null)`. The + parentheses distinguish it from a string `"null"` in a + text column. +- **Newlines** in cell text are replaced by `↵` for display. + Preserves the visual single-row-per-record property; the + underlying CSV still round-trips faithfully. +- **Tab** characters in cell text are replaced by `→` for + the same reason. +- **Other control characters** (anything below `0x20` + except those handled above) replaced by `·` (middle dot). + +These substitutions are display-only. The underlying data +in `playground.db` and `data/.csv` is untouched; the +round-trip from `insert` → `show data` → CSV → `rebuild` +preserves the original characters. + +### 4. Width and truncation + +The renderer computes intrinsic column widths as +`max(header_width, max(cell_width))` per column, in +**Unicode codepoint count** (`chars().count()`). East Asian +wide characters and grapheme clusters are counted as one +codepoint each, which under-counts visual width slightly; +acceptable for v1 and consistent with how the rest of the +codebase counts widths today. + +If the rendered table is wider than the output panel, +ratatui's existing line-truncation handles the right edge. +**No proactive cell truncation in this iteration.** Adding +a per-cell ellipsis pass (`…` when a cell exceeds a budget) +is a clean follow-up once we have a working renderer to +measure against; doing it up front commits to a width +budget we can't yet justify. + +### 5. Structure view + +Each structure rendering emits one pretty table: + +``` +┌──────┬────────┬─────────────┐ +│ Name │ Type │ Constraints │ +├──────┼────────┼─────────────┤ +│ id │ serial │ PK │ +│ Name │ text │ NOT NULL │ +└──────┴────────┴─────────────┘ +``` + +Columns: + +- **Name** — the column name, verbatim. +- **Type** — `Type::keyword()` (e.g., `serial`, `text`, + `shortid`). Falls back to the SQLite-reported type if + metadata is missing — only happens for foreign-attached + databases we didn't create, not in normal use. +- **Constraints** — comma-separated list of declared + constraints in this priority order: `PK`, `NOT NULL`. + Future constraints (`UNIQUE`, `CHECK`, `DEFAULT` from C3) + append to the list as they land. Empty when no + constraints apply (rendered as a single space, which the + border still frames). + +Foreign-key references are **not** rendered inside the +constraint column. They live in a separate `References:` / +`Referenced by:` block below the structure table, formatted +as today's plain-text indented list. The rationale is that +relationships are a multi-table concept and deserve their +own visualization (the future "two structures + arrow" +layout); flattening them into a single column would +foreclose that design. + +The block-level layout under a single command: + +``` +[ok] create table Customers +┌──────┬────────┬─────────────┐ +│ Name │ Type │ Constraints │ +├──────┼────────┼─────────────┤ +│ id │ serial │ PK │ +│ Name │ text │ │ +└──────┴────────┴─────────────┘ +References: + cust_id → Orders.cust_id (cust_orders, on delete cascade, on update no_action) +Referenced by: + Orders.cust_id → id (cust_orders, on delete cascade, on update no_action) +``` + +The relationship sections retain today's plain-text format +to leave room for the future relationship-rendering ADR. + +### 6. Theme integration + +Theme colors apply to the box-drawing characters via the +existing output-panel rendering path (the lines go through +`OutputLine` with `OutputKind::System` styling). No +per-cell theming in this iteration — that's V4 territory +(NFR-5 mentions "query result types" colouring, which +implies per-cell awareness; we set that up but don't ship +it yet). + +The chosen border characters render legibly on both light +and dark backgrounds in standard terminals. NFR-7 is +inherited rather than newly designed for. + +### 7. Implementation + +**Hand-rolled.** Consistent with the project's pattern of +narrow hand-rolled writers (CSV serializer, YAML writer, +history.log appender). A crate (`tabled`, `comfy-table`) +would save ~150 lines but cost ~70–150 KB of binary, +remove direct control over the substitutions in §3, and +add a dependency that's worth carrying only if our needs +later outgrow what we hand-roll. + +The renderer lives in a new module `src/output_render.rs` +(distinct from `src/ui.rs`, which is the ratatui Frame +renderer for the whole TUI layout). Public surface: + +```rust +pub fn render_data_table(data: &DataResult) -> Vec; +pub fn render_structure(desc: &TableDescription) -> Vec; +``` + +Each returns a `Vec`, one display row per element +— matches the existing `OutputLine`-per-line discipline so +scroll math remains accurate. + +Internal helpers (border characters, padding, alignment +detection, control-character substitution) are private to +the module. + +### 8. Testing + +- **Unit tests** in `output_render.rs` covering: each + alignment rule, NULL rendering, control-character + substitution, the structure layout for a representative + schema, and width computation under multibyte input. +- **Snapshot tests** via `insta` for the canonical examples + (a 2-column data table, a 3-column structure with PK + + NOT NULL, a structure with relationships). The snapshots + pin the exact rendered output and catch unintended drift. +- **Existing integration tests** (Tier-3) continue to + exercise the call sites; their assertions on output text + may need updates where they currently match + pipe-separator format. + +### 9. Out of scope + +- **OOS-1.** Relationship visualization (two structures + + arrow). Its own ADR; will compose `render_structure`. +- **OOS-2.** V4's scrollable session log + Markdown export. +- **OOS-3.** Per-cell theming for query-result type + highlighting (NFR-5 partial — sets up the architecture, + defers the colours until the query DSL ships). +- **OOS-4.** Cell-level truncation with ellipsis. +- **OOS-5.** ASCII fallback for terminals that can't + render box-drawing characters. + +## Consequences + +- The two most-frequented output surfaces (data results, + structure listings) gain a polished, distinctive look + matching NFR-4. +- The renderer module is a stable dependency for future + work: relationship visualization composes + `render_structure`; query-result rendering reuses + `render_data_table` (with eventual type-aware + alignment); V4's session log adds chrome around + whatever this module produces. +- `DataResult` gains a `column_types` field; the four + call sites in `db.rs` (`query_data`, `insert`, `update`, + `delete`) populate it from the same metadata table + `describe_table` already consults. No new query paths. +- Light/dark theme legibility is inherited from the + current output-panel styling. Per-cell theming is + deliberately deferred. +- Shipping this *before* the query-DSL ADR means the new + command's results land already looking polished, which + is the right order: pedagogy is worse when the + visualisation lags the feature. diff --git a/docs/adr/README.md b/docs/adr/README.md index dd60602..4476cb6 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -21,3 +21,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0013 — Relationships, naming, and the rebuild-table strategy](0013-relationships-and-rebuild-table.md) - [ADR-0014 — Data operations, value literals, and the auto-show pattern](0014-data-operations-and-value-model.md) - [ADR-0015 — Project storage runtime](0015-project-storage-runtime.md) +- [ADR-0016 — Pretty table rendering for data and structure views](0016-pretty-table-rendering.md) diff --git a/src/app.rs b/src/app.rs index ccd4b84..b015b0e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 { - let header = data.columns.clone(); - let body: Vec> = 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 = 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 = 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]"))); } diff --git a/src/db.rs b/src/db.rs index ee3d779..cb2c1c9 100644 --- a/src/db.rs +++ b/src/db.rs @@ -134,12 +134,20 @@ pub enum DbError { Io(String), } -/// Result of a query / show-data call (schema-less display rows). -/// `None` cells render as NULL; `Some(s)` renders as the string. +/// Result of a query / show-data call. +/// +/// `None` cells render as NULL; `Some(s)` renders as the +/// string. `column_types` carries the user-facing type per +/// column (per ADR-0016 §2): the renderer uses it for +/// alignment, and future work uses it for type-aware cell +/// styling. `None` only for the edge case of a +/// foreign-attached database we did not create — not +/// achievable in normal use. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DataResult { pub table_name: String, pub columns: Vec, + pub column_types: Vec>, pub rows: Vec>>, } @@ -1970,6 +1978,7 @@ fn query_rows_by_rowid( return Ok(DataResult { table_name: table.to_string(), columns: column_names, + column_types, rows: Vec::new(), }); } @@ -2016,6 +2025,7 @@ fn query_rows_by_rowid( Ok(DataResult { table_name: table.to_string(), columns: column_names, + column_types, rows, }) } @@ -2355,6 +2365,7 @@ fn do_query_data(conn: &Connection, table: &str) -> Result Ok(DataResult { table_name: table.to_string(), columns: column_names, + column_types, rows, }) } diff --git a/src/lib.rs b/src/lib.rs index de3fb9f..a097435 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,6 +14,7 @@ pub mod dsl; pub mod event; pub mod logging; pub mod mode; +pub mod output_render; pub mod persistence; pub mod project; pub mod runtime; diff --git a/src/output_render.rs b/src/output_render.rs new file mode 100644 index 0000000..b3fed8f --- /dev/null +++ b/src/output_render.rs @@ -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` — 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 { + let header_cells: Vec = data.columns.clone(); + let alignments: Vec = data + .column_types + .iter() + .map(|t| alignment_for(*t)) + .collect(); + + let body: Vec> = 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 (``), 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 { + let mut out: Vec = Vec::new(); + out.push(desc.name.clone()); + + let header_cells = vec![ + "Name".to_string(), + "Type".to_string(), + "Constraints".to_string(), + ]; + let body: Vec> = 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) -> 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], + alignments: &[Alignment], +) -> Vec { + 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 = 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 = 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}"); + } +} diff --git a/src/snapshots/rdbms_playground__output_render__tests__render_data_table_basic_shape.snap b/src/snapshots/rdbms_playground__output_render__tests__render_data_table_basic_shape.snap new file mode 100644 index 0000000..e5f81c2 --- /dev/null +++ b/src/snapshots/rdbms_playground__output_render__tests__render_data_table_basic_shape.snap @@ -0,0 +1,10 @@ +--- +source: src/output_render.rs +expression: "render_data_table(&data).join(\"\\n\")" +--- +┌────┬───────┬──────────────┐ +│ id │ Name │ Email │ +├────┼───────┼──────────────┤ +│ 1 │ Alice │ a@example.io │ +│ 2 │ Bob │ b@example.io │ +└────┴───────┴──────────────┘ diff --git a/src/snapshots/rdbms_playground__output_render__tests__render_data_table_empty_rows_shows_no_rows_marker.snap b/src/snapshots/rdbms_playground__output_render__tests__render_data_table_empty_rows_shows_no_rows_marker.snap new file mode 100644 index 0000000..364166b --- /dev/null +++ b/src/snapshots/rdbms_playground__output_render__tests__render_data_table_empty_rows_shows_no_rows_marker.snap @@ -0,0 +1,9 @@ +--- +source: src/output_render.rs +expression: out +--- +┌───────────┬──────┐ +│ id │ Name │ +├───────────┼──────┤ +│ (no rows) │ │ +└───────────┴──────┘ diff --git a/src/snapshots/rdbms_playground__output_render__tests__render_structure_basic.snap b/src/snapshots/rdbms_playground__output_render__tests__render_structure_basic.snap new file mode 100644 index 0000000..45944ce --- /dev/null +++ b/src/snapshots/rdbms_playground__output_render__tests__render_structure_basic.snap @@ -0,0 +1,12 @@ +--- +source: src/output_render.rs +expression: "render_structure(&desc).join(\"\\n\")" +--- +Customers +┌───────┬────────┬─────────────┐ +│ Name │ Type │ Constraints │ +├───────┼────────┼─────────────┤ +│ id │ serial │ PK │ +│ Name │ text │ NOT NULL │ +│ Email │ text │ │ +└───────┴────────┴─────────────┘ diff --git a/src/snapshots/rdbms_playground__output_render__tests__render_structure_with_relationships.snap b/src/snapshots/rdbms_playground__output_render__tests__render_structure_with_relationships.snap new file mode 100644 index 0000000..f0d764c --- /dev/null +++ b/src/snapshots/rdbms_playground__output_render__tests__render_structure_with_relationships.snap @@ -0,0 +1,12 @@ +--- +source: src/output_render.rs +expression: out +--- +Customers +┌──────┬────────┬─────────────┐ +│ Name │ Type │ Constraints │ +├──────┼────────┼─────────────┤ +│ id │ serial │ PK │ +└──────┴────────┴─────────────┘ +Referenced by: + Orders.cust_id → id (cust_orders, on delete cascade, on update no action) diff --git a/tests/walking_skeleton.rs b/tests/walking_skeleton.rs index 2134982..ea59c9d 100644 --- a/tests/walking_skeleton.rs +++ b/tests/walking_skeleton.rs @@ -298,9 +298,12 @@ fn create_table_flow_updates_tables_list_and_structure_view() { rendered.contains("[ok] create table Customers"), "output should confirm success:\n{rendered}" ); + // The structure table renders one line per column; the + // `id` row shows both the name and its `serial` type + // separated by box-drawing characters. assert!( - rendered.contains("id serial"), - "output should show the structure with the user-facing type:\n{rendered}" + rendered.lines().any(|l| l.contains("id") && l.contains("serial")), + "output should show the id/serial column row:\n{rendered}" ); } @@ -339,7 +342,10 @@ fn add_column_flow_updates_structure_view() { }); assert_eq!(app.current_table, Some(updated)); let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); - assert!(rendered.contains("Name text")); + assert!( + rendered.lines().any(|l| l.contains("Name") && l.contains("text")), + "expected the Name/text column row:\n{rendered}", + ); } #[test] @@ -497,6 +503,7 @@ fn insert_flow_emits_action_and_renders_data() { 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![vec![Some("1".to_string()), Some("Alice".to_string())]], }; app.update(AppEvent::DslInsertSucceeded { @@ -545,6 +552,7 @@ fn show_data_for_empty_table_renders_placeholder() { 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(), }; app.update(AppEvent::DslDataSucceeded {