eac7e5b81d
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.
195 lines
6.6 KiB
Rust
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");
|
|
}
|
|
}
|