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