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.
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
//! Catalog loader and `{name}` substitution (ADR-0019 §8.4).
|
||||
//!
|
||||
//! The catalog is an `include_str!`-embedded YAML file parsed
|
||||
//! once on first access. Hierarchical keys flatten to
|
||||
//! dot-separated paths so lookups are a single `HashMap` hit.
|
||||
//!
|
||||
//! Substitution rejects format specifiers (`{name:…}`),
|
||||
//! unknown / missing names, unterminated `{`, and stray `}`.
|
||||
//! `{{` and `}}` escape to literal `{` / `}`. Everything that
|
||||
//! ends in a `panic!` should have been caught by the validator
|
||||
//! unit test (Step 7) at build time.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::{Display, Write};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const EN_US: &str = include_str!("strings/en-US.yaml");
|
||||
|
||||
/// Loaded catalog: a flat map from dot-separated key to template.
|
||||
pub struct Catalog {
|
||||
entries: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
fn load() -> Self {
|
||||
let value: serde_yml::Value = serde_yml::from_str(EN_US)
|
||||
.expect("embedded en-US.yaml must parse (ADR-0019 §8.6 startup check)");
|
||||
let mut entries = HashMap::new();
|
||||
flatten(&value, String::new(), &mut entries);
|
||||
Self { entries }
|
||||
}
|
||||
|
||||
/// Look up a flat dot-separated key. Returns the template
|
||||
/// string with placeholders un-substituted.
|
||||
#[must_use]
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.entries.get(key).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Iterate every catalog key; used by the validator test.
|
||||
pub fn keys(&self) -> impl Iterator<Item = &str> {
|
||||
self.entries.keys().map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten(
|
||||
value: &serde_yml::Value,
|
||||
prefix: String,
|
||||
out: &mut HashMap<String, String>,
|
||||
) {
|
||||
match value {
|
||||
serde_yml::Value::Mapping(map) => {
|
||||
for (k, v) in map {
|
||||
let k_str = k
|
||||
.as_str()
|
||||
.expect("catalog keys must be strings");
|
||||
let next = if prefix.is_empty() {
|
||||
k_str.to_string()
|
||||
} else {
|
||||
format!("{prefix}.{k_str}")
|
||||
};
|
||||
flatten(v, next, out);
|
||||
}
|
||||
}
|
||||
serde_yml::Value::String(s) => {
|
||||
out.insert(prefix, s.clone());
|
||||
}
|
||||
// Empty top-level (Null) is fine — an empty catalog
|
||||
// loads as no entries. Anything else is a structure
|
||||
// error worth panicking over since the catalog is
|
||||
// shipped with the binary.
|
||||
serde_yml::Value::Null if prefix.is_empty() => {}
|
||||
other => panic!("catalog value at `{prefix}` is not a string: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Singleton catalog access. Loads on first call; subsequent
|
||||
/// calls are a `HashMap` lookup away.
|
||||
pub fn catalog() -> &'static Catalog {
|
||||
static C: OnceLock<Catalog> = OnceLock::new();
|
||||
C.get_or_init(Catalog::load)
|
||||
}
|
||||
|
||||
/// Look up `key` and substitute the supplied named arguments.
|
||||
/// See module docs for failure modes.
|
||||
pub fn translate(key: &str, args: &[(&str, &dyn Display)]) -> String {
|
||||
let template = catalog().get(key).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"missing catalog key: `{key}` (the validator should have caught this)"
|
||||
);
|
||||
});
|
||||
substitute(template, args, key)
|
||||
}
|
||||
|
||||
fn substitute(template: &str, args: &[(&str, &dyn Display)], key: &str) -> String {
|
||||
let mut out = String::with_capacity(template.len());
|
||||
let mut chars = template.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'{' => {
|
||||
if chars.peek() == Some(&'{') {
|
||||
chars.next();
|
||||
out.push('{');
|
||||
continue;
|
||||
}
|
||||
let mut name = String::new();
|
||||
let mut closed = false;
|
||||
while let Some(&nc) = chars.peek() {
|
||||
match nc {
|
||||
'}' => {
|
||||
chars.next();
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
':' => {
|
||||
panic!(
|
||||
"catalog key `{key}` uses a format specifier in \
|
||||
`{{{name}:...}}` — specifiers are not allowed \
|
||||
(ADR-0019 §8.4)"
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
chars.next();
|
||||
name.push(nc);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !closed {
|
||||
panic!("catalog key `{key}` has unterminated `{{`");
|
||||
}
|
||||
if name.is_empty() {
|
||||
panic!("catalog key `{key}` has empty `{{}}` placeholder");
|
||||
}
|
||||
let value = args
|
||||
.iter()
|
||||
.find(|(n, _)| *n == name)
|
||||
.map(|(_, v)| v)
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"catalog key `{key}` references `{{{name}}}` but that \
|
||||
argument was not supplied"
|
||||
);
|
||||
});
|
||||
write!(out, "{value}").expect("writing to String never fails");
|
||||
}
|
||||
'}' => {
|
||||
if chars.peek() == Some(&'}') {
|
||||
chars.next();
|
||||
out.push('}');
|
||||
} else {
|
||||
panic!("catalog key `{key}` has stray `}}`");
|
||||
}
|
||||
}
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn catalog_loads_at_least_the_test_entry() {
|
||||
// Step 1 ships a single sanity entry under `_test.*`;
|
||||
// its presence proves the loader walks the YAML and
|
||||
// flattens hierarchical groups correctly.
|
||||
assert!(
|
||||
catalog().get("_test.hello").is_some(),
|
||||
"expected `_test.hello` in catalog; entries: {:?}",
|
||||
catalog().keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn substitute_replaces_named_placeholder() {
|
||||
let s = substitute("Hello, {name}!", &[("name", &"World")], "_test.hello");
|
||||
assert_eq!(s, "Hello, World!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn substitute_handles_multiple_placeholders() {
|
||||
let s = substitute(
|
||||
"{a} + {b} = {sum}",
|
||||
&[("a", &1), ("b", &2), ("sum", &3)],
|
||||
"_test.math",
|
||||
);
|
||||
assert_eq!(s, "1 + 2 = 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn substitute_supports_doubled_braces_as_literals() {
|
||||
let s = substitute(
|
||||
"set with {{count}} = {count}",
|
||||
&[("count", &5)],
|
||||
"_test.literals",
|
||||
);
|
||||
assert_eq!(s, "set with {count} = 5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn substitute_passes_display_values_unchanged() {
|
||||
// Display for an i64 — no padding, no thousands separator.
|
||||
// ADR-0019 §8.7: value formats stay invariant.
|
||||
let s = substitute("n = {n}", &[("n", &1234567_i64)], "_test.display");
|
||||
assert_eq!(s, "n = 1234567");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "format specifier")]
|
||||
#[allow(clippy::literal_string_with_formatting_args)]
|
||||
fn substitute_rejects_format_specifier() {
|
||||
// The literal `{n:08}` is intentional — that's the
|
||||
// shape we want to refuse.
|
||||
let _ = substitute("padded: {n:08}", &[("n", &5)], "_test.specifier");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "not supplied")]
|
||||
fn substitute_rejects_unknown_placeholder() {
|
||||
let _ = substitute("Hello, {who}!", &[("name", &"World")], "_test.unknown");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "unterminated")]
|
||||
fn substitute_rejects_unterminated_brace() {
|
||||
let _ = substitute("oh no {", &[], "_test.unterm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "stray")]
|
||||
fn substitute_rejects_stray_close_brace() {
|
||||
let _ = substitute("oh no }", &[], "_test.stray");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "empty")]
|
||||
fn substitute_rejects_empty_placeholder() {
|
||||
let _ = substitute("oh no {}", &[], "_test.empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_macro_dispatches_to_translate() {
|
||||
// Sanity check that the macro produces the same output
|
||||
// as a direct call. Uses the `_test.hello` entry from
|
||||
// the catalog.
|
||||
let m = crate::t!("_test.hello", name = "World");
|
||||
let d = translate("_test.hello", &[("name", &"World")]);
|
||||
assert_eq!(m, d);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
//! Per-category catalog key schemas (ADR-0019 §8.3).
|
||||
//!
|
||||
//! Every catalog key the friendly-error layer references at
|
||||
//! runtime is enumerated here together with its expected
|
||||
//! placeholder set. The validator
|
||||
//! (`tests::keys_validate_against_catalog`) walks this list and
|
||||
//! asserts:
|
||||
//!
|
||||
//! - the key exists in the catalog;
|
||||
//! - every placeholder declared here appears at least once in
|
||||
//! the template;
|
||||
//! - no placeholder appears in the template that isn't declared
|
||||
//! here (catches typos in either direction);
|
||||
//! - every catalog key (outside the `_test.*` sanity group) is
|
||||
//! declared here (catches dead YAML entries).
|
||||
//!
|
||||
//! Adding a new translation site is a two-step change: add the
|
||||
//! key + placeholders here, add the YAML entry. Either alone
|
||||
//! fails the validator.
|
||||
//!
|
||||
//! ## Convention
|
||||
//!
|
||||
//! Each error entry in the catalog has:
|
||||
//!
|
||||
//! - a `.headline` template — used in both short and verbose
|
||||
//! modes;
|
||||
//! - optionally a `.hint` template — surfaced only in verbose
|
||||
//! mode.
|
||||
//!
|
||||
//! Single-line errors (object-not-found, already-exists,
|
||||
//! invalid-value) have no hint; the headline carries the whole
|
||||
//! message.
|
||||
//!
|
||||
//! Other categories (`help.*`, `ok.*`, `client_side.*`,
|
||||
//! `replay.*`, `parse.*`, modal labels, …) get added to this
|
||||
//! list as the migration sweep (ADR-0019 §9) lands them.
|
||||
|
||||
/// `(key, expected_placeholders)`. Sorted by key for grep-ability.
|
||||
pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
// ---- Already-exists collisions (anchor: "already exists") ----
|
||||
("error.already_exists.column.headline", &["table", "column"]),
|
||||
("error.already_exists.relationship.headline", &["name"]),
|
||||
("error.already_exists.table.headline", &["name"]),
|
||||
// ---- CHECK violations ----
|
||||
("error.check.insert.headline", &["table", "column"]),
|
||||
("error.check.insert.hint", &["column"]),
|
||||
("error.check.update.headline", &["table", "column"]),
|
||||
("error.check.update.hint", &["column"]),
|
||||
// ---- FK violations (anchor: "referenced by") ----
|
||||
(
|
||||
"error.foreign_key.child_side.insert.headline",
|
||||
&["parent_table", "parent_column", "value"],
|
||||
),
|
||||
(
|
||||
"error.foreign_key.child_side.insert.hint",
|
||||
&["parent_table", "parent_column"],
|
||||
),
|
||||
(
|
||||
"error.foreign_key.child_side.update.headline",
|
||||
&["parent_table", "parent_column", "value"],
|
||||
),
|
||||
(
|
||||
"error.foreign_key.child_side.update.hint",
|
||||
&["parent_table", "parent_column"],
|
||||
),
|
||||
(
|
||||
"error.foreign_key.parent_side.delete.headline",
|
||||
&["table", "child_table"],
|
||||
),
|
||||
("error.foreign_key.parent_side.delete.hint", &[]),
|
||||
(
|
||||
"error.foreign_key.parent_side.update.headline",
|
||||
&["table", "child_table"],
|
||||
),
|
||||
("error.foreign_key.parent_side.update.hint", &[]),
|
||||
// ---- Generic engine refusal ----
|
||||
("error.generic.headline", &["operation"]),
|
||||
("error.generic.hint", &["table"]),
|
||||
// ---- Invalid-value errors (pre-engine, single-line) ----
|
||||
(
|
||||
"error.invalid_value.arity.headline",
|
||||
&["expected", "actual"],
|
||||
),
|
||||
("error.invalid_value.empty_insert.headline", &[]),
|
||||
("error.invalid_value.empty_update.headline", &[]),
|
||||
// ---- Not-found errors (anchor: "no such ...") ----
|
||||
("error.not_found.column.headline", &["table", "column"]),
|
||||
("error.not_found.column_unqualified.headline", &["column"]),
|
||||
("error.not_found.relationship.headline", &["name"]),
|
||||
("error.not_found.table.headline", &["name"]),
|
||||
// ---- NOT NULL violations ----
|
||||
("error.not_null.insert.headline", &["table", "column"]),
|
||||
("error.not_null.insert.hint", &["column"]),
|
||||
("error.not_null.update.headline", &["table", "column"]),
|
||||
("error.not_null.update.hint", &["column"]),
|
||||
// ---- Type mismatch ----
|
||||
(
|
||||
"error.type_mismatch.change_column.headline",
|
||||
&["table", "column", "src_type", "target_type"],
|
||||
),
|
||||
(
|
||||
"error.type_mismatch.change_column.hint",
|
||||
&["target_type"],
|
||||
),
|
||||
(
|
||||
"error.type_mismatch.insert.headline",
|
||||
&["value", "expected_type"],
|
||||
),
|
||||
(
|
||||
"error.type_mismatch.insert.hint",
|
||||
&["table", "column", "expected_type"],
|
||||
),
|
||||
(
|
||||
"error.type_mismatch.update.headline",
|
||||
&["value", "expected_type"],
|
||||
),
|
||||
(
|
||||
"error.type_mismatch.update.hint",
|
||||
&["table", "column", "expected_type"],
|
||||
),
|
||||
// ---- UNIQUE violations (anchor: "already has the value") ----
|
||||
(
|
||||
"error.unique.insert.headline",
|
||||
&["table", "column", "value"],
|
||||
),
|
||||
("error.unique.insert.hint", &["table", "column"]),
|
||||
("error.unique.pk.insert.headline", &["table", "value"]),
|
||||
("error.unique.pk.insert.hint", &[]),
|
||||
("error.unique.pk.update.headline", &["table", "value"]),
|
||||
("error.unique.pk.update.hint", &[]),
|
||||
(
|
||||
"error.unique.update.headline",
|
||||
&["table", "column", "value"],
|
||||
),
|
||||
("error.unique.update.hint", &["table", "column"]),
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::KEYS_AND_PLACEHOLDERS;
|
||||
use crate::friendly::format::catalog;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Walks `KEYS_AND_PLACEHOLDERS` and verifies every entry
|
||||
/// matches the catalog. ADR-0019 §8.6.
|
||||
///
|
||||
/// Checks:
|
||||
/// 1. every declared key exists in the catalog;
|
||||
/// 2. every declared placeholder appears in the template;
|
||||
/// 3. every placeholder used is declared (catches typos);
|
||||
/// 4. every catalog key (outside `_test.*`) is declared
|
||||
/// (catches dead YAML entries);
|
||||
/// 5. no template contains a format specifier
|
||||
/// (`{name:...}`); ADR-0019 §8.4 forbids these;
|
||||
/// 6. no template contains forbidden engine vocabulary
|
||||
/// (ADR-0002 user-facing posture; same forbidden list
|
||||
/// as `tests/engine_vocabulary_audit.rs`).
|
||||
#[test]
|
||||
fn keys_validate_against_catalog() {
|
||||
let cat = catalog();
|
||||
let mut errors: Vec<String> = Vec::new();
|
||||
|
||||
for (key, expected) in KEYS_AND_PLACEHOLDERS {
|
||||
let Some(template) = cat.get(key) else {
|
||||
errors.push(format!("catalog missing key `{key}`"));
|
||||
continue;
|
||||
};
|
||||
|
||||
// Placeholder set check (declared ↔ used).
|
||||
let actual = collect_placeholders(template);
|
||||
let expected_set: HashSet<&str> = expected.iter().copied().collect();
|
||||
for name in &expected_set {
|
||||
if !actual.contains(*name) {
|
||||
errors.push(format!(
|
||||
"key `{key}`: declared placeholder `{{{name}}}` is not used in template:\n{template}"
|
||||
));
|
||||
}
|
||||
}
|
||||
for name in &actual {
|
||||
if !expected_set.contains(name.as_str()) {
|
||||
errors.push(format!(
|
||||
"key `{key}`: template uses `{{{name}}}` but it isn't declared in keys.rs:\n{template}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Format-specifier check (ADR-0019 §8.4). Look for
|
||||
// `{name:...}` shapes — the substitute helper would
|
||||
// panic at runtime, but catching it at test time
|
||||
// means we never ship a binary that can hit that
|
||||
// panic.
|
||||
if has_format_specifier(template) {
|
||||
errors.push(format!(
|
||||
"key `{key}`: template contains a `{{name:...}}` format specifier:\n{template}"
|
||||
));
|
||||
}
|
||||
|
||||
// Engine-vocabulary check (ADR-0002 user-facing
|
||||
// posture, regression-tested in
|
||||
// tests/engine_vocabulary_audit.rs).
|
||||
for needle in FORBIDDEN_ENGINE_VOCABULARY {
|
||||
if template.contains(needle) {
|
||||
errors.push(format!(
|
||||
"key `{key}`: template contains forbidden token `{needle}`:\n{template}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let declared: HashSet<&str> =
|
||||
KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect();
|
||||
for key in cat.keys() {
|
||||
if key.starts_with("_test.") {
|
||||
continue;
|
||||
}
|
||||
if !declared.contains(key) {
|
||||
errors.push(format!(
|
||||
"catalog has key `{key}` but it isn't declared in keys::KEYS_AND_PLACEHOLDERS"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
errors.is_empty(),
|
||||
"catalog validation failed:\n {}",
|
||||
errors.join("\n ")
|
||||
);
|
||||
}
|
||||
|
||||
/// Mirror of `tests/engine_vocabulary_audit.rs::FORBIDDEN`,
|
||||
/// duplicated here so the catalog validator is self-contained
|
||||
/// (no dependency on the integration-test binary).
|
||||
const FORBIDDEN_ENGINE_VOCABULARY: &[&str] = &[
|
||||
"SQLite", "sqlite", "rusqlite", "STRICT", "PRAGMA",
|
||||
];
|
||||
|
||||
/// Detect a `{name:...}` format-specifier placeholder.
|
||||
/// Doubled braces `{{` / `}}` are escapes — must skip them.
|
||||
fn has_format_specifier(template: &str) -> bool {
|
||||
let mut chars = template.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '{' {
|
||||
if chars.peek() == Some(&'{') {
|
||||
chars.next();
|
||||
continue;
|
||||
}
|
||||
while let Some(&nc) = chars.peek() {
|
||||
if nc == '}' {
|
||||
break;
|
||||
}
|
||||
if nc == ':' {
|
||||
return true;
|
||||
}
|
||||
chars.next();
|
||||
}
|
||||
} else if c == '}' && chars.peek() == Some(&'}') {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Walk `template` and pull out every `{name}` placeholder.
|
||||
/// Mirrors the substitution helper's parse — if the helper
|
||||
/// accepts a placeholder, this collects it.
|
||||
fn collect_placeholders(template: &str) -> HashSet<String> {
|
||||
let mut out = HashSet::new();
|
||||
let mut chars = template.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '{' {
|
||||
if chars.peek() == Some(&'{') {
|
||||
chars.next();
|
||||
continue;
|
||||
}
|
||||
let mut name = String::new();
|
||||
while let Some(&nc) = chars.peek() {
|
||||
if nc == '}' {
|
||||
chars.next();
|
||||
break;
|
||||
}
|
||||
chars.next();
|
||||
name.push(nc);
|
||||
}
|
||||
if !name.is_empty() && !name.contains(':') {
|
||||
out.insert(name);
|
||||
}
|
||||
} else if c == '}' && chars.peek() == Some(&'}') {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Friendly error layer and i18n message catalog (ADR-0019).
|
||||
//!
|
||||
//! Single chokepoint for user-visible message text. Engine
|
||||
//! errors flow through `translate()` into a structured
|
||||
//! [`FriendlyError`] payload that the renderer (in
|
||||
//! `output_render`) composes into final output. Every other
|
||||
//! user-visible string in the codebase migrates to this
|
||||
//! catalog over time via the [`t!`] macro (ADR-0019 §9).
|
||||
//!
|
||||
//! ## Catalog
|
||||
//!
|
||||
//! The catalog lives in `strings/<locale>.yaml`, embedded at
|
||||
//! compile time and parsed once on first access. Today the only
|
||||
//! locale is `en-US`; runtime selection is deferred (ADR-0019
|
||||
//! §8.2). Hierarchical YAML keys flatten to dot-paths
|
||||
//! internally — `error.unique.insert.verbose` is the path the
|
||||
//! `t!()` macro and the translator look up.
|
||||
//!
|
||||
//! ## The `t!()` macro
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use rdbms_playground::t;
|
||||
//! let s = t!("error.unique.insert.verbose",
|
||||
//! table = "Customers", column = "id");
|
||||
//! ```
|
||||
//!
|
||||
//! Placeholder values implement `Display`. Format specifiers
|
||||
//! (`{name:08.2}`, `{name:>10}`, …) are explicitly rejected at
|
||||
//! substitution time — see ADR-0019 §8.4.
|
||||
|
||||
pub mod error;
|
||||
pub mod format;
|
||||
pub mod keys;
|
||||
pub mod translate;
|
||||
|
||||
pub use error::{DiagnosticTable, FriendlyError};
|
||||
pub use format::{catalog, Catalog};
|
||||
pub use translate::{Operation, TranslateContext, Verbosity};
|
||||
|
||||
// `translate::translate` and `format::translate` are different
|
||||
// callables — the former is the structured DbError → FriendlyError
|
||||
// classifier (the H1 entry point); the latter is the lower-level
|
||||
// catalog lookup the `t!()` macro expands to. Re-export both
|
||||
// under non-conflicting names.
|
||||
pub use format::translate;
|
||||
pub use translate::translate as translate_error;
|
||||
|
||||
/// Look up `key` in the catalog and substitute named arguments.
|
||||
///
|
||||
/// Panics if the key is missing, if the template has malformed
|
||||
/// placeholders, or if the args don't supply every name the
|
||||
/// template references. The catalog validator unit test (Step 7,
|
||||
/// ADR-0019 §8.6) catches these at build time so they should
|
||||
/// never fire at runtime.
|
||||
#[macro_export]
|
||||
macro_rules! t {
|
||||
($key:literal $(, $name:ident = $value:expr)* $(,)?) => {{
|
||||
$crate::friendly::translate(
|
||||
$key,
|
||||
&[$( (stringify!($name), &$value as &dyn ::std::fmt::Display) ),*],
|
||||
)
|
||||
}};
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
# en-US catalog (ADR-0019).
|
||||
#
|
||||
# Hierarchical groups flatten to dot-paths internally:
|
||||
# error.unique.insert.headline
|
||||
# error.unique.insert.hint
|
||||
# help.cli_banner
|
||||
# replay.completed
|
||||
# … etc.
|
||||
#
|
||||
# Each error entry has a `headline` (one line, used in both
|
||||
# short and verbose modes) and may have a `hint` (one or more
|
||||
# lines, surfaced only in verbose mode). Short mode = headline
|
||||
# only; verbose mode = headline + hint + (if present) the
|
||||
# diagnostic table the translator built (ADR-0019 §7).
|
||||
#
|
||||
# Anchor phrases per ADR-0019 §10 are kept stable across
|
||||
# wording changes:
|
||||
# "no such table"
|
||||
# "no such column"
|
||||
# "no such relationship"
|
||||
# "already exists"
|
||||
# "already has the value"
|
||||
# "cannot be converted"
|
||||
# "discard information"
|
||||
# "referenced by"
|
||||
# "[client-side]"
|
||||
#
|
||||
# Placeholders use `{name}` substitution; format specifiers
|
||||
# (`{name:08.2}`, `{name:>10}`, …) are explicitly rejected by
|
||||
# the substitution helper (ADR-0019 §8.4).
|
||||
|
||||
# Sanity entry exercised by the loader's unit tests; not
|
||||
# user-facing.
|
||||
_test:
|
||||
hello: "Hello, {name}!"
|
||||
|
||||
# ---- Error category --------------------------------------------------
|
||||
error:
|
||||
|
||||
# UNIQUE constraint violations. Anchor: "already has the value".
|
||||
unique:
|
||||
insert:
|
||||
headline: "`{table}.{column}` already has the value `{value}`."
|
||||
hint: "The `{column}` column on `{table}` is unique — pick a different value, or update the existing row instead."
|
||||
update:
|
||||
headline: "`{table}.{column}` already has the value `{value}`."
|
||||
hint: "The `{column}` column on `{table}` is unique — your update would create a duplicate."
|
||||
# Primary-key collisions get distinct wording — the user
|
||||
# learns that PK is the canonical unique constraint.
|
||||
pk:
|
||||
insert:
|
||||
headline: "`{table}` already has a row with primary key `{value}`."
|
||||
hint: "Primary keys must be unique — pick a different value or update the existing row."
|
||||
update:
|
||||
headline: "`{table}` already has a row with primary key `{value}`."
|
||||
hint: "Primary keys must be unique — your update would create a duplicate."
|
||||
|
||||
# FOREIGN KEY violations. Anchor: "referenced by".
|
||||
foreign_key:
|
||||
# Child-side: insert/update sets a value that has no parent.
|
||||
child_side:
|
||||
insert:
|
||||
headline: "no parent row in `{parent_table}` has `{parent_column}` = `{value}`."
|
||||
hint: "Foreign keys must point at an existing parent row. Insert a matching parent first, or pick a value that already exists in `{parent_table}.{parent_column}`."
|
||||
update:
|
||||
headline: "no parent row in `{parent_table}` has `{parent_column}` = `{value}`."
|
||||
hint: "Foreign keys must point at an existing parent row. Pick a value that already exists in `{parent_table}.{parent_column}`."
|
||||
# Parent-side: delete/update on a row referenced by children.
|
||||
# The engine refuses unless the relationship's `on delete /
|
||||
# on update` says cascade or set null.
|
||||
parent_side:
|
||||
delete:
|
||||
headline: "`{table}` rows are referenced by `{child_table}`."
|
||||
hint: "Deleting these rows would orphan the children. Delete the children first, or change the relationship's `on delete` action to `cascade` or `set null`."
|
||||
update:
|
||||
headline: "`{table}` rows are referenced by `{child_table}`."
|
||||
hint: "Updating the referenced column would orphan the children. Update the children first, or change the relationship's `on update` action to `cascade`."
|
||||
|
||||
# NOT NULL constraint violations.
|
||||
not_null:
|
||||
insert:
|
||||
headline: "`{table}.{column}` cannot be null."
|
||||
hint: "The `{column}` column is required — provide a value for it in the row you are inserting."
|
||||
update:
|
||||
headline: "`{table}.{column}` cannot be null."
|
||||
hint: "The `{column}` column is required — pick a non-null value, or do not include `{column}` in your `set` list."
|
||||
|
||||
# CHECK constraint violations. Placeholder coverage —
|
||||
# the playground does not emit CHECK constraints today
|
||||
# (track C3), but the catalog is wired so the wording
|
||||
# is ready when constraint-management lands.
|
||||
check:
|
||||
insert:
|
||||
headline: "check constraint refused `{table}.{column}`."
|
||||
hint: "A check constraint requires `{column}` to satisfy a rule the inserted value did not."
|
||||
update:
|
||||
headline: "check constraint refused `{table}.{column}`."
|
||||
hint: "A check constraint requires `{column}` to satisfy a rule the new value did not."
|
||||
|
||||
# Type mismatch — engine-side STRICT refusal of a wrong-shape
|
||||
# value. Mostly the `change column ... --dont-convert` path
|
||||
# today (subsumes `friendly_change_column_engine_error`).
|
||||
type_mismatch:
|
||||
change_column:
|
||||
headline: "cannot change `{table}.{column}` from `{src_type}` to `{target_type}` with `--dont-convert`."
|
||||
hint: "The database refused at least one cell as the wrong shape for `{target_type}`. Re-run without `--dont-convert` to see which rows."
|
||||
insert:
|
||||
headline: "value `{value}` is not a `{expected_type}`."
|
||||
hint: "The `{column}` column on `{table}` is `{expected_type}` — provide a `{expected_type}` value, or change the column's type with `change column`."
|
||||
update:
|
||||
headline: "value `{value}` is not a `{expected_type}`."
|
||||
hint: "The `{column}` column on `{table}` is `{expected_type}` — provide a `{expected_type}` value, or change the column's type with `change column`."
|
||||
|
||||
# Object-not-found errors. Anchor: "no such ...". These are
|
||||
# genuinely single-line errors — no hint adds value.
|
||||
not_found:
|
||||
table:
|
||||
headline: "no such table: `{name}`"
|
||||
column:
|
||||
headline: "no such column: `{table}.{column}`"
|
||||
column_unqualified:
|
||||
headline: "no such column: `{column}`"
|
||||
relationship:
|
||||
headline: "no such relationship: `{name}`"
|
||||
|
||||
# Name-collision errors. Anchor: "already exists".
|
||||
already_exists:
|
||||
table:
|
||||
headline: "table `{name}` already exists"
|
||||
column:
|
||||
headline: "column `{table}.{column}` already exists"
|
||||
relationship:
|
||||
headline: "relationship `{name}` already exists"
|
||||
|
||||
# Generic catch-all when the translator can't classify the
|
||||
# engine error into a known category. The wording stays
|
||||
# engine-neutral; the message text from the engine is NOT
|
||||
# surfaced (ADR-0002 user-facing posture).
|
||||
generic:
|
||||
headline: "the database refused this `{operation}`."
|
||||
hint: "The operation could not be completed against the current state of `{table}`."
|
||||
|
||||
# Errors that are specifically about value validation
|
||||
# (DbError::InvalidValue) — wrong arity, wrong literal
|
||||
# form, etc. Pre-engine; the catalog covers what the
|
||||
# parser / validator already decided was wrong.
|
||||
invalid_value:
|
||||
arity:
|
||||
headline: "expected {expected} value(s), got {actual}."
|
||||
empty_insert:
|
||||
headline: "INSERT requires at least one column value."
|
||||
empty_update:
|
||||
headline: "UPDATE requires at least one assignment."
|
||||
@@ -0,0 +1,871 @@
|
||||
//! Translator: classify a [`DbError`] and produce a
|
||||
//! [`FriendlyError`] (ADR-0019 §2, §3).
|
||||
//!
|
||||
//! The translator is the chokepoint that absorbs the previous
|
||||
//! ad-hoc helpers (`friendly_change_column_engine_error`,
|
||||
//! `enrich_fk_message`). It pattern-matches on the structured
|
||||
//! [`SqliteErrorKind`] first; for the constraint-violation
|
||||
//! family (UNIQUE / FK / NOT NULL / CHECK) it then classifies
|
||||
//! by message text, since rusqlite folds them all under one
|
||||
//! kind today.
|
||||
//!
|
||||
//! Wording flows entirely through the catalog ([`crate::t!`])
|
||||
//! and never echoes engine error text verbatim.
|
||||
//!
|
||||
//! ## Row pinpointing
|
||||
//!
|
||||
//! ADR-0019 §6 calls for re-querying the database after a
|
||||
//! constraint failure to surface the offending row(s) through
|
||||
//! ADR-0017's bordered diagnostic-table renderer. That layer
|
||||
//! is plumbed structurally — [`FriendlyError::diagnostic_table`]
|
||||
//! exists for it — but the actual re-query implementation is a
|
||||
//! separate commit alongside its runtime-side wiring (the
|
||||
//! translator needs the user's attempted values, which arrive
|
||||
//! through [`TranslateContext`] populated at the runtime
|
||||
//! callsite).
|
||||
//!
|
||||
//! For now, [`FriendlyError::diagnostic_table`] is always
|
||||
//! `None` and the wording carries the full burden. Adding the
|
||||
//! diagnostic table later is purely additive: the translator's
|
||||
//! catalog keys and the renderer don't change.
|
||||
|
||||
use crate::db::{DbError, SqliteErrorKind};
|
||||
use crate::dsl::Type;
|
||||
use crate::friendly::error::FriendlyError;
|
||||
use crate::t;
|
||||
|
||||
/// Verbosity of the rendered error.
|
||||
///
|
||||
/// `Short` → headline only. `Verbose` → headline + hint (when
|
||||
/// the catalog entry has one) + diagnostic table (when present).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Verbosity {
|
||||
/// Headline only.
|
||||
Short,
|
||||
/// Headline + hint + diagnostic table. Default per ADR-0019 §5.
|
||||
#[default]
|
||||
Verbose,
|
||||
}
|
||||
|
||||
/// The user-visible operation that produced the error.
|
||||
///
|
||||
/// Drives operation-tailored wording (ADR-0019 §4) and is
|
||||
/// surfaced verbatim in `error.generic.*` as the `{operation}`
|
||||
/// placeholder.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Operation {
|
||||
Insert,
|
||||
Update,
|
||||
Delete,
|
||||
CreateTable,
|
||||
DropTable,
|
||||
AddColumn,
|
||||
DropColumn,
|
||||
RenameColumn,
|
||||
ChangeColumnType,
|
||||
AddRelationship,
|
||||
DropRelationship,
|
||||
Query,
|
||||
Rebuild,
|
||||
Replay,
|
||||
/// Catch-all for callsites with no specific operation
|
||||
/// context (e.g. opening a database, listing tables).
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Operation {
|
||||
/// Short user-facing label for the operation; appears in
|
||||
/// `error.generic.headline`'s `{operation}` placeholder.
|
||||
/// Stable wording — anchor-phrase-equivalent for future
|
||||
/// migration churn.
|
||||
#[must_use]
|
||||
pub const fn keyword(self) -> &'static str {
|
||||
match self {
|
||||
Self::Insert => "insert",
|
||||
Self::Update => "update",
|
||||
Self::Delete => "delete",
|
||||
Self::CreateTable => "create table",
|
||||
Self::DropTable => "drop table",
|
||||
Self::AddColumn => "add column",
|
||||
Self::DropColumn => "drop column",
|
||||
Self::RenameColumn => "rename column",
|
||||
Self::ChangeColumnType => "change column",
|
||||
Self::AddRelationship => "add relationship",
|
||||
Self::DropRelationship => "drop relationship",
|
||||
Self::Query => "query",
|
||||
Self::Rebuild => "rebuild",
|
||||
Self::Replay => "replay",
|
||||
Self::Other => "operation",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Context the translator uses to pick catalog keys and fill
|
||||
/// placeholders. Every field is optional — the translator falls
|
||||
/// back to abstract wording where context is missing.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TranslateContext<'a> {
|
||||
pub operation: Option<Operation>,
|
||||
pub table: Option<&'a str>,
|
||||
pub column: Option<&'a str>,
|
||||
/// For parent-side FK violations: the child table that
|
||||
/// references this row. Surfaced as `{child_table}` in
|
||||
/// `error.foreign_key.parent_side.*`.
|
||||
pub child_table: Option<&'a str>,
|
||||
pub src_type: Option<Type>,
|
||||
pub target_type: Option<Type>,
|
||||
/// User-attempted value for INSERT/UPDATE; surfaced as the
|
||||
/// `{value}` placeholder in UNIQUE / FK / type-mismatch
|
||||
/// wording. Best-effort: callsites populate when they have
|
||||
/// it; translator falls back to `<value>` otherwise.
|
||||
pub value: Option<String>,
|
||||
pub verbosity: Verbosity,
|
||||
}
|
||||
|
||||
impl<'a> TranslateContext<'a> {
|
||||
/// Convenience constructor for the common "I just have an
|
||||
/// operation" case.
|
||||
#[must_use]
|
||||
pub fn for_op(operation: Operation) -> Self {
|
||||
Self {
|
||||
operation: Some(operation),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify `error` and produce a structured [`FriendlyError`].
|
||||
/// See module docs for the classification flow.
|
||||
#[must_use]
|
||||
pub fn translate(error: &DbError, ctx: &TranslateContext<'_>) -> FriendlyError {
|
||||
match error {
|
||||
DbError::Sqlite { message, kind } => translate_sqlite(message, *kind, ctx),
|
||||
// Unsupported / InvalidValue carry text that is already
|
||||
// engine-neutral and friendly (constructed by our own
|
||||
// refusal sites). Catalog entries exist for the typed
|
||||
// invalid-value cases but the migration sweep
|
||||
// (ADR-0019 §9) is what wires them. For now, passthrough.
|
||||
DbError::Unsupported(message) | DbError::InvalidValue(message) => {
|
||||
passthrough(message)
|
||||
}
|
||||
DbError::PersistenceFatal { message, .. }
|
||||
| DbError::RebuildRowFailed { detail: message, .. }
|
||||
| DbError::Io(message) => passthrough(message),
|
||||
DbError::WorkerGone => passthrough(
|
||||
"the database worker is no longer available — the application must restart",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn translate_sqlite(
|
||||
message: &str,
|
||||
kind: SqliteErrorKind,
|
||||
ctx: &TranslateContext<'_>,
|
||||
) -> FriendlyError {
|
||||
// `change column ... --dont-convert` lets the engine
|
||||
// accept or refuse each cell. Whatever the engine returns
|
||||
// (constraint, datatype mismatch, …) means "the new type
|
||||
// didn't fit at least one cell". Route to the dedicated
|
||||
// change-column wording — subsumes the role
|
||||
// `friendly_change_column_engine_error` previously played.
|
||||
if matches!(ctx.operation, Some(Operation::ChangeColumnType)) {
|
||||
return translate_type_mismatch_change_column(ctx);
|
||||
}
|
||||
match kind {
|
||||
SqliteErrorKind::NoSuchTable => translate_not_found_table(message, ctx),
|
||||
SqliteErrorKind::NoSuchColumn => translate_not_found_column(message, ctx),
|
||||
SqliteErrorKind::AlreadyExists => translate_already_exists(message, ctx),
|
||||
// SqliteErrorKind::UniqueViolation is currently the
|
||||
// bucket for *every* constraint violation (UNIQUE, FK,
|
||||
// NOT NULL, CHECK) since rusqlite folds them under
|
||||
// ConstraintViolation. We split by message text here.
|
||||
SqliteErrorKind::UniqueViolation => translate_constraint(message, ctx),
|
||||
SqliteErrorKind::Other => translate_generic(message, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
fn translate_type_mismatch_change_column(ctx: &TranslateContext<'_>) -> FriendlyError {
|
||||
let table = ctx_table(ctx);
|
||||
let column = ctx_column(ctx);
|
||||
let src_type = ctx
|
||||
.src_type
|
||||
.map_or_else(|| "?".to_string(), |t| t.keyword().to_string());
|
||||
let target_type = ctx
|
||||
.target_type
|
||||
.map_or_else(|| "?".to_string(), |t| t.keyword().to_string());
|
||||
fe(
|
||||
t!(
|
||||
"error.type_mismatch.change_column.headline",
|
||||
table = table,
|
||||
column = column,
|
||||
src_type = src_type,
|
||||
target_type = target_type
|
||||
),
|
||||
verbose_hint(
|
||||
ctx,
|
||||
t!(
|
||||
"error.type_mismatch.change_column.hint",
|
||||
target_type = target_type
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn translate_constraint(message: &str, ctx: &TranslateContext<'_>) -> FriendlyError {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
if lower.contains("unique constraint failed") {
|
||||
translate_unique(message, ctx)
|
||||
} else if lower.contains("foreign key constraint failed") {
|
||||
translate_foreign_key(ctx)
|
||||
} else if lower.contains("not null constraint failed") {
|
||||
translate_not_null(message, ctx)
|
||||
} else if lower.contains("check constraint failed") {
|
||||
translate_check(message, ctx)
|
||||
} else {
|
||||
translate_generic(message, ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- UNIQUE -----------------------------------------------------
|
||||
|
||||
fn translate_unique(message: &str, ctx: &TranslateContext<'_>) -> FriendlyError {
|
||||
let (table, column) = parse_qualified_target(message)
|
||||
.unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
|
||||
let value = ctx_value(ctx);
|
||||
match ctx.operation {
|
||||
Some(Operation::Update) => fe(
|
||||
t!(
|
||||
"error.unique.update.headline",
|
||||
table = table,
|
||||
column = column,
|
||||
value = value
|
||||
),
|
||||
verbose_hint(
|
||||
ctx,
|
||||
t!(
|
||||
"error.unique.update.hint",
|
||||
table = table,
|
||||
column = column
|
||||
),
|
||||
),
|
||||
),
|
||||
// Default to the INSERT variant — it's the most common
|
||||
// path and the wording is symmetric enough that an
|
||||
// unknown-operation INSERT-vs-UPDATE confusion isn't
|
||||
// misleading.
|
||||
_ => fe(
|
||||
t!(
|
||||
"error.unique.insert.headline",
|
||||
table = table,
|
||||
column = column,
|
||||
value = value
|
||||
),
|
||||
verbose_hint(
|
||||
ctx,
|
||||
t!(
|
||||
"error.unique.insert.hint",
|
||||
table = table,
|
||||
column = column
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- FOREIGN KEY -----------------------------------------------
|
||||
|
||||
fn translate_foreign_key(ctx: &TranslateContext<'_>) -> FriendlyError {
|
||||
// The engine's "FOREIGN KEY constraint failed" carries no
|
||||
// detail. Disambiguation is operation-driven: child-side
|
||||
// happens on INSERT/UPDATE (the row being written points at
|
||||
// a missing parent); parent-side happens on DELETE/UPDATE
|
||||
// (the row being deleted is referenced by a child).
|
||||
//
|
||||
// Without context we default to child-side INSERT, which
|
||||
// is the more common case and matches the wording of the
|
||||
// pre-H1 enrich_fk_message helper.
|
||||
match ctx.operation {
|
||||
Some(Operation::Delete) => fk_parent_side_delete(ctx),
|
||||
Some(Operation::Update) => fk_parent_side_update(ctx),
|
||||
Some(Operation::Insert) => fk_child_side_insert(ctx),
|
||||
_ => fk_child_side_insert(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
fn fk_child_side_insert(ctx: &TranslateContext<'_>) -> FriendlyError {
|
||||
let parent_table = ctx_table(ctx);
|
||||
let parent_column = ctx_column(ctx);
|
||||
let value = ctx_value(ctx);
|
||||
fe(
|
||||
t!(
|
||||
"error.foreign_key.child_side.insert.headline",
|
||||
parent_table = parent_table,
|
||||
parent_column = parent_column,
|
||||
value = value
|
||||
),
|
||||
verbose_hint(
|
||||
ctx,
|
||||
t!(
|
||||
"error.foreign_key.child_side.insert.hint",
|
||||
parent_table = parent_table,
|
||||
parent_column = parent_column
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn fk_parent_side_delete(ctx: &TranslateContext<'_>) -> FriendlyError {
|
||||
let table = ctx_table(ctx);
|
||||
let child_table = ctx
|
||||
.child_table
|
||||
.map_or_else(|| "another table".to_string(), str::to_string);
|
||||
fe(
|
||||
t!(
|
||||
"error.foreign_key.parent_side.delete.headline",
|
||||
table = table,
|
||||
child_table = child_table
|
||||
),
|
||||
verbose_hint(ctx, t!("error.foreign_key.parent_side.delete.hint")),
|
||||
)
|
||||
}
|
||||
|
||||
fn fk_parent_side_update(ctx: &TranslateContext<'_>) -> FriendlyError {
|
||||
let table = ctx_table(ctx);
|
||||
let child_table = ctx
|
||||
.child_table
|
||||
.map_or_else(|| "another table".to_string(), str::to_string);
|
||||
fe(
|
||||
t!(
|
||||
"error.foreign_key.parent_side.update.headline",
|
||||
table = table,
|
||||
child_table = child_table
|
||||
),
|
||||
verbose_hint(ctx, t!("error.foreign_key.parent_side.update.hint")),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- NOT NULL --------------------------------------------------
|
||||
|
||||
fn translate_not_null(message: &str, ctx: &TranslateContext<'_>) -> FriendlyError {
|
||||
let (table, column) = parse_qualified_target(message)
|
||||
.unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
|
||||
match ctx.operation {
|
||||
Some(Operation::Update) => fe(
|
||||
t!(
|
||||
"error.not_null.update.headline",
|
||||
table = table,
|
||||
column = column
|
||||
),
|
||||
verbose_hint(ctx, t!("error.not_null.update.hint", column = column)),
|
||||
),
|
||||
_ => fe(
|
||||
t!(
|
||||
"error.not_null.insert.headline",
|
||||
table = table,
|
||||
column = column
|
||||
),
|
||||
verbose_hint(ctx, t!("error.not_null.insert.hint", column = column)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CHECK -----------------------------------------------------
|
||||
|
||||
fn translate_check(_message: &str, ctx: &TranslateContext<'_>) -> FriendlyError {
|
||||
// The engine reports CHECK constraint failures by constraint
|
||||
// name, not by column. We don't have user-named CHECK
|
||||
// constraints today, so the message is rarely informative.
|
||||
// Surface what we have via context.
|
||||
let table = ctx_table(ctx);
|
||||
let column = ctx_column(ctx);
|
||||
match ctx.operation {
|
||||
Some(Operation::Update) => fe(
|
||||
t!(
|
||||
"error.check.update.headline",
|
||||
table = table,
|
||||
column = column
|
||||
),
|
||||
verbose_hint(ctx, t!("error.check.update.hint", column = column)),
|
||||
),
|
||||
_ => fe(
|
||||
t!(
|
||||
"error.check.insert.headline",
|
||||
table = table,
|
||||
column = column
|
||||
),
|
||||
verbose_hint(ctx, t!("error.check.insert.hint", column = column)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- not_found / already_exists --------------------------------
|
||||
|
||||
fn translate_not_found_table(message: &str, ctx: &TranslateContext<'_>) -> FriendlyError {
|
||||
let name = parse_after_colon(message)
|
||||
.map_or_else(|| ctx_table(ctx), str::to_string);
|
||||
headline_only(t!("error.not_found.table.headline", name = name))
|
||||
}
|
||||
|
||||
fn translate_not_found_column(message: &str, ctx: &TranslateContext<'_>) -> FriendlyError {
|
||||
let name = parse_after_colon(message).unwrap_or("");
|
||||
if let Some((table, column)) = name.split_once('.') {
|
||||
headline_only(t!(
|
||||
"error.not_found.column.headline",
|
||||
table = table,
|
||||
column = column
|
||||
))
|
||||
} else if !name.is_empty() {
|
||||
headline_only(t!(
|
||||
"error.not_found.column_unqualified.headline",
|
||||
column = name
|
||||
))
|
||||
} else {
|
||||
headline_only(t!(
|
||||
"error.not_found.column.headline",
|
||||
table = ctx_table(ctx),
|
||||
column = ctx_column(ctx)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn translate_already_exists(message: &str, ctx: &TranslateContext<'_>) -> FriendlyError {
|
||||
// Three shapes feed in:
|
||||
// - Engine: "table T already exists"
|
||||
// - Our own: "column `T.col` already exists; pick a different name."
|
||||
// - Our own: "a relationship named `name` already exists. ..."
|
||||
// The catalog has typed entries; if we can't classify, fall
|
||||
// back to passthrough (the existing wording is already
|
||||
// engine-neutral).
|
||||
if let Some(name) = extract_quoted(message) {
|
||||
if let Some((table, column)) = name.split_once('.') {
|
||||
return headline_only(t!(
|
||||
"error.already_exists.column.headline",
|
||||
table = table,
|
||||
column = column
|
||||
));
|
||||
}
|
||||
return headline_only(t!(
|
||||
"error.already_exists.table.headline",
|
||||
name = name
|
||||
));
|
||||
}
|
||||
// No backticks — engine-style "table T already exists".
|
||||
if let Some(name) = parse_after_word(message, "table") {
|
||||
return headline_only(t!(
|
||||
"error.already_exists.table.headline",
|
||||
name = name
|
||||
));
|
||||
}
|
||||
if let Some(name) = parse_after_word(message, "relationship") {
|
||||
return headline_only(t!(
|
||||
"error.already_exists.relationship.headline",
|
||||
name = name
|
||||
));
|
||||
}
|
||||
// Fall back to context.
|
||||
let _ = ctx;
|
||||
passthrough(message)
|
||||
}
|
||||
|
||||
// ---- Generic catch-all -----------------------------------------
|
||||
|
||||
fn translate_generic(_message: &str, ctx: &TranslateContext<'_>) -> FriendlyError {
|
||||
// Engine message is intentionally NOT surfaced — ADR-0002
|
||||
// posture. The catalog provides the abstract wording.
|
||||
let operation = ctx
|
||||
.operation
|
||||
.map_or("operation", Operation::keyword);
|
||||
let table = ctx_table(ctx);
|
||||
fe(
|
||||
t!("error.generic.headline", operation = operation),
|
||||
verbose_hint(ctx, t!("error.generic.hint", table = table)),
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Helpers ---------------------------------------------------
|
||||
|
||||
fn passthrough<S: Into<String>>(s: S) -> FriendlyError {
|
||||
FriendlyError {
|
||||
headline: s.into(),
|
||||
hint: None,
|
||||
diagnostic_table: None,
|
||||
}
|
||||
}
|
||||
|
||||
const fn headline_only(headline: String) -> FriendlyError {
|
||||
FriendlyError {
|
||||
headline,
|
||||
hint: None,
|
||||
diagnostic_table: None,
|
||||
}
|
||||
}
|
||||
|
||||
const fn fe(headline: String, hint: Option<String>) -> FriendlyError {
|
||||
FriendlyError {
|
||||
headline,
|
||||
hint,
|
||||
diagnostic_table: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn verbose_hint(ctx: &TranslateContext<'_>, hint: String) -> Option<String> {
|
||||
if ctx.verbosity == Verbosity::Verbose {
|
||||
Some(hint)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback markers when context can't supply a value. We use
|
||||
// the catalog's `{name}` form so unfilled positions read as
|
||||
// "this placeholder was not supplied" — same shape the
|
||||
// translator's source uses, easier to grep, and visually
|
||||
// consistent with the catalog templates. Filling these
|
||||
// properly across the board needs schema-aware enrichment in
|
||||
// the runtime; that work is bundled with the row-pinpoint
|
||||
// re-query (ADR-0019 §6) since both need the Database handle.
|
||||
|
||||
fn ctx_table(ctx: &TranslateContext<'_>) -> String {
|
||||
ctx.table.map_or_else(|| "{table}".to_string(), str::to_string)
|
||||
}
|
||||
|
||||
fn ctx_column(ctx: &TranslateContext<'_>) -> String {
|
||||
ctx.column.map_or_else(|| "{column}".to_string(), str::to_string)
|
||||
}
|
||||
|
||||
fn ctx_value(ctx: &TranslateContext<'_>) -> String {
|
||||
ctx.value.clone().unwrap_or_else(|| "{value}".to_string())
|
||||
}
|
||||
|
||||
/// Extract `T.col` from a message like
|
||||
/// `"UNIQUE constraint failed: T.col"`. Returns `(T, col)` or
|
||||
/// `None` if the message doesn't have the expected shape.
|
||||
fn parse_qualified_target(message: &str) -> Option<(String, String)> {
|
||||
let after = parse_after_colon(message)?;
|
||||
let first_target = after.split(',').next()?.trim();
|
||||
let mut parts = first_target.splitn(2, '.');
|
||||
let table = parts.next()?.trim();
|
||||
let column = parts.next()?.trim();
|
||||
if table.is_empty() || column.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((table.to_string(), column.to_string()))
|
||||
}
|
||||
|
||||
/// Return the substring after the first `:`, trimmed. None if
|
||||
/// no colon present.
|
||||
fn parse_after_colon(message: &str) -> Option<&str> {
|
||||
message.split_once(':').map(|(_, rest)| rest.trim())
|
||||
}
|
||||
|
||||
/// Find a backtick-quoted substring in `message` (the wording
|
||||
/// our own refusal sites use for object names). Returns the
|
||||
/// content of the first quoted run, or None.
|
||||
fn extract_quoted(message: &str) -> Option<&str> {
|
||||
let start = message.find('`')? + 1;
|
||||
let rest = &message[start..];
|
||||
let end = rest.find('`')?;
|
||||
Some(&rest[..end])
|
||||
}
|
||||
|
||||
/// Find the token immediately after `keyword` in `message`.
|
||||
/// Used for parsing engine-style "table T already exists" and
|
||||
/// "relationship name already exists".
|
||||
fn parse_after_word<'a>(message: &'a str, keyword: &str) -> Option<&'a str> {
|
||||
let pos = message.find(keyword)? + keyword.len();
|
||||
let rest = message[pos..].trim_start();
|
||||
let token_end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
|
||||
let token = rest[..token_end].trim_matches(|c: char| c == '`' || c == '\'');
|
||||
if token.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(token)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ctx_with(op: Operation) -> TranslateContext<'static> {
|
||||
TranslateContext {
|
||||
operation: Some(op),
|
||||
..TranslateContext::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn sqlite(message: &str, kind: SqliteErrorKind) -> DbError {
|
||||
DbError::Sqlite {
|
||||
message: message.to_string(),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- UNIQUE ----
|
||||
|
||||
#[test]
|
||||
fn unique_insert_uses_anchor_phrase_and_picks_insert_template() {
|
||||
let err = sqlite(
|
||||
"UNIQUE constraint failed: Customers.id",
|
||||
SqliteErrorKind::UniqueViolation,
|
||||
);
|
||||
let mut ctx = ctx_with(Operation::Insert);
|
||||
ctx.value = Some("5".to_string());
|
||||
let f = translate(&err, &ctx);
|
||||
// Anchor phrase ADR-0019 §10.
|
||||
assert!(
|
||||
f.headline.contains("already has the value"),
|
||||
"missing anchor: {}",
|
||||
f.headline
|
||||
);
|
||||
assert!(f.headline.contains("Customers"));
|
||||
assert!(f.headline.contains("id"));
|
||||
assert!(f.headline.contains("`5`"));
|
||||
// Verbose mode → hint present.
|
||||
let hint = f.hint.expect("verbose default → hint");
|
||||
assert!(hint.contains("pick a different value"));
|
||||
assert!(hint.contains("update the existing row"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_update_picks_update_template() {
|
||||
let err = sqlite(
|
||||
"UNIQUE constraint failed: Customers.id",
|
||||
SqliteErrorKind::UniqueViolation,
|
||||
);
|
||||
let f = translate(&err, &ctx_with(Operation::Update));
|
||||
let hint = f.hint.expect("verbose default → hint");
|
||||
// Update wording differs from insert — operation-tailored.
|
||||
assert!(
|
||||
hint.contains("would create a duplicate"),
|
||||
"expected update wording: {hint}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_mode_omits_hint() {
|
||||
let err = sqlite(
|
||||
"UNIQUE constraint failed: Customers.id",
|
||||
SqliteErrorKind::UniqueViolation,
|
||||
);
|
||||
let mut ctx = ctx_with(Operation::Insert);
|
||||
ctx.verbosity = Verbosity::Short;
|
||||
let f = translate(&err, &ctx);
|
||||
assert!(f.hint.is_none(), "short mode → no hint, got {:?}", f.hint);
|
||||
// Headline still present.
|
||||
assert!(f.headline.contains("already has the value"));
|
||||
}
|
||||
|
||||
// ---- FK ----
|
||||
|
||||
#[test]
|
||||
fn fk_with_insert_op_renders_child_side_wording() {
|
||||
let err = sqlite(
|
||||
"FOREIGN KEY constraint failed",
|
||||
SqliteErrorKind::UniqueViolation,
|
||||
);
|
||||
let mut ctx = ctx_with(Operation::Insert);
|
||||
ctx.table = Some("Customers");
|
||||
ctx.column = Some("id");
|
||||
ctx.value = Some("99".to_string());
|
||||
let f = translate(&err, &ctx);
|
||||
assert!(
|
||||
f.headline.contains("no parent row"),
|
||||
"expected child-side phrasing: {}",
|
||||
f.headline
|
||||
);
|
||||
assert!(f.headline.contains("`99`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fk_with_delete_op_renders_parent_side_wording() {
|
||||
let err = sqlite(
|
||||
"FOREIGN KEY constraint failed",
|
||||
SqliteErrorKind::UniqueViolation,
|
||||
);
|
||||
let mut ctx = ctx_with(Operation::Delete);
|
||||
ctx.table = Some("Customers");
|
||||
ctx.child_table = Some("Orders");
|
||||
let f = translate(&err, &ctx);
|
||||
// Anchor phrase: "referenced by".
|
||||
assert!(
|
||||
f.headline.contains("referenced by"),
|
||||
"expected parent-side phrasing with anchor: {}",
|
||||
f.headline
|
||||
);
|
||||
assert!(f.headline.contains("Customers"));
|
||||
assert!(f.headline.contains("Orders"));
|
||||
}
|
||||
|
||||
// ---- NOT NULL ----
|
||||
|
||||
#[test]
|
||||
fn not_null_uses_typed_template() {
|
||||
let err = sqlite(
|
||||
"NOT NULL constraint failed: Customers.email",
|
||||
SqliteErrorKind::UniqueViolation,
|
||||
);
|
||||
let f = translate(&err, &ctx_with(Operation::Insert));
|
||||
assert!(f.headline.contains("cannot be null"));
|
||||
assert!(f.headline.contains("Customers"));
|
||||
assert!(f.headline.contains("email"));
|
||||
}
|
||||
|
||||
// ---- CHECK ----
|
||||
|
||||
#[test]
|
||||
fn check_uses_typed_template_and_falls_back_to_context() {
|
||||
let err = sqlite(
|
||||
"CHECK constraint failed: chk_age",
|
||||
SqliteErrorKind::UniqueViolation,
|
||||
);
|
||||
let mut ctx = ctx_with(Operation::Insert);
|
||||
ctx.table = Some("People");
|
||||
ctx.column = Some("age");
|
||||
let f = translate(&err, &ctx);
|
||||
assert!(f.headline.contains("check constraint refused"));
|
||||
assert!(f.headline.contains("People"));
|
||||
assert!(f.headline.contains("age"));
|
||||
}
|
||||
|
||||
// ---- not_found ----
|
||||
|
||||
#[test]
|
||||
fn no_such_table_uses_anchor_phrase() {
|
||||
let err = sqlite("no such table: Ghost", SqliteErrorKind::NoSuchTable);
|
||||
let f = translate(&err, &TranslateContext::default());
|
||||
// Anchor phrase ADR-0019 §10.
|
||||
assert!(
|
||||
f.headline.contains("no such table"),
|
||||
"missing anchor: {}",
|
||||
f.headline
|
||||
);
|
||||
assert!(f.headline.contains("Ghost"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_such_column_qualified() {
|
||||
let err = sqlite("no such column: T.zip", SqliteErrorKind::NoSuchColumn);
|
||||
let f = translate(&err, &TranslateContext::default());
|
||||
assert!(f.headline.contains("no such column"));
|
||||
assert!(f.headline.contains("T"));
|
||||
assert!(f.headline.contains("zip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_such_column_unqualified_uses_unqualified_key() {
|
||||
let err = sqlite("no such column: zip", SqliteErrorKind::NoSuchColumn);
|
||||
let f = translate(&err, &TranslateContext::default());
|
||||
assert!(f.headline.contains("no such column"));
|
||||
assert!(f.headline.contains("zip"));
|
||||
}
|
||||
|
||||
// ---- already_exists ----
|
||||
|
||||
#[test]
|
||||
fn already_exists_quoted_column_uses_column_template() {
|
||||
let err = sqlite(
|
||||
"column `T.x` already exists; pick a different name.",
|
||||
SqliteErrorKind::AlreadyExists,
|
||||
);
|
||||
let f = translate(&err, &TranslateContext::default());
|
||||
assert!(f.headline.contains("already exists"));
|
||||
assert!(f.headline.contains("T"));
|
||||
assert!(f.headline.contains("x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_exists_quoted_table_uses_table_template() {
|
||||
let err = sqlite(
|
||||
"table `Customers` already exists",
|
||||
SqliteErrorKind::AlreadyExists,
|
||||
);
|
||||
let f = translate(&err, &TranslateContext::default());
|
||||
assert!(f.headline.contains("already exists"));
|
||||
assert!(f.headline.contains("Customers"));
|
||||
}
|
||||
|
||||
// ---- generic ----
|
||||
|
||||
#[test]
|
||||
fn generic_path_uses_operation_keyword() {
|
||||
let err = sqlite(
|
||||
"some unrecognised constraint failure",
|
||||
SqliteErrorKind::UniqueViolation,
|
||||
);
|
||||
let f = translate(&err, &ctx_with(Operation::AddRelationship));
|
||||
// Falls into translate_constraint → generic since no
|
||||
// recognised constraint keyword.
|
||||
assert!(
|
||||
f.headline.contains("add relationship") || f.headline.contains("refused"),
|
||||
"got: {}",
|
||||
f.headline
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_engine_message_is_not_surfaced() {
|
||||
// ADR-0002 posture: the engine's text never reaches the
|
||||
// user verbatim. Check explicitly.
|
||||
let secret = "SQLite-flavoured detail with engine internals";
|
||||
let err = sqlite(secret, SqliteErrorKind::Other);
|
||||
let f = translate(&err, &ctx_with(Operation::Insert));
|
||||
let rendered = f.render();
|
||||
assert!(
|
||||
!rendered.contains(secret),
|
||||
"engine message leaked into translated output:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- passthrough variants ----
|
||||
|
||||
#[test]
|
||||
fn unsupported_passes_through() {
|
||||
let err = DbError::Unsupported(
|
||||
"cannot drop primary-key column `T.id`. Drop the table or change the primary key first."
|
||||
.to_string(),
|
||||
);
|
||||
let f = translate(&err, &TranslateContext::default());
|
||||
assert!(f.headline.contains("primary-key"));
|
||||
assert!(f.hint.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_value_passes_through() {
|
||||
let err = DbError::InvalidValue("expected 3 value(s), got 2".to_string());
|
||||
let f = translate(&err, &TranslateContext::default());
|
||||
assert_eq!(f.headline, "expected 3 value(s), got 2");
|
||||
}
|
||||
|
||||
// ---- internal helpers ----
|
||||
|
||||
#[test]
|
||||
fn parse_qualified_target_works_for_unique_message() {
|
||||
assert_eq!(
|
||||
parse_qualified_target("UNIQUE constraint failed: T.col"),
|
||||
Some(("T".to_string(), "col".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_qualified_target_returns_first_for_compound() {
|
||||
assert_eq!(
|
||||
parse_qualified_target("UNIQUE constraint failed: T.a, T.b"),
|
||||
Some(("T".to_string(), "a".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_qualified_target_none_when_no_dot() {
|
||||
assert_eq!(
|
||||
parse_qualified_target("FOREIGN KEY constraint failed"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_quoted_pulls_first_backtick_run() {
|
||||
assert_eq!(extract_quoted("column `T.x` already exists"), Some("T.x"));
|
||||
assert_eq!(extract_quoted("no backticks here"), None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user