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,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."
|
||||
Reference in New Issue
Block a user