Files
rdbms-playground/src/friendly/translate.rs
T
claude@clouddev1 ed881eea59 2g: advanced-mode highlight + engine.* wiring + matrix tests
Cross-cut verification matrix for ADR-0032 Phase 2 is now fully
populated with concrete test references — every row green. Filling
the matrix surfaced three real gaps that this commit closes.

1. Advanced-mode syntax highlighting (ADR-0030 §8 matrix row).
   The `ui.rs` Advanced branch routed through `plain_input_spans`,
   bypassing the highlight walker entirely. In production SQL
   keywords past the entry word rendered as plain identifiers.
   Fix: mode-aware variants of `highlight_runs`,
   `render_input_runs`, `lex_to_runs`, and `input_diagnostics`;
   the Advanced render path now uses the highlighted form with
   `Mode::Advanced`. `plain_input_spans` removed (unused).

2. Engine.* key wiring (ADR-0032 §11.4 / §13 matrix rows + handoff
   §3.3 follow-up). The four Phase-2 engine.* catalog entries
   were authored in 2d but never reached: `translate_generic`
   discarded the engine message and returned a vague catalog
   entry. Fix: pattern-match the engine message text for the four
   Phase-2 categories (aggregate misuse, group-by required,
   compound arity mismatch fallback, scalar-subquery cardinality)
   inside `translate_generic`, routing each to its engine-neutral
   catalog entry.

3. Matrix-coverage tests. Thirteen new tests covering the rows
   that had no explicit coverage:
   - 3 SQL keyword/operator/CASE highlight tests
   - 4 engine.* engine-message tests
   - 3 sql_expr column-completion tests (WHERE, HAVING)
   - 3 predicate-warning slot tests (CASE, ORDER BY, projection)
   - 1 all-10-playground-types recovery test (tests/sql_select.rs)

Plan document (docs/plans/20260520-adr-0032-phase-2.md) updated:
every (TBD) row in the cross-cut matrix replaced with a concrete
test file::function reference and a green status marker.

Test totals: 1428 → 1441 passing (+13 new). Clippy clean.
2026-05-20 21:38:08 +00:00

1151 lines
39 KiB
Rust

//! 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::{DiagnosticTable, 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,
AddIndex,
DropIndex,
AddConstraint,
DropConstraint,
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::AddIndex => "add index",
Self::DropIndex => "drop index",
Self::AddConstraint => "add constraint",
Self::DropConstraint => "drop constraint",
Self::Query => "query",
Self::Rebuild => "rebuild",
Self::Replay => "replay",
Self::Other => "operation",
}
}
}
/// Schema-resolved facts about a failure (ADR-0019 §6).
///
/// Built by the runtime (where the `Database` handle is
/// available) and passed to the App via
/// [`crate::event::AppEvent::DslFailed`]. The App combines a
/// `FailureContext` with its current verbosity and the
/// operation derived from the originating `Command` to build a
/// [`TranslateContext`].
///
/// Every field is optional — the runtime fills what it can
/// resolve and leaves the rest `None`. The translator falls
/// back to its `{name}`-form placeholders where data is
/// missing.
#[derive(Debug, Clone, Default)]
pub struct FailureContext {
/// Operation table from the command (may differ from
/// `parent_table` for FK violations).
pub table: Option<String>,
/// Column name resolved from engine error (`UNIQUE
/// constraint failed: T.col` etc.) or via FK relationship
/// lookup.
pub column: Option<String>,
/// User's attempted value for the offending column.
pub value: Option<String>,
/// For child-side FK violations: the parent table the FK
/// references.
pub parent_table: Option<String>,
/// For child-side FK violations: the parent column the
/// FK references.
pub parent_column: Option<String>,
/// For parent-side FK violations: a child table that
/// references this row.
pub child_table: Option<String>,
/// Pinpointed offending row(s) per ADR-0019 §6 / ADR-0017
/// §7. Rendered through the bordered diagnostic-table
/// renderer when present.
pub diagnostic_table: Option<DiagnosticTable>,
/// For a `CHECK` violation: the column's compiled `CHECK`
/// expression, resolved from the schema (ADR-0029 §10). Lets
/// the friendly error name the rule the value broke.
pub check_rule: Option<String>,
}
/// Context the translator uses to pick catalog keys and fill
/// placeholders. Every field is optional — the translator falls
/// back to its `{name}`-form placeholders where context is
/// missing.
#[derive(Debug, Clone, Default)]
pub struct TranslateContext {
pub operation: Option<Operation>,
pub table: Option<String>,
pub column: Option<String>,
/// 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<String>,
/// For child-side FK violations: the parent table the FK
/// references. Surfaced as `{parent_table}` in
/// `error.foreign_key.child_side.*`.
pub parent_table: Option<String>,
/// For child-side FK violations: the parent column the FK
/// references. Surfaced as `{parent_column}`.
pub parent_column: Option<String>,
pub src_type: Option<Type>,
pub target_type: Option<Type>,
/// User-attempted value for INSERT/UPDATE.
pub value: Option<String>,
/// Pinpointed offending row(s); rendered onto the
/// `FriendlyError::diagnostic_table` field when present.
pub diagnostic_table: Option<DiagnosticTable>,
/// For a `CHECK` violation: the column's compiled `CHECK`
/// expression (ADR-0029 §10). When present, the friendly
/// error names the rule the value broke.
pub check_rule: Option<String>,
pub verbosity: Verbosity,
}
impl TranslateContext {
/// 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()
}
}
/// Combine schema-resolved facts with operation and
/// verbosity to build the full translator input.
#[must_use]
pub fn from_facts(
operation: Operation,
verbosity: Verbosity,
facts: FailureContext,
) -> Self {
Self {
operation: Some(operation),
table: facts.table,
column: facts.column,
child_table: facts.child_table,
parent_table: facts.parent_table,
parent_column: facts.parent_column,
src_type: None,
target_type: None,
value: facts.value,
diagnostic_table: facts.diagnostic_table,
check_rule: facts.check_rule,
verbosity,
}
}
}
/// Classify `error` and produce a structured [`FriendlyError`].
/// See module docs for the classification flow.
#[must_use]
pub fn translate(error: &DbError, ctx: &TranslateContext) -> FriendlyError {
let mut fe = 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",
),
};
// Attach the row pinpoint when the runtime resolved one.
// The translator never builds the table itself — it only
// forwards what enrichment supplied.
if fe.diagnostic_table.is_none() {
fe.diagnostic_table = ctx.diagnostic_table.clone();
}
fe
}
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 enrichment-driven first
// (`parent_table` populated → child-side; `child_table`
// populated → parent-side), with operation as the
// tiebreaker when enrichment didn't run.
//
// - Insert always points "outward" → child-side.
// - Delete always points "inward" → parent-side.
// - Update can be either; we let the enrichment payload
// choose, defaulting to child-side (the more pedagogically
// common case for a learner).
if ctx.parent_table.is_some() {
return match ctx.operation {
Some(Operation::Update) => fk_child_side_update(ctx),
_ => fk_child_side_insert(ctx),
};
}
if ctx.child_table.is_some() {
return match ctx.operation {
Some(Operation::Update) => fk_parent_side_update(ctx),
_ => fk_parent_side_delete(ctx),
};
}
match ctx.operation {
Some(Operation::Delete) => fk_parent_side_delete(ctx),
Some(Operation::Update) => fk_child_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_parent_table(ctx);
let parent_column = ctx_parent_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_child_side_update(ctx: &TranslateContext) -> FriendlyError {
let parent_table = ctx_parent_table(ctx);
let parent_column = ctx_parent_column(ctx);
let value = ctx_value(ctx);
fe(
t!(
"error.foreign_key.child_side.update.headline",
parent_table = parent_table,
parent_column = parent_column,
value = value
),
verbose_hint(
ctx,
t!(
"error.foreign_key.child_side.update.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(ctx);
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(ctx);
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 a `CHECK` failure by the column the
// constraint sits on; the runtime's enrichment resolves the
// table, the offending value, and — the teaching moment —
// the rule itself (ADR-0029 §10). When the rule could not
// be resolved, the plain hint stands on its own.
let table = ctx_table(ctx);
let column = ctx_column(ctx);
let is_update = matches!(ctx.operation, Some(Operation::Update));
let headline = if is_update {
t!("error.check.update.headline", table = table, column = column)
} else {
t!("error.check.insert.headline", table = table, column = column)
};
let hint = ctx.check_rule.as_ref().map_or_else(
|| {
if is_update {
t!("error.check.update.hint", column = column)
} else {
t!("error.check.insert.hint", column = column)
}
},
|rule| {
let value = ctx_value(ctx);
if is_update {
t!(
"error.check.update.hint_with_rule",
value = value,
rule = rule,
column = column
)
} else {
t!(
"error.check.insert.hint_with_rule",
value = value,
rule = rule,
column = column
)
}
},
);
fe(headline, verbose_hint(ctx, hint))
}
// ---- 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.
//
// ADR-0032 §11.5 engine-error translations: pattern-match
// the engine's message text for the four Phase-2 cases that
// arrive as `SqliteErrorKind::Other` and route each to its
// engine-neutral catalog entry. The classifier
// intentionally doesn't grow new SqliteErrorKind variants
// for these — they share a single fallback bucket and are
// distinguished by text pattern at translation time.
let lower = message.to_ascii_lowercase();
if lower.contains("misuse of aggregate") {
return headline_only(t!("engine.aggregate_misuse", name = "?"));
}
if lower.contains("group by")
|| lower.contains("must appear in")
{
return headline_only(t!("engine.group_by_required"));
}
if (lower.contains("union")
|| lower.contains("intersect")
|| lower.contains("except"))
&& lower.contains("result columns")
{
// Last-resort safety net — the pre-flight pass in 2d.1
// catches this in most cases; if the engine surfaces it
// anyway, route it through the engine-neutral key.
return headline_only(t!(
"engine.compound_arity_mismatch",
op = "set operator"
));
}
if lower.contains("scalar subquery") || lower.contains("more than one row") {
return headline_only(t!("engine.scalar_subquery_too_many_rows"));
}
if lower.contains("recursive")
&& (lower.contains("cte") || lower.contains("union"))
{
return headline_only(t!("engine.recursive_cte_malformed"));
}
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. With runtime-side
// enrichment (ADR-0019 §6) populating `FailureContext`,
// these fallbacks rarely render in practice.
fn ctx_table(ctx: &TranslateContext) -> String {
ctx.table.clone().unwrap_or_else(|| "{table}".to_string())
}
fn ctx_column(ctx: &TranslateContext) -> String {
ctx.column.clone().unwrap_or_else(|| "{column}".to_string())
}
fn ctx_value(ctx: &TranslateContext) -> String {
ctx.value.clone().unwrap_or_else(|| "{value}".to_string())
}
fn ctx_parent_table(ctx: &TranslateContext) -> String {
ctx.parent_table.clone().unwrap_or_else(|| "{parent_table}".to_string())
}
fn ctx_parent_column(ctx: &TranslateContext) -> String {
ctx.parent_column.clone().unwrap_or_else(|| "{parent_column}".to_string())
}
fn ctx_child_table(ctx: &TranslateContext) -> String {
ctx.child_table.clone().unwrap_or_else(|| "{child_table}".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 {
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.parent_table = Some("Customers".to_string());
ctx.parent_column = Some("id".to_string());
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("Customers"));
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".to_string());
ctx.child_table = Some("Orders".to_string());
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".to_string());
ctx.column = Some("age".to_string());
let f = translate(&err, &ctx);
assert!(f.headline.contains("check constraint refused"));
assert!(f.headline.contains("People"));
assert!(f.headline.contains("age"));
}
#[test]
fn check_with_a_resolved_rule_names_the_rule_and_value() {
// ADR-0029 §10: when enrichment resolves the column's
// compiled CHECK expression and the offending value,
// the hint names both.
let err = sqlite(
"CHECK constraint failed: score",
SqliteErrorKind::UniqueViolation,
);
let mut ctx = ctx_with(Operation::Insert);
ctx.table = Some("T".to_string());
ctx.column = Some("score".to_string());
ctx.value = Some("-5".to_string());
ctx.check_rule = Some("\"score\" >= 0".to_string());
let f = translate(&err, &ctx);
assert!(f.headline.contains("check constraint refused"));
let hint = f.hint.expect("a verbose hint");
assert!(hint.contains("-5"), "the offending value: {hint}");
assert!(hint.contains("\"score\" >= 0"), "the rule: {hint}");
}
// ---- 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);
}
// ---- ADR-0032 §11.5 engine.* keys ----
#[test]
fn aggregate_misuse_engine_message_routes_through_catalog() {
let err = sqlite(
"misuse of aggregate function COUNT()",
SqliteErrorKind::Other,
);
let f = translate(&err, &TranslateContext::default());
assert!(
f.headline.contains("aggregate"),
"expected engine.aggregate_misuse wording; got {}",
f.headline,
);
// Engine name (SQLite) must not appear (ADR-0002 posture).
assert!(
!f.headline.to_lowercase().contains("sqlite"),
"headline leaks engine name: {}",
f.headline,
);
}
#[test]
fn group_by_required_engine_message_routes_through_catalog() {
let err = sqlite(
"column must appear in the GROUP BY clause or be used in an aggregate function",
SqliteErrorKind::Other,
);
let f = translate(&err, &TranslateContext::default());
assert!(
f.headline.contains("GROUP BY"),
"expected engine.group_by_required wording; got {}",
f.headline,
);
}
#[test]
fn compound_arity_engine_message_routes_through_catalog() {
let err = sqlite(
"SELECTs to the left and right of UNION do not have the same number of result columns",
SqliteErrorKind::Other,
);
let f = translate(&err, &TranslateContext::default());
assert!(
f.headline.contains("number of columns"),
"expected engine.compound_arity_mismatch wording; got {}",
f.headline,
);
}
#[test]
fn scalar_subquery_too_many_rows_routes_through_catalog() {
let err = sqlite(
"scalar subquery returned more than one row",
SqliteErrorKind::Other,
);
let f = translate(&err, &TranslateContext::default());
assert!(
f.headline.contains("more than one row"),
"expected engine.scalar_subquery_too_many_rows wording; got {}",
f.headline,
);
}
}