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,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