//! 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 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 { 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). /// 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 { 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 { let mut out: Vec = vec![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(); // 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 { let mut out: Vec = 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 { let mut out: Vec = 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 { let mut out: Vec = 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`-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 { let mut out: Vec = 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 = 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, emitted: &mut HashSet, 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 = 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, 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], alignments: &[Alignment], ) -> Vec { let body: Vec> = 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) -> 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 = 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], 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, /// 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, /// 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, } /// 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, } 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, width: usize, endpoint_rows: Vec, } 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 + (ncols−1) dividers. let ncols = widths.len(); let body_inner: usize = widths.iter().map(|w| w + 2).sum::() + (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 = 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) -> 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 { 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 = 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 { let indent = " "; let mut out: Vec = 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 { 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 ` 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 { 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 { 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 ` 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 { let mut out: Vec = 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::>() .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 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::>() .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 `. 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}"); } }