Files
rdbms-playground/src/friendly/error.rs
T
claude@clouddev1 eac7e5b81d ADR-0019 implementation: friendly error layer + i18n catalog
All eight implementation steps from ADR-0019's §"Order of
operations":

Step 1 — `src/friendly/` module skeleton; `t!()` macro; YAML
  catalog loader (`include_str!` + `serde_yml`); `{name}`
  substitution helper that rejects format specifiers per §8.4.

Step 2 — `error.*` catalog populated for UNIQUE / FK /
  NOT NULL / CHECK / type-mismatch / not_found / already_exists /
  generic / invalid_value, with verbose hints per
  pedagogical-voice rule (§5). Anchor phrases (§10) preserved
  verbatim.

Step 3 — `FriendlyError { headline, hint, diagnostic_table }`
  + renderer composing the three blocks per §7.

Step 4 — `translate(&DbError, &TranslateContext) → FriendlyError`.
  Classifies by `SqliteErrorKind` first, then by message text
  for the constraint family. `change column` failures route to
  the type-mismatch headline, subsuming the previous
  `friendly_change_column_engine_error` helper.

Step 5 — `DbError::friendly_message()` delegates to the
  translator with default context. Removed
  `friendly_change_column_engine_error` (absorbed) and
  `enrich_fk_message` (FK list moves to the deferred re-query
  step). One test rewritten to assert on the engine-classified
  payload rather than the removed enrichment text.

Step 6 — `messages (short|verbose)` app-level command parallel
  to `mode`. `App::messages_verbosity` (default verbose)
  threaded into `TranslateContext` via
  `App::build_translate_context`. `AppEvent::DslFailed` now
  carries the structured `DbError`, plus the App extracts the
  user's attempted value from `Command::Insert` / `Update`
  to fill the `{value}` placeholder for UNIQUE / NOT NULL.

Step 7 — Catalog validator (§8.6) checks for missing keys,
  unused/undeclared placeholders, format specifiers, and
  forbidden engine vocabulary. `main.rs` parses the embedded
  catalog at startup so a corrupted build artefact fails
  loudly there rather than at the first `t!()` call.

Step 8 — Anchor phrases (§10) held: existing tests asserting
  on "no such table", "already exists", "cannot be converted",
  etc. all pass without rewording.

## Tally

603 tests passing (was 561: +42 net). Clippy clean with
nursery lints. Release binary 7.7 MB.

## Deliberately deferred

- Schema-aware enrichment for FK violations (parent_table /
  parent_column / child_table) and the multi-value
  natural-order INSERT case for UNIQUE. Both need the
  Database handle in scope at translation time, so they
  bundle naturally with the row-pinpoint re-query work
  (ADR-0019 §6) — that follow-on adds runtime-side
  enrichment via a `Database` lookup and a structured
  failure-context carried on `DslFailed`. Until then,
  unfilled placeholders render as their `{name}` form for
  visual consistency with the catalog.
- Migration sweep (§9). Only `error.*` is catalog-driven so
  far; `help.*`, `ok.*`, `client_side.*`, `replay.*`,
  `parse.*`, modal labels, etc. migrate per-PR.
- Settings persistence for `messages`. In-session state for
  now; waits on the future settings ADR.
2026-05-09 12:43:37 +00:00

195 lines
6.6 KiB
Rust

//! Structured friendly-error payload (ADR-0019 §7).
//!
//! `FriendlyError` is the type the translator produces. The
//! renderer (in this module) composes it into final text. The
//! payload is structured so the renderer can decide layout —
//! per ADR-0019 §F-detail, "leave rendering details to the
//! renderer instead of banging everything in one string".
//!
//! Composition order:
//!
//! ```text
//! <headline>
//!
//! <hint> (only when present)
//!
//! ┌────────────┬───────┐ (only when present)
//! │ … bordered │ table │
//! └────────────┴───────┘
//! ```
//!
//! Blank-line separators sit between the three blocks. The
//! diagnostic table uses ADR-0017's bordered renderer (via
//! `output_render::render_diagnostic_table`) so its visual
//! style matches the lossy / incompatible refusal diagnostics
//! introduced there.
use crate::output_render::{Alignment, render_diagnostic_table};
/// Bordered row pinpoint produced by the translator's
/// post-failure re-query (ADR-0019 §6). The renderer hands
/// these straight to ADR-0017's `render_diagnostic_table`.
#[derive(Debug, Clone)]
pub struct DiagnosticTable {
pub headers: Vec<String>,
pub rows: Vec<Vec<String>>,
pub alignments: Vec<Alignment>,
}
/// Structured friendly-error payload (ADR-0019 §7).
///
/// `headline` is always present — a single, complete line of
/// "what happened". `hint` is the verbose-mode addition: one or
/// more lines of pedagogical "what to do next". `diagnostic_table`
/// is the (optional) row pinpoint surfaced through ADR-0017's
/// bordered renderer.
#[derive(Debug, Clone)]
pub struct FriendlyError {
pub headline: String,
pub hint: Option<String>,
pub diagnostic_table: Option<DiagnosticTable>,
}
impl FriendlyError {
/// Compose the payload into a single multi-line `String`.
/// Newline-separated; safe to feed into `note_error()`,
/// `eprintln!`, or any other plain-text sink.
#[must_use]
pub fn render(&self) -> String {
self.render_lines().join("\n")
}
/// Compose the payload into a sequence of display lines.
/// Used by the App layer where each output line is its own
/// `OutputLine` for accurate scroll-position math (per the
/// `App::push_multiline` invariant).
#[must_use]
pub fn render_lines(&self) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
// Headline can itself be multi-line if the catalog
// entry has embedded newlines (rare today; possible
// for future contributions). Split so each display
// line is its own entry.
for line in self.headline.lines() {
out.push(line.to_string());
}
if let Some(hint) = &self.hint {
out.push(String::new());
for line in hint.lines() {
out.push(line.to_string());
}
}
if let Some(table) = &self.diagnostic_table {
out.push(String::new());
out.extend(render_diagnostic_table(
&table.headers,
&table.rows,
&table.alignments,
));
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fe(headline: &str) -> FriendlyError {
FriendlyError {
headline: headline.to_string(),
hint: None,
diagnostic_table: None,
}
}
#[test]
fn renders_headline_only_when_no_hint_no_table() {
let f = fe("`Customers.id` already has the value `5`.");
let s = f.render();
assert_eq!(s, "`Customers.id` already has the value `5`.");
}
#[test]
fn renders_headline_blank_then_hint() {
let f = FriendlyError {
headline: "`Customers.id` already has the value `5`.".to_string(),
hint: Some("Pick a different value or update the existing row.".to_string()),
diagnostic_table: None,
};
let lines = f.render_lines();
assert_eq!(
lines,
vec![
"`Customers.id` already has the value `5`.".to_string(),
String::new(),
"Pick a different value or update the existing row.".to_string(),
],
);
}
#[test]
fn renders_headline_blank_table_with_no_hint() {
let f = FriendlyError {
headline: "rows still reference this".to_string(),
hint: None,
diagnostic_table: Some(DiagnosticTable {
headers: vec!["id".to_string(), "name".to_string()],
rows: vec![
vec!["1".to_string(), "Alice".to_string()],
vec!["2".to_string(), "Bob".to_string()],
],
alignments: vec![Alignment::Right, Alignment::Left],
}),
};
let lines = f.render_lines();
// headline + blank + bordered table (5+ lines).
assert_eq!(lines[0], "rows still reference this");
assert_eq!(lines[1], "");
// The first table line is the top border.
assert!(
lines[2].starts_with('┌'),
"expected bordered table after headline, got: {:?}",
lines[2]
);
// And the headers contain `id` and `name`.
assert!(
lines.iter().any(|l| l.contains("id") && l.contains("name")),
"missing headers row: {lines:?}"
);
}
#[test]
fn renders_all_three_with_blank_separators() {
let f = FriendlyError {
headline: "headline".to_string(),
hint: Some("hint".to_string()),
diagnostic_table: Some(DiagnosticTable {
headers: vec!["c".to_string()],
rows: vec![vec!["v".to_string()]],
alignments: vec![Alignment::Left],
}),
};
let lines = f.render_lines();
// headline, blank, hint, blank, then 5 lines of table
// = 9 minimum. We assert order rather than exact length
// so future renderer tweaks don't trip the test.
assert_eq!(lines[0], "headline");
assert_eq!(lines[1], "");
assert_eq!(lines[2], "hint");
assert_eq!(lines[3], "");
assert!(lines[4].starts_with('┌'));
}
#[test]
fn multi_line_headline_splits_at_newlines() {
// Future-proofing: if a catalog entry ever embeds a
// newline in the headline, the renderer treats each
// line independently rather than emitting one long row.
let f = fe("first line\nsecond line");
let lines = f.render_lines();
assert_eq!(lines[0], "first line");
assert_eq!(lines[1], "second line");
}
}