Files
rdbms-playground/src/output_render.rs
T
claude@clouddev1 0a343036d8 feat: compound-FK bus routing + complete V1 relationship visualization (ADR-0044)
Completes requirement V1. A compound (multi-column) FK now routes a
bus connector — each paired endpoint's stub merges into a shared
vertical channel that splits to the other side — plus an explicit
"(a, b) ▶ P.(x, y)" pairing line; the bus generalises the single-column
jog (reproducing it exactly, so prior snapshots are unchanged).
Self-referential FKs render as two same-named boxes.

- output_render.rs: gutter_seg routes all endpoint pairs via a
  junction() bus; pairing line for compound FKs; compound, self-ref,
  and compound-from-data (build_diagram_table glue) tests + snapshots
- compound_fk.rs: worker test that show_relationship carries both
  paired column lists into the diagram payload
- db.rs: document do_show_one's now-app-superseded relationship prose
  branch (retained as a worker-API/text fallback; could back a future
  non-visual display option, cf. ADR-0044 OOS-7)

Second /runda pass over the implementation: confirmed ADR-compliance,
UTF-8/byte-range safety, and edge-case routing. The ADR §3 last-resort
helper line was considered and rejected (vertical fallback + ratatui
truncation cover all realistic cases). ADR-0044 marked implemented;
requirements.md V1 -> [x].

Full suite 2207 pass / 0 fail / 1 ignored; clippy nursery clean.
2026-06-10 10:17:09 +00:00

1940 lines
68 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 std::collections::HashSet;
use crate::app::{OutputKind, OutputLine, OutputSpan, OutputStyleClass};
use crate::db::{ColumnDescription, DataResult, ExplainRow, QueryPlan, TableDescription};
use crate::dsl::Type;
use crate::mode::Mode;
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).
/// Display a relationship-endpoint column list (ADR-0043): the bare
/// column for a single-column FK, `(a, b)` for a compound one.
fn cols_disp(cols: &[String]) -> String {
if cols.len() == 1 {
cols[0].clone()
} else {
format!("({})", cols.join(", "))
}
}
#[must_use]
pub fn render_structure(desc: &TableDescription) -> Vec<String> {
let mut out = structure_box_lines(desc);
out.extend(relationship_prose_lines(desc));
out.extend(index_lines(desc));
out.extend(constraint_lines(desc));
out
}
/// The table-name header line + the box-drawn column / type /
/// constraint table. Shared by the prose [`render_structure`] and the
/// diagram [`render_structure_with_diagrams`] (ADR-0044).
fn structure_box_lines(desc: &TableDescription) -> Vec<String> {
let mut out: Vec<String> = vec![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();
// Every cell is a keyword/text string, so left-align throughout.
let alignments = vec![Alignment::Left, Alignment::Left, Alignment::Left];
out.extend(render_table(&header_cells, &body, &alignments));
out
}
/// The `References:` / `Referenced by:` prose blocks (ADR-0016 §5),
/// retained for the incidental DDL echoes (ADR-0044 §1).
fn relationship_prose_lines(desc: &TableDescription) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
if !desc.outbound_relationships.is_empty() {
out.push("References:".to_string());
for r in &desc.outbound_relationships {
out.push(format!(
" {}{}.{} ({}, on delete {}, on update {})",
cols_disp(&r.local_columns),
r.other_table,
cols_disp(&r.other_columns),
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,
cols_disp(&r.other_columns),
cols_disp(&r.local_columns),
r.name,
r.on_delete,
r.on_update,
));
}
}
out
}
/// Indexes section (ADR-0025), only when the table carries a
/// user-created index. A UNIQUE index is marked `[unique]` (ADR-0035
/// §4d).
fn index_lines(desc: &TableDescription) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
if !desc.indexes.is_empty() {
out.push("Indexes:".to_string());
for index in &desc.indexes {
let unique = if index.unique { " [unique]" } else { "" };
out.push(format!(
" {} ({}){unique}",
index.name,
index.columns.join(", "),
));
}
}
out
}
/// Table-level constraints (ADR-0035 §4i b): composite `UNIQUE (a, b)`
/// and table `CHECK (…)`. Column-level constraints already show in the
/// per-column "Constraints" column; this is the multi-column / named
/// set, each with its addressable name where it has one.
fn constraint_lines(desc: &TableDescription) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
if !desc.unique_constraints.is_empty() || !desc.check_constraints.is_empty() {
out.push("Table constraints:".to_string());
for cols in &desc.unique_constraints {
out.push(format!(
" {}: unique ({})",
crate::db::unique_constraint_name(cols),
cols.join(", ")
));
}
for chk in &desc.check_constraints {
match &chk.name {
Some(name) => out.push(format!(" check {name} ({})", chk.expr)),
None => out.push(format!(" check ({})", chk.expr)),
}
}
}
out
}
/// The plan annotation taxonomy (ADR-0028 §4).
///
/// A `detail` string is classified by the **first** entry
/// whose marker substring it contains; the marker's byte range
/// is also the run that carries the category colour (ADR-0028
/// §6 — "only the category-bearing keywords"). Order matters:
/// the automatic-index markers come before the plain-index and
/// `SCAN` ones so a `SEARCH … USING AUTOMATIC … INDEX` is not
/// mis-read as a plain index search or a full scan; `SCAN`
/// comes last so a covering-index scan (`SCAN … USING INDEX …`)
/// classifies on its index, not on the `SCAN`.
///
/// A `detail` matching no marker renders neutral — the engine's
/// plan vocabulary may grow (ADR-0028 §4).
const PLAN_TAXONOMY: &[(&str, OutputStyleClass)] = &[
("USING AUTOMATIC COVERING INDEX", OutputStyleClass::AutomaticIndex),
("USING AUTOMATIC INDEX", OutputStyleClass::AutomaticIndex),
("USING COVERING INDEX", OutputStyleClass::Efficient),
("USING INTEGER PRIMARY KEY", OutputStyleClass::Efficient),
("USING PRIMARY KEY", OutputStyleClass::Efficient),
("USING INDEX", OutputStyleClass::Efficient),
("USE TEMP B-TREE", OutputStyleClass::Expensive),
("SCAN", OutputStyleClass::Expensive),
];
/// The short "you should add an index here" tag appended to an
/// automatic-index plan node (ADR-0028 §6 — the distinct
/// marker that makes it read as advice, not merely "slow").
const AUTO_INDEX_TAG: &str = " ← add an index?";
/// Render a captured query plan as a box-drawing tree
/// (ADR-0028 §3/§4).
///
/// The standard-SQL display form of the explained statement is
/// shown first; the plan tree follows, built from each row's
/// `id` / `parent` links. Node text is the engine's `detail`
/// string **verbatim** — nothing is reworded — but each
/// node's category-bearing keywords carry a semantic colour
/// from the annotation taxonomy.
///
/// Unlike the `Vec<String>`-returning renderers above this
/// returns ready-built `OutputLine`s, because a plan line
/// carries per-span styling (ADR-0028 §5). Each line is
/// stamped with `mode` for the submission-mode tag.
#[must_use]
pub fn render_explain_plan(plan: &QueryPlan, mode: Mode) -> Vec<OutputLine> {
let mut out: Vec<OutputLine> = Vec::with_capacity(plan.rows.len() + 1);
// The display SQL is wholly neutral — no category keywords.
out.push(neutral_plan_line(plan.display_sql.clone(), mode));
// `emitted` guards against a malformed plan with a cyclic
// or self-referential `parent` link — every node is drawn
// at most once.
let mut emitted: HashSet<i64> = HashSet::new();
render_plan_subtree(&plan.rows, 0, "", &mut out, &mut emitted, mode);
out
}
/// Append the subtree rooted at `parent` (`0` = top level) to
/// `out`, drawing box-drawing connectors. `prefix` is the
/// indent accumulated from ancestor levels.
fn render_plan_subtree(
rows: &[ExplainRow],
parent: i64,
prefix: &str,
out: &mut Vec<OutputLine>,
emitted: &mut HashSet<i64>,
mode: Mode,
) {
let children: Vec<&ExplainRow> =
rows.iter().filter(|r| r.parent == parent).collect();
let last_idx = children.len().saturating_sub(1);
for (idx, row) in children.iter().enumerate() {
if !emitted.insert(row.id) {
continue;
}
let is_last = idx == last_idx;
let connector = if is_last { "└─ " } else { "├─ " };
out.push(plan_node_line(prefix, connector, &row.detail, mode));
let child_prefix =
format!("{prefix}{}", if is_last { " " } else { "" });
render_plan_subtree(rows, row.id, &child_prefix, out, emitted, mode);
}
}
/// Classify `detail` against the taxonomy, returning the byte
/// offset + length of the matched marker and its colour class.
/// `None` when no marker matches (a neutral node).
fn classify_detail(detail: &str) -> Option<(usize, usize, OutputStyleClass)> {
PLAN_TAXONOMY
.iter()
.find_map(|(marker, class)| detail.find(marker).map(|off| (off, marker.len(), *class)))
}
/// Build one plan-tree node line: `{prefix}{connector}{detail}`
/// with the connectors / prefix / table & index names neutral
/// and only the category-bearing keyword run coloured
/// (ADR-0028 §6). An automatic-index node also gets the
/// "add an index?" advice tag.
fn plan_node_line(prefix: &str, connector: &str, detail: &str, mode: Mode) -> OutputLine {
let mut text = format!("{prefix}{connector}{detail}");
let detail_start = prefix.len() + connector.len();
let mut runs: Vec<OutputSpan> = Vec::new();
match classify_detail(detail) {
Some((offset, len, class)) => {
let marker_start = detail_start + offset;
let marker_end = marker_start + len;
push_neutral(&mut runs, 0, marker_start);
runs.push(OutputSpan {
byte_range: (marker_start, marker_end),
class,
});
push_neutral(&mut runs, marker_end, text.len());
if class == OutputStyleClass::AutomaticIndex {
let tag_start = text.len();
text.push_str(AUTO_INDEX_TAG);
runs.push(OutputSpan {
byte_range: (tag_start, text.len()),
class,
});
}
}
None => push_neutral(&mut runs, 0, text.len()),
}
OutputLine::styled(text, OutputKind::System, mode, runs)
}
/// A wholly-neutral plan line — used for the display-SQL line,
/// which carries no category keywords. Styled (rather than
/// plain) so it renders in the neutral foreground colour
/// instead of the whole-line `System` styling (ADR-0028 §6).
fn neutral_plan_line(text: String, mode: Mode) -> OutputLine {
let mut runs = Vec::new();
push_neutral(&mut runs, 0, text.len());
OutputLine::styled(text, OutputKind::System, mode, runs)
}
/// Push a `Neutral` span covering `[start, end)`, skipping the
/// empty case.
fn push_neutral(runs: &mut Vec<OutputSpan>, start: usize, end: usize) {
if end > start {
runs.push(OutputSpan {
byte_range: (start, end),
class: OutputStyleClass::Neutral,
});
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Alignment {
Left,
Right,
}
/// Per-column alignment hint for [`render_diagnostic_table`].
#[must_use]
pub const fn numeric_alignment_for(ty: Type) -> Alignment {
alignment_for(Some(ty))
}
/// Render an arbitrary bordered table from headers + rows +
/// per-column alignments. Used for change-column diagnostic
/// output (ADR-0017 §7) — anything tabular goes through the
/// pretty-table renderer.
///
/// Cell contents are sanitised the same way as
/// [`render_data_table`]: newlines and control characters
/// become visible markers (display-only).
#[must_use]
pub fn render_diagnostic_table(
headers: &[String],
rows: &[Vec<String>],
alignments: &[Alignment],
) -> Vec<String> {
let body: Vec<Vec<String>> = rows
.iter()
.map(|r| r.iter().map(|c| sanitize_cell(c)).collect())
.collect();
render_table(headers, &body, alignments)
}
/// 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<String> = Vec::new();
if c.primary_key {
parts.push("PK".to_string());
}
if c.notnull {
parts.push("NOT NULL".to_string());
}
// ADR-0029: a PK column's implicit uniqueness is already
// conveyed by `PK`; `unique` is only set for non-PK columns.
if c.unique {
parts.push("UNIQUE".to_string());
}
if let Some(default) = &c.default {
parts.push(format!("DEFAULT {default}"));
}
if let Some(check) = &c.check {
parts.push(format!("CHECK ({check})"));
}
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,
/// The rule **under a full-width title row** that introduces the
/// body's column split (ADR-0044 §2.1): `├──┬──┤`.
TitleUnderline,
}
fn border_row(widths: &[usize], kind: BorderRow) -> String {
let (left, mid, right) = match kind {
BorderRow::Top => ('┌', '┬', '┐'),
BorderRow::HeaderUnderline => ('├', '┼', '┤'),
BorderRow::Bottom => ('└', '┴', '┘'),
BorderRow::TitleUnderline => ('├', '┬', '┤'),
};
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
}
// ── Relationship visualization (ADR-0044) ──────────────────────────
//
// A relationship diagram draws two table boxes joined by a connector:
// child (FK holder) on the left, parent (referenced) on the right, the
// arrow pointing child → parent, cardinality `n … 1`. The renderer is
// decoupled from the db structs — `build_diagram_table` adapts a
// `TableDescription`, and the layout/routing logic is unit-testable on
// plain `DiagramTable`s. Output is styled `OutputLine`s (ADR-0044 §5)
// composed from per-box styled segments. Side-by-side when the width
// allows, vertical-stack fallback otherwise (§3).
/// One column as it appears inside a diagram box.
pub(crate) struct DiagramCol {
/// Column name.
pub name: String,
/// Type keyword; `None` in a compact box (`show table`) where only
/// the participating column name is shown.
pub type_text: Option<String>,
/// Whether the column is part of its table's primary key.
pub pk: bool,
/// Whether the column is an endpoint of the relationship drawn.
pub endpoint: bool,
}
/// A table as drawn in a relationship diagram.
pub(crate) struct DiagramTable {
/// Table name (the box's bold title row).
pub name: String,
/// Columns shown in the box (all for a full box, only the
/// participating ones for a compact box).
pub cols: Vec<DiagramCol>,
}
/// The horizontal gutter between two side-by-side boxes.
const GUTTER: usize = 18;
/// A styled line under construction: text plus its per-span runs
/// (ADR-0028 §5 / ADR-0044 §5). Segments compose by concatenation
/// with run offsets shifted, so two boxes + a gutter merge onto one
/// line without losing styling.
#[derive(Clone)]
struct Seg {
text: String,
runs: Vec<OutputSpan>,
}
impl Seg {
const fn new() -> Self {
Self {
text: String::new(),
runs: Vec::new(),
}
}
/// Append `s` as a run of `class`. Empty strings are ignored.
fn push(&mut self, s: &str, class: OutputStyleClass) {
if s.is_empty() {
return;
}
let start = self.text.len();
self.text.push_str(s);
self.runs.push(OutputSpan {
byte_range: (start, self.text.len()),
class,
});
}
/// Append `n` spaces of `class` (padding).
fn pad(&mut self, n: usize, class: OutputStyleClass) {
if n > 0 {
self.push(&" ".repeat(n), class);
}
}
/// Concatenate another segment, shifting its run offsets.
fn append(&mut self, other: &Self) {
let base = self.text.len();
self.text.push_str(&other.text);
for r in &other.runs {
self.runs.push(OutputSpan {
byte_range: (r.byte_range.0 + base, r.byte_range.1 + base),
class: r.class,
});
}
}
fn into_line(self, mode: Mode) -> OutputLine {
OutputLine::styled(self.text, OutputKind::System, mode, self.runs)
}
}
/// A laid-out box: equal-width styled lines plus the line index of
/// each endpoint column (where a connector attaches).
struct BoxLayout {
segs: Vec<Seg>,
width: usize,
endpoint_rows: Vec<usize>,
}
use crate::app::OutputStyleClass::{
DiagramCardinality as Card, DiagramConnector as Conn, DiagramKey as Key,
DiagramTableName as TitleClass, Neutral,
};
/// A whole-line connector segment (borders / rules) of `text`.
fn conn_line(text: String) -> Seg {
let mut seg = Seg::new();
seg.push(&text, Conn);
seg
}
/// Lay out one table box: a full-width bold title row over a 1- or
/// 2-column body (label + optional type), styled per ADR-0044 §5.
fn render_box(t: &DiagramTable) -> BoxLayout {
let has_types = t.cols.iter().any(|c| c.type_text.is_some());
// Per-column label display width = name + ` (PK)` + ` ●` markers.
let label_w = t
.cols
.iter()
.map(|c| {
cell_width(&c.name)
+ usize::from(c.pk) * 5 // " (PK)"
+ usize::from(c.endpoint) * 2 // " ●"
})
.max()
.unwrap_or(0);
let mut widths = vec![label_w];
if has_types {
let type_w = t
.cols
.iter()
.map(|c| cell_width(c.type_text.as_deref().unwrap_or("")))
.max()
.unwrap_or(0);
widths.push(type_w);
}
// Inner width between the side borders == a body border's width
// minus the two corners: Σ(w+2) over columns + (ncols1) dividers.
let ncols = widths.len();
let body_inner: usize = widths.iter().map(|w| w + 2).sum::<usize>() + (ncols - 1);
// The title needs `name` + a space each side; if that exceeds the
// body width, widen the (first) label column so every row aligns.
let title_min = cell_width(&t.name) + 2;
let inner = if title_min > body_inner {
widths[0] += title_min - body_inner;
title_min
} else {
body_inner
};
let mut segs: Vec<Seg> = Vec::with_capacity(t.cols.len() + 4);
segs.push(conn_line(h_border(inner, '┌', '┐'))); // top: title spans
segs.push(title_seg(&t.name, inner));
segs.push(conn_line(border_row(&widths, BorderRow::TitleUnderline)));
for c in &t.cols {
// Use the (possibly title-widened) label column width so the
// body cells pad to the box width even when the name is wider.
segs.push(body_seg(c, widths[0], has_types.then(|| widths[1])));
}
segs.push(conn_line(border_row(&widths, BorderRow::Bottom)));
// Body row j sits at line index 3 (0=top, 1=title, 2=rule, 3+ body).
let endpoint_rows = t
.cols
.iter()
.enumerate()
.filter(|(_, c)| c.endpoint)
.map(|(j, _)| 3 + j)
.collect();
BoxLayout {
segs,
width: inner + 2,
endpoint_rows,
}
}
/// A plain horizontal border of `inner` dashes between two corners.
fn h_border(inner: usize, left: char, right: char) -> String {
let mut s = String::new();
s.push(left);
for _ in 0..inner {
s.push('─');
}
s.push(right);
s
}
/// The full-width title row `│ name │` (name in the
/// stand-out table-name style), padded to `inner`.
fn title_seg(name: &str, inner: usize) -> Seg {
let mut seg = Seg::new();
seg.push("", Conn);
seg.push(" ", Conn);
seg.push(name, TitleClass);
seg.pad(inner.saturating_sub(1 + cell_width(name)), Conn);
seg.push("", Conn);
seg
}
/// One body row: `│ name (PK) ● │ type │`, markers in the key style,
/// reproducing the byte layout of [`content_row`] so widths line up.
fn body_seg(c: &DiagramCol, label_w: usize, type_w: Option<usize>) -> Seg {
let mut seg = Seg::new();
seg.push("", Conn);
// Label cell.
seg.push(" ", Conn);
let mut used = cell_width(&c.name);
seg.push(&c.name, Neutral);
if c.pk {
seg.push(" (PK)", Key);
used += 5;
}
if c.endpoint {
seg.push("", Key);
used += 2;
}
seg.pad(label_w.saturating_sub(used), Neutral);
seg.push(" ", Conn);
seg.push("", Conn);
// Type cell (full box only).
if let Some(tw) = type_w {
let t = c.type_text.clone().unwrap_or_default();
seg.push(" ", Conn);
seg.push(&t, Neutral);
seg.pad(tw.saturating_sub(cell_width(&t)), Neutral);
seg.push(" ", Conn);
seg.push("", Conn);
}
seg
}
/// The box-drawing glyph for a bus junction given which directions it
/// connects (up / down the bus, a child stub from the left, a parent
/// stub to the right).
const fn junction(up: bool, down: bool, left: bool, right: bool) -> char {
match (up, down, left, right) {
(true, true, true, true) => '┼',
(true, true, true, false) => '┤',
(true, true, false, true) => '├',
(true, true, false, false) => '│',
(true, false, true, true) => '┴',
(true, false, true, false) => '┘',
(true, false, false, true) => '└',
(false, true, true, true) => '┬',
(false, true, true, false) => '┐',
(false, true, false, true) => '┌',
(false, false, true, _) | (false, false, false, true) => '─',
_ => '│',
}
}
/// One row of the gutter as a styled segment, routing **all** endpoint
/// pairs (ADR-0044 §2.3 / §2.4): each child endpoint row gets an `n`
/// stub from the left, each parent endpoint row a `1` stub + `▶` to the
/// right, both merging into a shared vertical bus at the centre. For a
/// single-column FK this reduces to the simple jogged connector.
fn gutter_seg(i: usize, child_rows: &[usize], parent_rows: &[usize], w: usize) -> Seg {
let mut cells = vec![' '; w];
let vc = w / 2;
let on_child = child_rows.contains(&i);
let on_parent = parent_rows.contains(&i);
if on_child {
for c in &mut cells[1..vc] {
*c = '─';
}
cells[0] = 'n';
}
if on_parent {
for c in &mut cells[vc + 1..w - 1] {
*c = '─';
}
cells[w - 2] = '1';
cells[w - 1] = '▶';
}
// The vertical bus spans the full range of endpoint rows.
let bounds = child_rows
.iter()
.chain(parent_rows)
.copied()
.fold(None, |acc: Option<(usize, usize)>, r| {
Some(acc.map_or((r, r), |(lo, hi)| (lo.min(r), hi.max(r))))
});
if let Some((top, bot)) = bounds
&& i >= top
&& i <= bot
{
cells[vc] = junction(i > top, i < bot, on_child, on_parent);
}
let mut seg = Seg::new();
for ch in cells {
let class = if ch == 'n' || ch == '1' { Card } else { Conn };
seg.push(&ch.to_string(), class);
}
seg
}
/// The `on delete … · on update …` label below a diagram (muted).
fn action_seg(on_delete: &str, on_update: &str) -> Seg {
let mut seg = Seg::new();
seg.push(
&format!(" on delete {on_delete} · on update {on_update}"),
crate::app::OutputStyleClass::Hint,
);
seg
}
/// A blank styled line of `w` spaces (fills a shorter box's side).
fn blank_seg(w: usize) -> Seg {
let mut seg = Seg::new();
seg.pad(w, Neutral);
seg
}
/// The explicit pairing line for a compound FK (ADR-0044 §2.4).
fn pairing_seg(text: &str) -> Seg {
let mut seg = Seg::new();
seg.push(&format!(" {text}"), Neutral);
seg
}
/// Two boxes side by side, joined by the bus connector (ADR-0044
/// §2.3/§2.4), with an optional compound-FK pairing line and the
/// actions line beneath.
fn compose_side_by_side(
cb: &BoxLayout,
pb: &BoxLayout,
pairing: Option<&str>,
on_delete: &str,
on_update: &str,
) -> Vec<Seg> {
let height = cb.segs.len().max(pb.segs.len());
let blank_l = blank_seg(cb.width);
let blank_r = blank_seg(pb.width);
let mut out: Vec<Seg> = Vec::with_capacity(height + 2);
for i in 0..height {
let mut seg = Seg::new();
seg.append(cb.segs.get(i).unwrap_or(&blank_l));
seg.append(&gutter_seg(i, &cb.endpoint_rows, &pb.endpoint_rows, GUTTER));
seg.append(pb.segs.get(i).unwrap_or(&blank_r));
out.push(seg);
}
if let Some(p) = pairing {
out.push(pairing_seg(p));
}
out.push(action_seg(on_delete, on_update));
out
}
/// Vertical-stack fallback for narrow terminals (ADR-0044 §3): child
/// box, a downward connector carrying the actions, then the parent box,
/// and the optional pairing line.
fn compose_vertical(
cb: &BoxLayout,
pb: &BoxLayout,
pairing: Option<&str>,
on_delete: &str,
on_update: &str,
) -> Vec<Seg> {
let indent = " ";
let mut out: Vec<Seg> = cb.segs.clone();
let mut a = Seg::new();
a.push(indent, Conn);
a.push("│ n", Card);
a.push(
&format!(" on delete {on_delete}"),
crate::app::OutputStyleClass::Hint,
);
out.push(a);
let mut b = Seg::new();
b.push(indent, Conn);
b.push("▼ 1", Card);
b.push(
&format!(" on update {on_update}"),
crate::app::OutputStyleClass::Hint,
);
out.push(b);
out.extend(pb.segs.clone());
if let Some(p) = pairing {
out.push(pairing_seg(p));
}
out
}
/// Lay out a relationship between two `DiagramTable`s at `width`,
/// choosing side-by-side or the vertical fallback (ADR-0044 §3). A
/// compound FK (>1 paired column) also gets an explicit pairing line.
fn render_relationship_layout(
child: &DiagramTable,
parent: &DiagramTable,
on_delete: &str,
on_update: &str,
width: usize,
) -> Vec<Seg> {
let cb = render_box(child);
let pb = render_box(parent);
let child_cols: Vec<&str> = child
.cols
.iter()
.filter(|c| c.endpoint)
.map(|c| c.name.as_str())
.collect();
let parent_cols: Vec<&str> = parent
.cols
.iter()
.filter(|c| c.endpoint)
.map(|c| c.name.as_str())
.collect();
let pairing = (child_cols.len() > 1).then(|| {
format!(
"({}) ▶ {}.({})",
child_cols.join(", "),
parent.name,
parent_cols.join(", "),
)
});
if cb.width + GUTTER + pb.width <= width.max(1) {
compose_side_by_side(&cb, &pb, pairing.as_deref(), on_delete, on_update)
} else {
compose_vertical(&cb, &pb, pairing.as_deref(), on_delete, on_update)
}
}
/// Build a full-box `DiagramTable` from a table description, marking
/// the columns that are this relationship's endpoints.
fn build_diagram_table(desc: &TableDescription, endpoint_cols: &[String]) -> DiagramTable {
DiagramTable {
name: desc.name.clone(),
cols: desc
.columns
.iter()
.map(|c| DiagramCol {
name: c.name.clone(),
type_text: Some(type_display(c)),
pk: c.primary_key,
endpoint: endpoint_cols.iter().any(|e| e == &c.name),
})
.collect(),
}
}
/// Render one relationship as a styled diagram (ADR-0044): the full
/// `show relationship <name>` view, both tables as full structure
/// boxes joined by a connector, laid out for `width`.
pub(crate) fn render_relationship_diagram(
data: &crate::db::RelationshipDiagramData,
width: u16,
mode: Mode,
) -> Vec<OutputLine> {
let child = build_diagram_table(&data.child, &data.rel.child_columns);
let parent = build_diagram_table(&data.parent, &data.rel.parent_columns);
let on_delete = data.rel.on_delete.to_string();
let on_update = data.rel.on_update.to_string();
render_relationship_layout(&child, &parent, &on_delete, &on_update, width as usize)
.into_iter()
.map(|s| s.into_line(mode))
.collect()
}
/// A plain (unstyled) system output line — falls back to whole-line
/// `System` styling, exactly like `note_system`.
const fn plain_system(text: String, mode: Mode) -> OutputLine {
OutputLine {
text,
kind: OutputKind::System,
mode_at_submission: mode,
styled_runs: None,
status: None,
}
}
/// A compact (name-only) box for one endpoint of a `show table`
/// relationship diagram (ADR-0044 §4): the table name + just the
/// participating column(s), all marked as endpoints.
fn compact_table(name: &str, cols: &[String]) -> DiagramTable {
DiagramTable {
name: name.to_string(),
cols: cols
.iter()
.map(|c| DiagramCol {
name: c.clone(),
type_text: None,
pk: false,
endpoint: true,
})
.collect(),
}
}
/// One relationship of the focal table as a compact connector diagram
/// (ADR-0044 §4). `outbound` = the focal table is the child (FK
/// holder, drawn left); otherwise it is the parent (drawn right).
fn render_compact_relationship(
focal: &str,
rel: &crate::db::RelationshipEnd,
outbound: bool,
width: usize,
) -> Vec<Seg> {
let focal_box = compact_table(focal, &rel.local_columns);
let other_box = compact_table(&rel.other_table, &rel.other_columns);
let (child, parent) = if outbound {
(focal_box, other_box)
} else {
(other_box, focal_box)
};
render_relationship_layout(
&child,
&parent,
&rel.on_delete.to_string(),
&rel.on_update.to_string(),
width,
)
}
/// `show table <T>` and relationship-DDL echoes (ADR-0044 §1, Diagram
/// mode): the focal structure box, then a **Relationships** section of
/// compact stacked diagrams, then indexes / table constraints. Box,
/// index and constraint sections are plain system lines; the diagrams
/// are styled.
pub(crate) fn render_structure_with_diagrams(
desc: &TableDescription,
width: u16,
mode: Mode,
) -> Vec<OutputLine> {
let mut out: Vec<OutputLine> = structure_box_lines(desc)
.into_iter()
.map(|s| plain_system(s, mode))
.collect();
if !desc.outbound_relationships.is_empty() || !desc.inbound_relationships.is_empty() {
out.push(plain_system("Relationships".to_string(), mode));
// Outbound (this table is the child) first, then inbound, each
// a compact connector diagram stacked vertically (ADR-0044 §4).
for rel in &desc.outbound_relationships {
for seg in render_compact_relationship(&desc.name, rel, true, width as usize) {
out.push(seg.into_line(mode));
}
}
for rel in &desc.inbound_relationships {
for seg in render_compact_relationship(&desc.name, rel, false, width as usize) {
out.push(seg.into_line(mode));
}
}
}
for s in index_lines(desc) {
out.push(plain_system(s, mode));
}
for s in constraint_lines(desc) {
out.push(plain_system(s, mode));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::{ColumnDescription, IndexInfo, RelationshipEnd};
use crate::dsl::ReferentialAction;
use insta::assert_snapshot;
// ── Relationship visualization (ADR-0044) ──────────────────────
fn dcol(name: &str, ty: &str, pk: bool, endpoint: bool) -> DiagramCol {
DiagramCol {
name: name.to_string(),
type_text: Some(ty.to_string()),
pk,
endpoint,
}
}
/// `orders.customer_id → customers.id`, the canonical 1:n example.
fn orders_to_customers() -> (DiagramTable, DiagramTable) {
let child = DiagramTable {
name: "orders".to_string(),
cols: vec![
dcol("id", "int", true, false),
dcol("customer_id", "int", false, true),
dcol("total", "real", false, false),
],
};
let parent = DiagramTable {
name: "customers".to_string(),
cols: vec![
dcol("id", "int", true, true),
dcol("name", "text", false, false),
dcol("email", "text", false, false),
],
};
(child, parent)
}
/// Join a laid-out diagram's segment text for assertions/snapshots.
fn layout_text(child: &DiagramTable, parent: &DiagramTable, width: usize) -> String {
render_relationship_layout(child, parent, "cascade", "no action", width)
.iter()
.map(|s| s.text.clone())
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn relationship_diagram_single_column_side_by_side_snapshot() {
let (child, parent) = orders_to_customers();
// Wide width forces the side-by-side layout.
let out = layout_text(&child, &parent, 200);
assert_snapshot!(out);
}
#[test]
fn relationship_diagram_carries_names_cardinality_arrow_and_actions() {
let (child, parent) = orders_to_customers();
let out = layout_text(&child, &parent, 200);
// Both tables named, FK marker present, connector + cardinality,
// child→parent arrow, and the referential actions line.
assert!(out.contains("orders"), "child name:\n{out}");
assert!(out.contains("customers"), "parent name:\n{out}");
assert!(out.contains("customer_id ●"), "FK marker:\n{out}");
assert!(out.contains("id (PK) ●"), "parent endpoint marker:\n{out}");
assert!(out.contains('▶'), "arrowhead:\n{out}");
assert!(out.contains('n') && out.contains('1'), "cardinality:\n{out}");
assert!(
out.contains("on delete cascade · on update no action"),
"actions:\n{out}"
);
}
#[test]
fn relationship_diagram_title_uses_table_name_style() {
use crate::app::OutputStyleClass;
let (child, parent) = orders_to_customers();
let segs = render_relationship_layout(&child, &parent, "cascade", "no action", 200);
// A span styles the literal table name in the stand-out class
// (ADR-0044 §2.1 — the name must not read as a column).
let styled = segs.iter().any(|s| {
s.runs.iter().any(|r| {
r.class == OutputStyleClass::DiagramTableName
&& &s.text[r.byte_range.0..r.byte_range.1] == "orders"
})
});
assert!(styled, "table name should carry DiagramTableName style");
}
#[test]
fn relationship_diagram_vertical_fallback_when_narrow() {
let (child, parent) = orders_to_customers();
// A width too small for two boxes side by side stacks them.
let out = layout_text(&child, &parent, 20);
assert!(out.contains('▼'), "vertical connector:\n{out}");
assert!(!out.contains('▶'), "no side-by-side arrow:\n{out}");
let ci = out.find("orders").expect("child");
let pi = out.find("customers").expect("parent");
assert!(pi > ci, "parent stacked below child:\n{out}");
}
#[test]
fn relationship_diagram_compound_fk_routes_a_bus_and_pairing_line() {
// A 2-column FK: (cust_region, cust_id) → customers.(region, id).
let child = DiagramTable {
name: "orders".to_string(),
cols: vec![
dcol("cust_region", "text", false, true),
dcol("cust_id", "int", false, true),
dcol("total", "real", false, false),
],
};
let parent = DiagramTable {
name: "customers".to_string(),
cols: vec![
dcol("region", "text", true, true),
dcol("id", "int", true, true),
dcol("name", "text", false, false),
],
};
let out = layout_text(&child, &parent, 200);
// Both endpoint pairs marked, the bus joins them, and an explicit
// pairing line removes any ambiguity (ADR-0044 §2.4).
assert!(out.contains("cust_region ●"), "child ep 1:\n{out}");
assert!(out.contains("cust_id ●"), "child ep 2:\n{out}");
assert!(
out.contains("(cust_region, cust_id) ▶ customers.(region, id)"),
"pairing line:\n{out}",
);
assert_snapshot!(out);
}
#[test]
fn relationship_diagram_self_referential_shows_two_same_named_boxes() {
// Employee.manager_id → Employee.id (a self-referential FK):
// rendered as two boxes bearing the same name (ADR-0044 §6).
let child = DiagramTable {
name: "Employee".to_string(),
cols: vec![
dcol("id", "serial", true, false),
dcol("manager_id", "int", false, true),
],
};
let parent = DiagramTable {
name: "Employee".to_string(),
cols: vec![
dcol("id", "serial", true, true),
dcol("manager_id", "int", false, false),
],
};
let out = layout_text(&child, &parent, 200);
assert_eq!(out.matches("Employee").count(), 2, "two boxes:\n{out}");
assert!(out.contains("manager_id ●"), "FK endpoint:\n{out}");
assert!(out.contains('▶'), "connector:\n{out}");
assert_snapshot!(out);
}
#[test]
fn render_relationship_diagram_marks_all_compound_endpoints_from_data() {
// The full App-side entry: build_diagram_table must mark BOTH
// paired columns on each side from RelationshipDiagramData.
let blank_rels = || (Vec::new(), Vec::new());
let (r_out, r_in) = blank_rels();
let region = TableDescription {
name: "Region".to_string(),
columns: vec![col("country", Type::Int, true, false), col("code", Type::Int, true, false)],
outbound_relationships: r_out,
inbound_relationships: r_in,
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let (c_out, c_in) = blank_rels();
let city = TableDescription {
name: "City".to_string(),
columns: vec![
col("country", Type::Int, false, false),
col("region_code", Type::Int, false, false),
col("name", Type::Text, false, false),
],
outbound_relationships: c_out,
inbound_relationships: c_in,
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let data = crate::db::RelationshipDiagramData {
rel: crate::persistence::RelationshipSchema {
name: "city_region".to_string(),
parent_table: "Region".to_string(),
parent_columns: vec!["country".to_string(), "code".to_string()],
child_table: "City".to_string(),
child_columns: vec!["country".to_string(), "region_code".to_string()],
on_delete: ReferentialAction::NoAction,
on_update: ReferentialAction::NoAction,
},
child: city,
parent: region,
};
let text = render_relationship_diagram(&data, 200, Mode::Simple)
.iter()
.map(|l| l.text.clone())
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("region_code ●"), "child endpoint 2:\n{text}");
assert!(text.contains("(PK) ●"), "parent endpoint is PK + marked:\n{text}");
assert!(
text.contains("(country, region_code) ▶ Region.(country, code)"),
"pairing line:\n{text}",
);
}
#[test]
fn render_structure_with_diagrams_replaces_prose_with_compact_diagrams() {
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_columns: vec!["cust_id".to_string()],
local_columns: vec!["id".to_string()],
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::NoAction,
}],
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let lines = render_structure_with_diagrams(&desc, 200, Mode::Simple);
let text = lines
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
// Diagram form: a Relationships heading + a connector, NOT the
// prose `Referenced by:` block.
assert!(text.contains("Relationships"), "heading:\n{text}");
assert!(!text.contains("Referenced by:"), "no prose block:\n{text}");
assert!(text.contains("Customers"), "focal box:\n{text}");
assert!(text.contains("Orders"), "neighbour box:\n{text}");
assert!(text.contains('▶'), "connector arrow:\n{text}");
// Box lines plain; diagram lines styled.
assert!(
lines.iter().any(|l| l.styled_runs.is_some()),
"styled diagram lines",
);
assert!(
lines.iter().any(|l| l.styled_runs.is_none()),
"plain box lines",
);
assert_snapshot!(text);
}
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,
unique: false,
default: None,
check: None,
}
}
// --- 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(),
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: 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_columns: vec!["cust_id".to_string()],
local_columns: vec!["id".to_string()],
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::NoAction,
}],
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
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(),
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: 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_shows_indexes_section() {
let desc = TableDescription {
name: "Customers".to_string(),
columns: vec![
col("id", Type::Serial, true, false),
col("Email", Type::Text, false, false),
],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: vec![IndexInfo {
name: "idx_email".to_string(),
columns: vec!["Email".to_string()],
unique: false,
}],
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let out = render_structure(&desc).join("\n");
assert!(out.contains("Indexes:"), "got:\n{out}");
assert!(out.contains("idx_email (Email)"), "got:\n{out}");
// A plain index carries no uniqueness marker.
assert!(!out.contains("[unique]"), "plain index unmarked; got:\n{out}");
}
#[test]
fn render_structure_marks_a_unique_index() {
// ADR-0035 §4d: a UNIQUE index is marked `[unique]` so a learner
// can tell it from a performance-only index.
let desc = TableDescription {
name: "Customers".to_string(),
columns: vec![
col("id", Type::Serial, true, false),
col("Email", Type::Text, false, false),
],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: vec![IndexInfo {
name: "uidx_email".to_string(),
columns: vec!["Email".to_string()],
unique: true,
}],
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let out = render_structure(&desc).join("\n");
assert!(out.contains("uidx_email (Email) [unique]"), "got:\n{out}");
}
#[test]
fn render_structure_shows_table_level_constraints() {
// ADR-0035 §4i (b): composite UNIQUE and table-level CHECK
// (named + unnamed) render in a "Table constraints:" section,
// distinct from the per-column "Constraints" column.
let desc = TableDescription {
name: "T".to_string(),
columns: vec![
col("a", Type::Int, true, false),
col("b", Type::Int, false, false),
],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
unique_constraints: vec![vec!["a".to_string(), "b".to_string()]],
check_constraints: vec![
crate::persistence::TableCheck { name: None, expr: "a < b".to_string() },
crate::persistence::TableCheck {
name: Some("a_lt_b".to_string()),
expr: "a <> b".to_string(),
},
],
};
let out = render_structure(&desc).join("\n");
assert!(out.contains("Table constraints:"), "got:\n{out}");
assert!(out.contains("unique (a, b)"), "got:\n{out}");
// The composite UNIQUE shows its derived, addressable name
// (ADR-0035 Amendment 1) so the user can `drop constraint <name>`.
assert!(out.contains("unique_a_b: unique (a, b)"), "got:\n{out}");
assert!(out.contains("check (a < b)"), "unnamed check; got:\n{out}");
assert!(out.contains("check a_lt_b (a <> b)"), "named check shows its name; got:\n{out}");
}
#[test]
fn render_structure_omits_indexes_section_when_none() {
let desc = TableDescription {
name: "T".to_string(),
columns: vec![col("id", Type::Serial, true, false)],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: Vec::new(),
};
let out = render_structure(&desc).join("\n");
assert!(!out.contains("Indexes:"), "got:\n{out}");
}
// --- render_explain_plan (ADR-0028 §3) --------------
#[test]
fn render_explain_plan_puts_display_sql_first() {
let plan = QueryPlan {
display_sql: "SELECT \"id\" FROM \"T\"".to_string(),
rows: vec![ExplainRow {
id: 2,
parent: 0,
detail: "SCAN T".to_string(),
}],
};
let lines = render_explain_plan(&plan, Mode::Simple);
assert_eq!(lines[0].text, "SELECT \"id\" FROM \"T\"");
assert!(lines[1].text.contains("SCAN T"), "got {:?}", lines[1].text);
assert!(lines[1].text.contains('└'), "last node uses └─");
}
#[test]
fn render_explain_plan_nests_children_under_parents() {
let plan = QueryPlan {
display_sql: "SELECT 1".to_string(),
rows: vec![
ExplainRow { id: 1, parent: 0, detail: "root".to_string() },
ExplainRow { id: 2, parent: 1, detail: "child-a".to_string() },
ExplainRow { id: 3, parent: 1, detail: "child-b".to_string() },
],
};
let lines = render_explain_plan(&plan, Mode::Simple);
// display SQL + 3 plan nodes.
assert_eq!(lines.len(), 4);
assert!(lines[1].text.contains("root"));
assert!(lines[2].text.contains("├─ child-a"), "got {:?}", lines[2].text);
assert!(lines[3].text.contains("└─ child-b"), "got {:?}", lines[3].text);
// The single root uses `└─`; its children are indented
// by three spaces (no `│` spine, the root being last).
assert!(lines[1].text.starts_with("└─ root"));
assert!(lines[2].text.starts_with(" ├─ child-a"));
}
/// The semantic class of the styled run covering the first
/// occurrence of `needle` in `line`'s text.
fn span_class_for(line: &OutputLine, needle: &str) -> OutputStyleClass {
let runs = line.styled_runs.as_ref().expect("line should be styled");
let at = line.text.find(needle).expect("needle present in text");
runs.iter()
.find(|s| s.byte_range.0 <= at && at < s.byte_range.1)
.map(|s| s.class)
.expect("a styled run covers the needle")
}
fn one_node_plan(detail: &str) -> QueryPlan {
QueryPlan {
display_sql: "SELECT 1".to_string(),
rows: vec![ExplainRow {
id: 1,
parent: 0,
detail: detail.to_string(),
}],
}
}
#[test]
fn render_explain_plan_colours_a_full_scan_expensive() {
let plan = one_node_plan("SCAN Customers");
let lines = render_explain_plan(&plan, Mode::Simple);
assert_eq!(span_class_for(&lines[1], "SCAN"), OutputStyleClass::Expensive);
// The table name stays neutral (ADR-0028 §6).
assert_eq!(
span_class_for(&lines[1], "Customers"),
OutputStyleClass::Neutral,
);
}
#[test]
fn render_explain_plan_colours_an_index_search_efficient() {
let plan = one_node_plan("SEARCH Customers USING INDEX Customers_Email_idx (Email=?)");
let lines = render_explain_plan(&plan, Mode::Simple);
assert_eq!(
span_class_for(&lines[1], "USING INDEX"),
OutputStyleClass::Efficient,
);
// The index name and the connector stay neutral.
assert_eq!(
span_class_for(&lines[1], "Customers_Email_idx"),
OutputStyleClass::Neutral,
);
assert_eq!(span_class_for(&lines[1], "└─"), OutputStyleClass::Neutral);
}
#[test]
fn render_explain_plan_flags_an_automatic_index() {
let plan =
one_node_plan("SEARCH Orders USING AUTOMATIC COVERING INDEX (CustId=?)");
let lines = render_explain_plan(&plan, Mode::Simple);
assert_eq!(
span_class_for(&lines[1], "USING AUTOMATIC COVERING INDEX"),
OutputStyleClass::AutomaticIndex,
);
// ADR-0028 §6: the distinct "add an index" advice tag.
assert!(
lines[1].text.ends_with("← add an index?"),
"got {:?}",
lines[1].text,
);
assert_eq!(
span_class_for(&lines[1], "add an index"),
OutputStyleClass::AutomaticIndex,
);
}
#[test]
fn render_explain_plan_temp_btree_is_expensive() {
let plan = one_node_plan("USE TEMP B-TREE FOR ORDER BY");
let lines = render_explain_plan(&plan, Mode::Simple);
assert_eq!(
span_class_for(&lines[1], "USE TEMP B-TREE"),
OutputStyleClass::Expensive,
);
}
#[test]
fn render_explain_plan_covering_index_scan_classifies_on_the_index() {
// A `SCAN … USING INDEX …` is a covering-index scan —
// it must read as efficient, not as a full scan.
let plan = one_node_plan("SCAN Customers USING COVERING INDEX idx");
let lines = render_explain_plan(&plan, Mode::Simple);
assert_eq!(
span_class_for(&lines[1], "USING COVERING INDEX"),
OutputStyleClass::Efficient,
);
assert_eq!(span_class_for(&lines[1], "SCAN"), OutputStyleClass::Neutral);
}
#[test]
fn render_explain_plan_unrecognised_detail_is_neutral() {
let plan = one_node_plan("CO-ROUTINE 0x1");
let lines = render_explain_plan(&plan, Mode::Simple);
let runs = lines[1].styled_runs.as_ref().unwrap();
assert!(
runs.iter().all(|s| s.class == OutputStyleClass::Neutral),
"an unrecognised detail must render wholly neutral: {runs:?}",
);
}
#[test]
fn render_explain_plan_display_sql_line_is_neutral() {
let plan = one_node_plan("SCAN T");
let lines = render_explain_plan(&plan, Mode::Simple);
let runs = lines[0].styled_runs.as_ref().unwrap();
assert!(runs.iter().all(|s| s.class == OutputStyleClass::Neutral));
}
#[test]
fn render_explain_plan_survives_a_self_referential_node() {
// A malformed plan node that is both a root (`parent ==
// 0`) and its own parent (`id == 0`) must not loop.
let plan = QueryPlan {
display_sql: String::new(),
rows: vec![ExplainRow {
id: 0,
parent: 0,
detail: "self-rooted".to_string(),
}],
};
let lines = render_explain_plan(&plan, Mode::Simple);
// display-SQL line + the node, drawn exactly once.
assert_eq!(lines.len(), 2);
}
#[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,
unique: false,
default: None,
check: None,
}],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
indexes: Vec::new(),
unique_constraints: Vec::new(),
check_constraints: 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}");
}
}