feat: show relationship <name> renders a styled two-table diagram (ADR-0044)

The first wired slice of relationship visualization (V1). `show
relationship <name>` now renders the relationship as two full
structure boxes joined by a width-jogging connector (child-left /
parent-right, n…1 cardinality, on delete/update actions), styled
App-side, with a vertical-stack fallback for narrow terminals.

- db.rs: RelationshipDiagramData + show_relationship worker path
  (structured data: the relationship + both endpoint TableDescriptions)
- runtime.rs: named relationships route to the structured outcome
  (boxed); other show <kind> forms stay prose
- app.rs/event.rs/ui.rs: DslShowRelationshipSucceeded rendered App-side;
  new diagram OutputStyleClass variants; App::last_output_width from ui.rs
- output_render.rs: styled Seg layout engine (boxes, connector routing,
  side-by-side + vertical), composing the ADR-0016 box primitives

Tests: 4 unit + 4 integration; full suite 2201 pass / 0 fail / 1 ignored;
clippy nursery clean. requirements.md V1 stays [/] (show table diagrams,
compound routing, DDL-echo wiring remain).
This commit is contained in:
claude@clouddev1
2026-06-09 22:27:39 +00:00
parent bb02dfb752
commit cad90ec4a5
8 changed files with 756 additions and 1 deletions
+495
View File
@@ -484,6 +484,9 @@ 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 {
@@ -491,6 +494,7 @@ fn border_row(widths: &[usize], kind: BorderRow) -> String {
BorderRow::Top => ('┌', '┬', '┐'),
BorderRow::HeaderUnderline => ('├', '┼', '┤'),
BorderRow::Bottom => ('└', '┴', '┘'),
BorderRow::TitleUnderline => ('├', '┬', '┤'),
};
let mut s = String::new();
s.push(left);
@@ -540,6 +544,402 @@ fn content_row(cells: &[String], widths: &[usize], alignments: &[Alignment]) ->
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 {
segs.push(body_seg(c, label_w, has_types.then_some(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
}
/// One row of the gutter as a styled segment: routes the connector
/// from the child endpoint row (`crow`) to the parent endpoint row
/// (`prow`), `n` at the child end and `1` at the parent end, a `▶`
/// arrowhead into the parent. Handles the straight (same height) and
/// jogged (differing height) cases.
fn gutter_seg(i: usize, crow: usize, prow: usize, w: usize) -> Seg {
let mut cells = vec![' '; w];
let vc = w / 2;
if crow == prow {
if i == crow {
for c in &mut cells[1..w - 1] {
*c = '─';
}
cells[0] = 'n';
cells[w - 2] = '1';
cells[w - 1] = '▶';
}
} else if i == crow {
for c in &mut cells[..vc] {
*c = '─';
}
cells[vc] = if crow < prow { '┐' } else { '┘' };
cells[0] = 'n';
} else if i == prow {
cells[vc] = if prow > crow { '└' } else { '┌' };
for c in &mut cells[vc + 1..w - 1] {
*c = '─';
}
cells[w - 2] = '1';
cells[w - 1] = '▶';
} else if i > crow.min(prow) && i < crow.max(prow) {
cells[vc] = '│';
}
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
}
/// Two boxes side by side, joined by the gutter connector (ADR-0044
/// §2.3), with the actions line beneath.
fn compose_side_by_side(
cb: &BoxLayout,
pb: &BoxLayout,
crow: usize,
prow: usize,
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 + 1);
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, crow, prow, GUTTER));
seg.append(pb.segs.get(i).unwrap_or(&blank_r));
out.push(seg);
}
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.
fn compose_vertical(
cb: &BoxLayout,
pb: &BoxLayout,
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());
out
}
/// Lay out a relationship between two `DiagramTable`s at `width`,
/// choosing side-by-side or the vertical fallback (ADR-0044 §3).
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 crow = cb.endpoint_rows.first().copied().unwrap_or(0);
let prow = pb.endpoint_rows.first().copied().unwrap_or(0);
if cb.width + GUTTER + pb.width <= width.max(1) {
compose_side_by_side(&cb, &pb, crow, prow, on_delete, on_update)
} else {
compose_vertical(&cb, &pb, 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()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -547,6 +947,101 @@ mod tests {
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}");
}
fn col(name: &str, ty: Type, pk: bool, notnull: bool) -> ColumnDescription {
ColumnDescription {
name: name.to_string(),