//! Pretty-table rendering for the output panel //! (ADR-0016). //! //! Two public entry points: [`render_data_table`] for query //! / show-data / auto-show outputs, and [`render_structure`] //! for table-structure listings produced after DDL or //! `show table`. //! //! Both return a `Vec` — one display row per element //! — to match the existing `OutputLine`-per-line discipline //! in `app.rs`. Border chars are Unicode box-drawing //! (UTF-8); no ASCII fallback (ADR-0016 OOS-5). //! //! Layout: outer frame + header underline only. No per-row //! horizontal rules, which keeps typical row counts (5–50) //! readable without visual noise. //! //! NULL renders as the literal `(null)`; cell newlines, tabs //! and control characters render as `↵`, `→`, `·` //! respectively (display-only — underlying data is //! untouched). use crate::db::{ColumnDescription, DataResult, TableDescription}; use crate::dsl::Type; const NULL_DISPLAY: &str = "(null)"; /// Render a query / data result as a Vec of display rows. /// /// Empty result sets yield the header + a `(no rows)` line /// inside the frame — matches the existing UX hint that "the /// query ran but there was nothing to show." #[must_use] pub fn render_data_table(data: &DataResult) -> Vec { let header_cells: Vec = data.columns.clone(); let alignments: Vec = data .column_types .iter() .map(|t| alignment_for(*t)) .collect(); let body: Vec> = if data.rows.is_empty() { // For empty tables, still render the header band so // the user sees the column shape, then a single // `(no rows)` row spanning all columns. We achieve // the spanning effect with a left-aligned cell in // the first column and empty cells elsewhere. let mut row = vec![String::new(); header_cells.len().max(1)]; if let Some(first) = row.first_mut() { *first = "(no rows)".to_string(); } vec![row] } else { data.rows .iter() .map(|r| { r.iter() .map(|c| { c.as_ref() .map_or_else(|| NULL_DISPLAY.to_string(), |s| sanitize_cell(s)) }) .collect() }) .collect() }; render_table(&header_cells, &body, &alignments) } /// Render a table-structure listing. /// /// Produces a header line (``), the schema table /// itself, and — for a structure that has FK relationships /// — `References:` / `Referenced by:` blocks below as plain /// indented text (relationship visualization is its own /// future ADR per §5 OOS-1). #[must_use] pub fn render_structure(desc: &TableDescription) -> Vec { let mut out: Vec = Vec::new(); out.push(desc.name.clone()); let header_cells = vec![ "Name".to_string(), "Type".to_string(), "Constraints".to_string(), ]; let body: Vec> = desc .columns .iter() .map(|c| { vec![ c.name.clone(), type_display(c), constraints_display(c), ] }) .collect(); // Type column gets the same numeric/text rule as data // columns by virtue of consistency, but every entry is // a keyword string ("text", "serial", …) so left-align // is correct in every case. Constraints are similarly // textual. let alignments = vec![Alignment::Left, Alignment::Left, Alignment::Left]; out.extend(render_table(&header_cells, &body, &alignments)); if !desc.outbound_relationships.is_empty() { out.push("References:".to_string()); for r in &desc.outbound_relationships { out.push(format!( " {} → {}.{} ({}, on delete {}, on update {})", r.local_column, r.other_table, r.other_column, r.name, r.on_delete, r.on_update, )); } } if !desc.inbound_relationships.is_empty() { out.push("Referenced by:".to_string()); for r in &desc.inbound_relationships { out.push(format!( " {}.{} → {} ({}, on delete {}, on update {})", r.other_table, r.other_column, r.local_column, r.name, r.on_delete, r.on_update, )); } } out } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Alignment { Left, Right, } /// Map a user-facing type to its column alignment per /// ADR-0016 §2. `None` (foreign-attached, no metadata) /// falls back to Left. const fn alignment_for(ty: Option) -> Alignment { match ty { Some(Type::Int | Type::Real | Type::Decimal | Type::Serial) => Alignment::Right, Some(Type::Text) | Some(Type::Bool) | Some(Type::Date) | Some(Type::DateTime) | Some(Type::Blob) | Some(Type::ShortId) | None => Alignment::Left, } } fn type_display(c: &ColumnDescription) -> String { c.user_type .map_or_else(|| c.sqlite_type.to_lowercase(), |t| t.keyword().to_string()) } fn constraints_display(c: &ColumnDescription) -> String { let mut parts: Vec<&str> = Vec::new(); if c.primary_key { parts.push("PK"); } if c.notnull { parts.push("NOT NULL"); } parts.join(", ") } /// Replace newlines / tabs / other control characters with /// visible display markers per ADR-0016 §3. Display-only; /// underlying data is untouched. fn sanitize_cell(s: &str) -> String { let mut out = String::with_capacity(s.len()); for ch in s.chars() { match ch { '\n' => out.push('↵'), '\t' => out.push('→'), c if (c as u32) < 0x20 => out.push('·'), other => out.push(other), } } out } /// Width in Unicode codepoints. Matches existing /// width-counting throughout the codebase; deferring true /// Unicode-Width handling to a later pass. fn cell_width(s: &str) -> usize { s.chars().count() } /// Render a single bordered table given header cells, body /// rows, and per-column alignment. Outer frame + /// header-underline only. fn render_table( headers: &[String], body: &[Vec], alignments: &[Alignment], ) -> Vec { debug_assert_eq!(headers.len(), alignments.len()); // Compute column widths: max(header, all body cells). // Empty headers + empty body produces an empty table, // which we still want to render as a single horizontal // line — easier to reason about than a missing one. let column_count = headers.len(); let mut widths: Vec = headers.iter().map(|s| cell_width(s)).collect(); for row in body { for (i, cell) in row.iter().enumerate() { if i < widths.len() { let w = cell_width(cell); if w > widths[i] { widths[i] = w; } } } } let mut out: Vec = Vec::with_capacity(body.len() + 3); out.push(border_row(&widths, BorderRow::Top)); out.push(content_row(headers, &widths, alignments)); out.push(border_row(&widths, BorderRow::HeaderUnderline)); if column_count == 0 { // Nothing more to render; the bottom border closes // an empty box. Unusual but well-defined. } else { for row in body { out.push(content_row(row, &widths, alignments)); } } out.push(border_row(&widths, BorderRow::Bottom)); out } #[derive(Debug, Clone, Copy)] enum BorderRow { Top, HeaderUnderline, Bottom, } fn border_row(widths: &[usize], kind: BorderRow) -> String { let (left, mid, right) = match kind { BorderRow::Top => ('┌', '┬', '┐'), BorderRow::HeaderUnderline => ('├', '┼', '┤'), BorderRow::Bottom => ('└', '┴', '┘'), }; let mut s = String::new(); s.push(left); if widths.is_empty() { s.push(right); return s; } for (i, w) in widths.iter().enumerate() { if i > 0 { s.push(mid); } // One space of padding on each side of the cell, so // a width-w cell occupies w + 2 box columns. for _ in 0..(w + 2) { s.push('─'); } } s.push(right); s } fn content_row(cells: &[String], widths: &[usize], alignments: &[Alignment]) -> String { let mut s = String::new(); s.push('│'); for (i, w) in widths.iter().enumerate() { let cell = cells.get(i).cloned().unwrap_or_default(); let align = alignments.get(i).copied().unwrap_or(Alignment::Left); s.push(' '); let pad_total = w.saturating_sub(cell_width(&cell)); match align { Alignment::Left => { s.push_str(&cell); for _ in 0..pad_total { s.push(' '); } } Alignment::Right => { for _ in 0..pad_total { s.push(' '); } s.push_str(&cell); } } s.push(' '); s.push('│'); } s } #[cfg(test)] mod tests { use super::*; use crate::db::{ColumnDescription, RelationshipEnd}; use crate::dsl::ReferentialAction; use insta::assert_snapshot; fn col(name: &str, ty: Type, pk: bool, notnull: bool) -> ColumnDescription { ColumnDescription { name: name.to_string(), user_type: Some(ty), sqlite_type: match ty { Type::Int | Type::Serial | Type::Bool => "INTEGER".to_string(), Type::Real => "REAL".to_string(), Type::Blob => "BLOB".to_string(), _ => "TEXT".to_string(), }, notnull, primary_key: pk, } } // --- Width / alignment helpers ---------------------- #[test] fn alignment_for_numeric_types_is_right() { for ty in [Type::Int, Type::Real, Type::Decimal, Type::Serial] { assert_eq!(alignment_for(Some(ty)), Alignment::Right, "{ty:?}"); } } #[test] fn alignment_for_other_types_is_left() { for ty in [ Type::Text, Type::Bool, Type::Date, Type::DateTime, Type::Blob, Type::ShortId, ] { assert_eq!(alignment_for(Some(ty)), Alignment::Left, "{ty:?}"); } } #[test] fn alignment_for_unknown_type_is_left() { assert_eq!(alignment_for(None), Alignment::Left); } // --- sanitize_cell ---------------------------------- #[test] fn sanitize_replaces_newline_with_return_arrow() { assert_eq!(sanitize_cell("a\nb"), "a↵b"); } #[test] fn sanitize_replaces_tab_with_arrow() { assert_eq!(sanitize_cell("a\tb"), "a→b"); } #[test] fn sanitize_replaces_other_control_chars_with_dot() { // \x07 is BEL; should become a middle dot. assert_eq!(sanitize_cell("a\x07b"), "a·b"); } #[test] fn sanitize_passes_through_normal_text() { assert_eq!(sanitize_cell("Alice & Bob"), "Alice & Bob"); } // --- cell_width ------------------------------------- #[test] fn cell_width_counts_codepoints_not_bytes() { // 'é' is 2 bytes in UTF-8 but counts as 1 codepoint. assert_eq!(cell_width("héllo"), 5); assert_eq!("héllo".len(), 6); } // --- render_data_table snapshots -------------------- #[test] fn render_data_table_basic_shape() { let data = DataResult { table_name: "Customers".to_string(), columns: vec!["id".to_string(), "Name".to_string(), "Email".to_string()], column_types: vec![ Some(Type::Serial), Some(Type::Text), Some(Type::Text), ], rows: vec![ vec![ Some("1".to_string()), Some("Alice".to_string()), Some("a@example.io".to_string()), ], vec![ Some("2".to_string()), Some("Bob".to_string()), Some("b@example.io".to_string()), ], ], }; assert_snapshot!(render_data_table(&data).join("\n")); } #[test] fn render_data_table_empty_rows_shows_no_rows_marker() { let data = DataResult { table_name: "Customers".to_string(), columns: vec!["id".to_string(), "Name".to_string()], column_types: vec![Some(Type::Serial), Some(Type::Text)], rows: Vec::new(), }; let out = render_data_table(&data).join("\n"); assert!(out.contains("(no rows)"), "got:\n{out}"); assert_snapshot!(out); } #[test] fn render_data_table_nulls_render_as_null_marker() { let data = DataResult { table_name: "T".to_string(), columns: vec!["a".to_string(), "b".to_string()], column_types: vec![Some(Type::Int), Some(Type::Text)], rows: vec![ vec![Some("1".to_string()), None], vec![None, Some("hi".to_string())], ], }; let out = render_data_table(&data).join("\n"); assert!(out.contains("(null)"), "got:\n{out}"); } #[test] fn render_data_table_right_aligns_numerics() { // A numeric column with mixed-width values should // right-align so the digits' ones place stacks. // Single-char header keeps column width = max body // width = 3 (i.e. "999"), making the assertions // explicit. let data = DataResult { table_name: "T".to_string(), columns: vec!["n".to_string()], column_types: vec![Some(Type::Int)], rows: vec![ vec![Some("1".to_string())], vec![Some("42".to_string())], vec![Some("999".to_string())], ], }; let out = render_data_table(&data).join("\n"); // The narrowest value ("1") must have leading // padding so its rightmost digit lines up with "9" // and "2" in the wider rows. assert!( out.contains("│ 1 │"), "expected right-aligned `1`:\n{out}", ); assert!( out.contains("│ 42 │"), "expected right-aligned `42`:\n{out}", ); assert!( out.contains("│ 999 │"), "expected right-aligned `999`:\n{out}", ); } #[test] fn render_data_table_left_aligns_text() { let data = DataResult { table_name: "T".to_string(), columns: vec!["Name".to_string()], column_types: vec![Some(Type::Text)], rows: vec![ vec![Some("Alice".to_string())], vec![Some("Bo".to_string())], ], }; let out = render_data_table(&data).join("\n"); assert!( out.contains("│ Alice │"), "expected left-aligned `Alice`:\n{out}", ); assert!( out.contains("│ Bo │"), "expected left-aligned `Bo` with trailing pad:\n{out}", ); } #[test] fn render_data_table_handles_newline_in_cell() { let data = DataResult { table_name: "T".to_string(), columns: vec!["note".to_string()], column_types: vec![Some(Type::Text)], rows: vec![vec![Some("Hello\nWorld".to_string())]], }; let out = render_data_table(&data).join("\n"); assert!(out.contains("Hello↵World"), "got:\n{out}"); } // --- render_structure snapshots --------------------- #[test] fn render_structure_basic() { let desc = TableDescription { name: "Customers".to_string(), columns: vec![ col("id", Type::Serial, true, false), col("Name", Type::Text, false, true), col("Email", Type::Text, false, false), ], outbound_relationships: Vec::new(), inbound_relationships: Vec::new(), }; assert_snapshot!(render_structure(&desc).join("\n")); } #[test] fn render_structure_with_relationships() { let desc = TableDescription { name: "Customers".to_string(), columns: vec![col("id", Type::Serial, true, false)], outbound_relationships: Vec::new(), inbound_relationships: vec![RelationshipEnd { name: "cust_orders".to_string(), other_table: "Orders".to_string(), other_column: "cust_id".to_string(), local_column: "id".to_string(), on_delete: ReferentialAction::Cascade, on_update: ReferentialAction::NoAction, }], }; let out = render_structure(&desc).join("\n"); assert!( out.contains("Referenced by:"), "expected inbound relationship section:\n{out}", ); assert!( out.contains("Orders.cust_id → id"), "expected inbound relationship line:\n{out}", ); assert_snapshot!(out); } #[test] fn render_structure_pk_and_notnull_render_in_constraints() { let desc = TableDescription { name: "T".to_string(), columns: vec![ col("id", Type::Serial, true, false), col("name", Type::Text, false, true), col("nick", Type::Text, false, false), ], outbound_relationships: Vec::new(), inbound_relationships: Vec::new(), }; let out = render_structure(&desc).join("\n"); // PK appears for id, NOT NULL for name, blank for nick. assert!(out.contains("│ id │ serial │ PK"), "got:\n{out}"); assert!(out.contains("│ name │ text │ NOT NULL"), "got:\n{out}"); } #[test] fn render_structure_falls_back_to_sqlite_type_when_user_type_missing() { let mut desc = TableDescription { name: "T".to_string(), columns: vec![ColumnDescription { name: "x".to_string(), user_type: None, sqlite_type: "INTEGER".to_string(), notnull: false, primary_key: false, }], outbound_relationships: Vec::new(), inbound_relationships: Vec::new(), }; let out = render_structure(&desc).join("\n"); // The lowercase form of the SQLite type should appear. assert!(out.contains("integer"), "got:\n{out}"); // And the name column should still align correctly with no user_type. desc.columns[0].user_type = Some(Type::Int); let with_type = render_structure(&desc).join("\n"); assert!(with_type.contains("int"), "got:\n{with_type}"); } }