eac7e5b81d
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.
253 lines
8.3 KiB
Rust
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);
|
|
}
|
|
}
|