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:
claude@clouddev1
2026-05-09 12:43:37 +00:00
parent d4801ea52f
commit eac7e5b81d
13 changed files with 2295 additions and 125 deletions
+61 -110
View File
@@ -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:?}"),