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
+293
View File
@@ -0,0 +1,293 @@
//! Per-category catalog key schemas (ADR-0019 §8.3).
//!
//! Every catalog key the friendly-error layer references at
//! runtime is enumerated here together with its expected
//! placeholder set. The validator
//! (`tests::keys_validate_against_catalog`) walks this list and
//! asserts:
//!
//! - the key exists in the catalog;
//! - every placeholder declared here appears at least once in
//! the template;
//! - no placeholder appears in the template that isn't declared
//! here (catches typos in either direction);
//! - every catalog key (outside the `_test.*` sanity group) is
//! declared here (catches dead YAML entries).
//!
//! Adding a new translation site is a two-step change: add the
//! key + placeholders here, add the YAML entry. Either alone
//! fails the validator.
//!
//! ## Convention
//!
//! Each error entry in the catalog has:
//!
//! - a `.headline` template — used in both short and verbose
//! modes;
//! - optionally a `.hint` template — surfaced only in verbose
//! mode.
//!
//! Single-line errors (object-not-found, already-exists,
//! invalid-value) have no hint; the headline carries the whole
//! message.
//!
//! Other categories (`help.*`, `ok.*`, `client_side.*`,
//! `replay.*`, `parse.*`, modal labels, …) get added to this
//! list as the migration sweep (ADR-0019 §9) lands them.
/// `(key, expected_placeholders)`. Sorted by key for grep-ability.
pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
// ---- Already-exists collisions (anchor: "already exists") ----
("error.already_exists.column.headline", &["table", "column"]),
("error.already_exists.relationship.headline", &["name"]),
("error.already_exists.table.headline", &["name"]),
// ---- CHECK violations ----
("error.check.insert.headline", &["table", "column"]),
("error.check.insert.hint", &["column"]),
("error.check.update.headline", &["table", "column"]),
("error.check.update.hint", &["column"]),
// ---- FK violations (anchor: "referenced by") ----
(
"error.foreign_key.child_side.insert.headline",
&["parent_table", "parent_column", "value"],
),
(
"error.foreign_key.child_side.insert.hint",
&["parent_table", "parent_column"],
),
(
"error.foreign_key.child_side.update.headline",
&["parent_table", "parent_column", "value"],
),
(
"error.foreign_key.child_side.update.hint",
&["parent_table", "parent_column"],
),
(
"error.foreign_key.parent_side.delete.headline",
&["table", "child_table"],
),
("error.foreign_key.parent_side.delete.hint", &[]),
(
"error.foreign_key.parent_side.update.headline",
&["table", "child_table"],
),
("error.foreign_key.parent_side.update.hint", &[]),
// ---- Generic engine refusal ----
("error.generic.headline", &["operation"]),
("error.generic.hint", &["table"]),
// ---- Invalid-value errors (pre-engine, single-line) ----
(
"error.invalid_value.arity.headline",
&["expected", "actual"],
),
("error.invalid_value.empty_insert.headline", &[]),
("error.invalid_value.empty_update.headline", &[]),
// ---- Not-found errors (anchor: "no such ...") ----
("error.not_found.column.headline", &["table", "column"]),
("error.not_found.column_unqualified.headline", &["column"]),
("error.not_found.relationship.headline", &["name"]),
("error.not_found.table.headline", &["name"]),
// ---- NOT NULL violations ----
("error.not_null.insert.headline", &["table", "column"]),
("error.not_null.insert.hint", &["column"]),
("error.not_null.update.headline", &["table", "column"]),
("error.not_null.update.hint", &["column"]),
// ---- Type mismatch ----
(
"error.type_mismatch.change_column.headline",
&["table", "column", "src_type", "target_type"],
),
(
"error.type_mismatch.change_column.hint",
&["target_type"],
),
(
"error.type_mismatch.insert.headline",
&["value", "expected_type"],
),
(
"error.type_mismatch.insert.hint",
&["table", "column", "expected_type"],
),
(
"error.type_mismatch.update.headline",
&["value", "expected_type"],
),
(
"error.type_mismatch.update.hint",
&["table", "column", "expected_type"],
),
// ---- UNIQUE violations (anchor: "already has the value") ----
(
"error.unique.insert.headline",
&["table", "column", "value"],
),
("error.unique.insert.hint", &["table", "column"]),
("error.unique.pk.insert.headline", &["table", "value"]),
("error.unique.pk.insert.hint", &[]),
("error.unique.pk.update.headline", &["table", "value"]),
("error.unique.pk.update.hint", &[]),
(
"error.unique.update.headline",
&["table", "column", "value"],
),
("error.unique.update.hint", &["table", "column"]),
];
#[cfg(test)]
mod tests {
use super::KEYS_AND_PLACEHOLDERS;
use crate::friendly::format::catalog;
use std::collections::HashSet;
/// Walks `KEYS_AND_PLACEHOLDERS` and verifies every entry
/// matches the catalog. ADR-0019 §8.6.
///
/// Checks:
/// 1. every declared key exists in the catalog;
/// 2. every declared placeholder appears in the template;
/// 3. every placeholder used is declared (catches typos);
/// 4. every catalog key (outside `_test.*`) is declared
/// (catches dead YAML entries);
/// 5. no template contains a format specifier
/// (`{name:...}`); ADR-0019 §8.4 forbids these;
/// 6. no template contains forbidden engine vocabulary
/// (ADR-0002 user-facing posture; same forbidden list
/// as `tests/engine_vocabulary_audit.rs`).
#[test]
fn keys_validate_against_catalog() {
let cat = catalog();
let mut errors: Vec<String> = Vec::new();
for (key, expected) in KEYS_AND_PLACEHOLDERS {
let Some(template) = cat.get(key) else {
errors.push(format!("catalog missing key `{key}`"));
continue;
};
// Placeholder set check (declared ↔ used).
let actual = collect_placeholders(template);
let expected_set: HashSet<&str> = expected.iter().copied().collect();
for name in &expected_set {
if !actual.contains(*name) {
errors.push(format!(
"key `{key}`: declared placeholder `{{{name}}}` is not used in template:\n{template}"
));
}
}
for name in &actual {
if !expected_set.contains(name.as_str()) {
errors.push(format!(
"key `{key}`: template uses `{{{name}}}` but it isn't declared in keys.rs:\n{template}"
));
}
}
// Format-specifier check (ADR-0019 §8.4). Look for
// `{name:...}` shapes — the substitute helper would
// panic at runtime, but catching it at test time
// means we never ship a binary that can hit that
// panic.
if has_format_specifier(template) {
errors.push(format!(
"key `{key}`: template contains a `{{name:...}}` format specifier:\n{template}"
));
}
// Engine-vocabulary check (ADR-0002 user-facing
// posture, regression-tested in
// tests/engine_vocabulary_audit.rs).
for needle in FORBIDDEN_ENGINE_VOCABULARY {
if template.contains(needle) {
errors.push(format!(
"key `{key}`: template contains forbidden token `{needle}`:\n{template}"
));
}
}
}
let declared: HashSet<&str> =
KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect();
for key in cat.keys() {
if key.starts_with("_test.") {
continue;
}
if !declared.contains(key) {
errors.push(format!(
"catalog has key `{key}` but it isn't declared in keys::KEYS_AND_PLACEHOLDERS"
));
}
}
assert!(
errors.is_empty(),
"catalog validation failed:\n {}",
errors.join("\n ")
);
}
/// Mirror of `tests/engine_vocabulary_audit.rs::FORBIDDEN`,
/// duplicated here so the catalog validator is self-contained
/// (no dependency on the integration-test binary).
const FORBIDDEN_ENGINE_VOCABULARY: &[&str] = &[
"SQLite", "sqlite", "rusqlite", "STRICT", "PRAGMA",
];
/// Detect a `{name:...}` format-specifier placeholder.
/// Doubled braces `{{` / `}}` are escapes — must skip them.
fn has_format_specifier(template: &str) -> bool {
let mut chars = template.chars().peekable();
while let Some(c) = chars.next() {
if c == '{' {
if chars.peek() == Some(&'{') {
chars.next();
continue;
}
while let Some(&nc) = chars.peek() {
if nc == '}' {
break;
}
if nc == ':' {
return true;
}
chars.next();
}
} else if c == '}' && chars.peek() == Some(&'}') {
chars.next();
}
}
false
}
/// Walk `template` and pull out every `{name}` placeholder.
/// Mirrors the substitution helper's parse — if the helper
/// accepts a placeholder, this collects it.
fn collect_placeholders(template: &str) -> HashSet<String> {
let mut out = HashSet::new();
let mut chars = template.chars().peekable();
while let Some(c) = chars.next() {
if c == '{' {
if chars.peek() == Some(&'{') {
chars.next();
continue;
}
let mut name = String::new();
while let Some(&nc) = chars.peek() {
if nc == '}' {
chars.next();
break;
}
chars.next();
name.push(nc);
}
if !name.is_empty() && !name.contains(':') {
out.insert(name);
}
} else if c == '}' && chars.peek() == Some(&'}') {
chars.next();
}
}
out
}
}