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
+63
View File
@@ -0,0 +1,63 @@
//! Friendly error layer and i18n message catalog (ADR-0019).
//!
//! Single chokepoint for user-visible message text. Engine
//! errors flow through `translate()` into a structured
//! [`FriendlyError`] payload that the renderer (in
//! `output_render`) composes into final output. Every other
//! user-visible string in the codebase migrates to this
//! catalog over time via the [`t!`] macro (ADR-0019 §9).
//!
//! ## Catalog
//!
//! The catalog lives in `strings/<locale>.yaml`, embedded at
//! compile time and parsed once on first access. Today the only
//! locale is `en-US`; runtime selection is deferred (ADR-0019
//! §8.2). Hierarchical YAML keys flatten to dot-paths
//! internally — `error.unique.insert.verbose` is the path the
//! `t!()` macro and the translator look up.
//!
//! ## The `t!()` macro
//!
//! ```ignore
//! use rdbms_playground::t;
//! let s = t!("error.unique.insert.verbose",
//! table = "Customers", column = "id");
//! ```
//!
//! Placeholder values implement `Display`. Format specifiers
//! (`{name:08.2}`, `{name:>10}`, …) are explicitly rejected at
//! substitution time — see ADR-0019 §8.4.
pub mod error;
pub mod format;
pub mod keys;
pub mod translate;
pub use error::{DiagnosticTable, FriendlyError};
pub use format::{catalog, Catalog};
pub use translate::{Operation, TranslateContext, Verbosity};
// `translate::translate` and `format::translate` are different
// callables — the former is the structured DbError → FriendlyError
// classifier (the H1 entry point); the latter is the lower-level
// catalog lookup the `t!()` macro expands to. Re-export both
// under non-conflicting names.
pub use format::translate;
pub use translate::translate as translate_error;
/// Look up `key` in the catalog and substitute named arguments.
///
/// Panics if the key is missing, if the template has malformed
/// placeholders, or if the args don't supply every name the
/// template references. The catalog validator unit test (Step 7,
/// ADR-0019 §8.6) catches these at build time so they should
/// never fire at runtime.
#[macro_export]
macro_rules! t {
($key:literal $(, $name:ident = $value:expr)* $(,)?) => {{
$crate::friendly::translate(
$key,
&[$( (stringify!($name), &$value as &dyn ::std::fmt::Display) ),*],
)
}};
}