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
+153
View File
@@ -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."