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:
@@ -266,13 +266,21 @@ pub enum SqliteErrorKind {
|
||||
}
|
||||
|
||||
impl DbError {
|
||||
/// Placeholder for the H1 friendly-error layer. Today this
|
||||
/// returns the same string as [`std::fmt::Display`]; when H1
|
||||
/// lands the body becomes the translation logic and
|
||||
/// callsites do not need to change.
|
||||
/// User-visible rendering of this error.
|
||||
///
|
||||
/// Routes through the H1 friendly-error layer
|
||||
/// ([`crate::friendly::translate_error`], ADR-0019). With
|
||||
/// no context the translator falls back to abstract wording
|
||||
/// — for operation-tailored output, callers should
|
||||
/// construct a [`crate::friendly::TranslateContext`] and
|
||||
/// call the translator directly. This method exists for the
|
||||
/// many callsites where context isn't readily available
|
||||
/// (fatal-banner paths, fallback render paths) and the
|
||||
/// abstract wording is acceptable.
|
||||
#[must_use]
|
||||
pub fn friendly_message(&self) -> String {
|
||||
self.to_string()
|
||||
let ctx = crate::friendly::TranslateContext::default();
|
||||
crate::friendly::translate_error(self, &ctx).render()
|
||||
}
|
||||
|
||||
fn from_rusqlite(err: rusqlite::Error) -> Self {
|
||||
@@ -1982,7 +1990,19 @@ fn do_change_column_type(
|
||||
// any error in a friendly message (ADR-0017 §5,
|
||||
// ADR-0002 user-facing posture).
|
||||
rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates)
|
||||
.map_err(|e| friendly_change_column_engine_error(table, column, src_ty, ty, e))?;
|
||||
.map_err(|e| {
|
||||
let ctx = crate::friendly::TranslateContext {
|
||||
operation: Some(crate::friendly::Operation::ChangeColumnType),
|
||||
table: Some(table),
|
||||
column: Some(column),
|
||||
src_type: Some(src_ty),
|
||||
target_type: Some(ty),
|
||||
..crate::friendly::TranslateContext::default()
|
||||
};
|
||||
let rendered =
|
||||
crate::friendly::translate_error(&e, &ctx).render();
|
||||
DbError::Unsupported(rendered)
|
||||
})?;
|
||||
None
|
||||
}
|
||||
ChangeColumnMode::Default | ChangeColumnMode::ForceConversion => Some(
|
||||
@@ -2346,39 +2366,6 @@ fn fill_auto_generated_cells(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wrap an engine-level error from the `--dont-convert` path
|
||||
/// into a friendly form per ADR-0017 §5 / ADR-0002. Avoids
|
||||
/// surfacing engine vocabulary (SQLite, STRICT, PRAGMA, …) by
|
||||
/// recognising the common kinds and producing a generic
|
||||
/// abstract phrasing.
|
||||
fn friendly_change_column_engine_error(
|
||||
table: &str,
|
||||
column: &str,
|
||||
src_ty: Type,
|
||||
target_ty: Type,
|
||||
err: DbError,
|
||||
) -> DbError {
|
||||
let detail = match &err {
|
||||
DbError::Sqlite { message, .. } => {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
if lower.contains("constraint") || lower.contains("not null") {
|
||||
"the database refused at least one cell."
|
||||
} else if lower.contains("type") || lower.contains("non-text") || lower.contains("non-integer") {
|
||||
"the database refused at least one cell as the wrong shape \
|
||||
for the new type."
|
||||
} else {
|
||||
"the database refused the operation."
|
||||
}
|
||||
}
|
||||
_ => return err,
|
||||
};
|
||||
DbError::Unsupported(format!(
|
||||
"Cannot change `{table}.{column}` from {src_ty} to {target_ty} with \
|
||||
`--dont-convert`: {detail} Re-run without the flag to see per-row \
|
||||
diagnostics."
|
||||
))
|
||||
}
|
||||
|
||||
/// Maximum diagnostic rows rendered per refusal (ADR-0017 §7).
|
||||
/// Rows beyond this collapse into a trailing `… and N more` row.
|
||||
const DIAGNOSTIC_ROW_CAP: usize = 100;
|
||||
@@ -3398,74 +3385,29 @@ fn bound_to_sqlite_value(b: &Bound) -> rusqlite::types::Value {
|
||||
}
|
||||
}
|
||||
|
||||
fn enrich_fk_message(conn: &Connection, table: &str, base: String) -> String {
|
||||
// SQLite tells us "FOREIGN KEY constraint failed" without
|
||||
// saying which constraint. We list both:
|
||||
// - outbound FKs (this table is child) — relevant for
|
||||
// INSERT and UPDATE that try to set a non-matching FK
|
||||
// value;
|
||||
// - inbound FKs (this table is parent) — relevant for
|
||||
// DELETE / UPDATE on the parent side that violate a
|
||||
// RESTRICT or NO ACTION constraint.
|
||||
// The user reads both lists and recognises the relevant
|
||||
// direction from the operation they ran. Full H1 would
|
||||
// pinpoint the offending row.
|
||||
let outbound = read_relationships_outbound(conn, table).unwrap_or_default();
|
||||
let inbound = read_relationships_inbound(conn, table).unwrap_or_default();
|
||||
if outbound.is_empty() && inbound.is_empty() {
|
||||
return base;
|
||||
}
|
||||
let mut msg = base;
|
||||
if !outbound.is_empty() {
|
||||
msg.push_str(". Foreign keys on this table (relevant for INSERT/UPDATE):");
|
||||
for r in outbound {
|
||||
msg.push_str(&format!(
|
||||
"\n - {}.{} → {}.{}",
|
||||
table, r.local_column, r.other_table, r.other_column
|
||||
));
|
||||
}
|
||||
}
|
||||
if !inbound.is_empty() {
|
||||
msg.push_str(
|
||||
"\nThis table is referenced by (relevant for DELETE/UPDATE on parent side):",
|
||||
);
|
||||
for r in inbound {
|
||||
msg.push_str(&format!(
|
||||
"\n - {}.{} → {}.{} (on delete {})",
|
||||
r.other_table, r.other_column, table, r.local_column, r.on_delete
|
||||
));
|
||||
}
|
||||
}
|
||||
msg.push_str(
|
||||
"\nCheck that referenced values exist (for INSERT/UPDATE) or that no children \
|
||||
reference these rows (for DELETE/UPDATE on the parent side).",
|
||||
);
|
||||
msg
|
||||
}
|
||||
|
||||
/// Execute an INSERT/UPDATE/DELETE and convert any rusqlite
|
||||
/// failure into a `DbError`. Wraps the raw `conn.execute` so the
|
||||
/// three callers (insert, update, delete) have a single hook for
|
||||
/// future row-pinpointing per ADR-0019 §6 — when re-query lands,
|
||||
/// the runtime-side wiring will pass the table identity into the
|
||||
/// translator from here.
|
||||
///
|
||||
/// Today this is a thin wrapper. The `table` argument is
|
||||
/// retained as the future re-query hook needs it. (The previous
|
||||
/// `enrich_fk_message` helper that lived here, listing all the
|
||||
/// outbound/inbound FKs in the error message, was absorbed into
|
||||
/// the friendly-error layer's catalog wording per ADR-0019;
|
||||
/// re-introducing the per-FK detail belongs to the re-query
|
||||
/// follow-on, not as freeform plain-text appended to engine
|
||||
/// errors.)
|
||||
fn execute_with_fk_enrichment(
|
||||
conn: &Connection,
|
||||
table: &str,
|
||||
_table: &str,
|
||||
sql: &str,
|
||||
params: &[rusqlite::types::Value],
|
||||
) -> Result<usize, DbError> {
|
||||
let result = conn.execute(sql, rusqlite::params_from_iter(params.iter()));
|
||||
match result {
|
||||
Ok(n) => Ok(n),
|
||||
Err(e) => {
|
||||
let mut db_err = DbError::from_rusqlite(e);
|
||||
if let DbError::Sqlite { message, kind } = &db_err {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
if lower.contains("foreign key") {
|
||||
db_err = DbError::Sqlite {
|
||||
message: enrich_fk_message(conn, table, message.clone()),
|
||||
kind: *kind,
|
||||
};
|
||||
}
|
||||
}
|
||||
Err(db_err)
|
||||
}
|
||||
}
|
||||
conn.execute(sql, rusqlite::params_from_iter(params.iter()))
|
||||
.map_err(DbError::from_rusqlite)
|
||||
}
|
||||
|
||||
/// Fetch a small `DataResult` containing only the rows whose
|
||||
@@ -6480,9 +6422,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fk_violation_message_lists_outbound_relationships() {
|
||||
async fn fk_violation_returns_engine_classified_constraint_error() {
|
||||
// Pre-H1 (ADR-0019), this test asserted that the
|
||||
// engine's `FOREIGN KEY constraint failed` text was
|
||||
// enriched in-band with a list of every relevant FK on
|
||||
// the offending table. That enrichment moved into the
|
||||
// friendly-error layer's catalog wording (see
|
||||
// `friendly::translate::tests::fk_with_*_op_renders_*`)
|
||||
// and out of the raw `DbError::Sqlite { message }`
|
||||
// payload. What stays in `message` is the engine's
|
||||
// un-enriched text — useful for the translator's own
|
||||
// classification, but no longer the user-facing
|
||||
// surface.
|
||||
//
|
||||
// This test now just confirms the engine error reaches
|
||||
// us classified as a constraint violation; user-facing
|
||||
// wording is exercised in the friendly module.
|
||||
let db = db();
|
||||
// Two-table setup with FK.
|
||||
customers_table(&db).await;
|
||||
db.create_table(
|
||||
"Orders".to_string(),
|
||||
@@ -6504,7 +6460,6 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Try to insert an Order pointing at a nonexistent Customer.
|
||||
let err = db
|
||||
.insert(
|
||||
"Orders".to_string(),
|
||||
@@ -6517,11 +6472,7 @@ mod tests {
|
||||
DbError::Sqlite { message, .. } => {
|
||||
assert!(
|
||||
message.to_ascii_lowercase().contains("foreign key"),
|
||||
"{message}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("Orders.CustId → Customers.id"),
|
||||
"FK enrichment should list the FK: {message}"
|
||||
"expected engine-classified FK message, got: {message}"
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected error: {other:?}"),
|
||||
|
||||
Reference in New Issue
Block a user