Files
rdbms-playground/src/friendly/format.rs
T
claude@clouddev1 eac7e5b81d 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.
2026-05-09 12:43:37 +00:00

253 lines
8.3 KiB
Rust

//! Catalog loader and `{name}` substitution (ADR-0019 §8.4).
//!
//! The catalog is an `include_str!`-embedded YAML file parsed
//! once on first access. Hierarchical keys flatten to
//! dot-separated paths so lookups are a single `HashMap` hit.
//!
//! Substitution rejects format specifiers (`{name:…}`),
//! unknown / missing names, unterminated `{`, and stray `}`.
//! `{{` and `}}` escape to literal `{` / `}`. Everything that
//! ends in a `panic!` should have been caught by the validator
//! unit test (Step 7) at build time.
use std::collections::HashMap;
use std::fmt::{Display, Write};
use std::sync::OnceLock;
const EN_US: &str = include_str!("strings/en-US.yaml");
/// Loaded catalog: a flat map from dot-separated key to template.
pub struct Catalog {
entries: HashMap<String, String>,
}
impl Catalog {
fn load() -> Self {
let value: serde_yml::Value = serde_yml::from_str(EN_US)
.expect("embedded en-US.yaml must parse (ADR-0019 §8.6 startup check)");
let mut entries = HashMap::new();
flatten(&value, String::new(), &mut entries);
Self { entries }
}
/// Look up a flat dot-separated key. Returns the template
/// string with placeholders un-substituted.
#[must_use]
pub fn get(&self, key: &str) -> Option<&str> {
self.entries.get(key).map(String::as_str)
}
/// Iterate every catalog key; used by the validator test.
pub fn keys(&self) -> impl Iterator<Item = &str> {
self.entries.keys().map(String::as_str)
}
}
fn flatten(
value: &serde_yml::Value,
prefix: String,
out: &mut HashMap<String, String>,
) {
match value {
serde_yml::Value::Mapping(map) => {
for (k, v) in map {
let k_str = k
.as_str()
.expect("catalog keys must be strings");
let next = if prefix.is_empty() {
k_str.to_string()
} else {
format!("{prefix}.{k_str}")
};
flatten(v, next, out);
}
}
serde_yml::Value::String(s) => {
out.insert(prefix, s.clone());
}
// Empty top-level (Null) is fine — an empty catalog
// loads as no entries. Anything else is a structure
// error worth panicking over since the catalog is
// shipped with the binary.
serde_yml::Value::Null if prefix.is_empty() => {}
other => panic!("catalog value at `{prefix}` is not a string: {other:?}"),
}
}
/// Singleton catalog access. Loads on first call; subsequent
/// calls are a `HashMap` lookup away.
pub fn catalog() -> &'static Catalog {
static C: OnceLock<Catalog> = OnceLock::new();
C.get_or_init(Catalog::load)
}
/// Look up `key` and substitute the supplied named arguments.
/// See module docs for failure modes.
pub fn translate(key: &str, args: &[(&str, &dyn Display)]) -> String {
let template = catalog().get(key).unwrap_or_else(|| {
panic!(
"missing catalog key: `{key}` (the validator should have caught this)"
);
});
substitute(template, args, key)
}
fn substitute(template: &str, args: &[(&str, &dyn Display)], key: &str) -> String {
let mut out = String::with_capacity(template.len());
let mut chars = template.chars().peekable();
while let Some(c) = chars.next() {
match c {
'{' => {
if chars.peek() == Some(&'{') {
chars.next();
out.push('{');
continue;
}
let mut name = String::new();
let mut closed = false;
while let Some(&nc) = chars.peek() {
match nc {
'}' => {
chars.next();
closed = true;
break;
}
':' => {
panic!(
"catalog key `{key}` uses a format specifier in \
`{{{name}:...}}` — specifiers are not allowed \
(ADR-0019 §8.4)"
);
}
_ => {
chars.next();
name.push(nc);
}
}
}
if !closed {
panic!("catalog key `{key}` has unterminated `{{`");
}
if name.is_empty() {
panic!("catalog key `{key}` has empty `{{}}` placeholder");
}
let value = args
.iter()
.find(|(n, _)| *n == name)
.map(|(_, v)| v)
.unwrap_or_else(|| {
panic!(
"catalog key `{key}` references `{{{name}}}` but that \
argument was not supplied"
);
});
write!(out, "{value}").expect("writing to String never fails");
}
'}' => {
if chars.peek() == Some(&'}') {
chars.next();
out.push('}');
} else {
panic!("catalog key `{key}` has stray `}}`");
}
}
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catalog_loads_at_least_the_test_entry() {
// Step 1 ships a single sanity entry under `_test.*`;
// its presence proves the loader walks the YAML and
// flattens hierarchical groups correctly.
assert!(
catalog().get("_test.hello").is_some(),
"expected `_test.hello` in catalog; entries: {:?}",
catalog().keys().collect::<Vec<_>>()
);
}
#[test]
fn substitute_replaces_named_placeholder() {
let s = substitute("Hello, {name}!", &[("name", &"World")], "_test.hello");
assert_eq!(s, "Hello, World!");
}
#[test]
fn substitute_handles_multiple_placeholders() {
let s = substitute(
"{a} + {b} = {sum}",
&[("a", &1), ("b", &2), ("sum", &3)],
"_test.math",
);
assert_eq!(s, "1 + 2 = 3");
}
#[test]
fn substitute_supports_doubled_braces_as_literals() {
let s = substitute(
"set with {{count}} = {count}",
&[("count", &5)],
"_test.literals",
);
assert_eq!(s, "set with {count} = 5");
}
#[test]
fn substitute_passes_display_values_unchanged() {
// Display for an i64 — no padding, no thousands separator.
// ADR-0019 §8.7: value formats stay invariant.
let s = substitute("n = {n}", &[("n", &1234567_i64)], "_test.display");
assert_eq!(s, "n = 1234567");
}
#[test]
#[should_panic(expected = "format specifier")]
#[allow(clippy::literal_string_with_formatting_args)]
fn substitute_rejects_format_specifier() {
// The literal `{n:08}` is intentional — that's the
// shape we want to refuse.
let _ = substitute("padded: {n:08}", &[("n", &5)], "_test.specifier");
}
#[test]
#[should_panic(expected = "not supplied")]
fn substitute_rejects_unknown_placeholder() {
let _ = substitute("Hello, {who}!", &[("name", &"World")], "_test.unknown");
}
#[test]
#[should_panic(expected = "unterminated")]
fn substitute_rejects_unterminated_brace() {
let _ = substitute("oh no {", &[], "_test.unterm");
}
#[test]
#[should_panic(expected = "stray")]
fn substitute_rejects_stray_close_brace() {
let _ = substitute("oh no }", &[], "_test.stray");
}
#[test]
#[should_panic(expected = "empty")]
fn substitute_rejects_empty_placeholder() {
let _ = substitute("oh no {}", &[], "_test.empty");
}
#[test]
fn t_macro_dispatches_to_translate() {
// Sanity check that the macro produces the same output
// as a direct call. Uses the `_test.hello` entry from
// the catalog.
let m = crate::t!("_test.hello", name = "World");
let d = translate("_test.hello", &[("name", &"World")]);
assert_eq!(m, d);
}
}