Files
rdbms-playground/src/friendly/keys.rs
T
claude@clouddev1 11071ae164 ADR-0021 implementation: per-command usage templates in parse errors
New `dsl::usage` module: registry pairing each command's
entry-keyword with a `parse.usage.*` catalog key.
`matched_entry()` resolves the entry keyword from the
consumed token prefix; multi-entry families (add, drop,
show) return all matching keys.

Catalog: new `parse.usage.<command>` keys (one per command),
`parse.token.{keyword,punct,...}` vocabulary (one per
Keyword/Punct variant + token-class labels + LexError
kinds), and `parse.available_commands` for the no-prefix
fallback. Catalog grows ~60 entries.

Validator: extended KEYS_AND_PLACEHOLDERS; new completeness
test asserts every Keyword and Punct variant has its
`parse.token.*` entry.

`app::dispatch_dsl` rewritten to compose three blocks per
ADR-0021 §2: caret + structural/custom error + usage block
(or available-commands fallback per §5). Caret math fixed
to use original-input byte position rather than
trimmed-input position (the lexer no longer trims before
lexing). Three pre-existing app tests adjusted to look
across all error lines instead of `output.back()` (the
usage block is now the last line).

`dsl::usage::matched_entry` uses `<=` rather than `<` for
position comparison so custom errors raised by `try_map`
(whose span starts at the first consumed token) still
resolve to the entry keyword.

Tests: 668 passing, 0 failing, 1 ignored (650 baseline →
+18: 8 usage + 1 token-vocab completeness + 9 new
integration tests in tests/parse_error_pedagogy.rs
covering create/add/drop/show/frobulate/update/insert
cases). Clippy clean.
2026-05-10 14:41:32 +00:00

495 lines
18 KiB
Rust

//! 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"],
),
// ---- Help text ----
("help.cli_banner", &[]),
("help.in_app_body", &[]),
// ---- Parse error rendering ----
("parse.available_commands", &["commands"]),
("parse.caret", &["padding"]),
("parse.empty", &[]),
("parse.error", &["detail"]),
// Per-command usage templates (ADR-0021 §1). One key per
// command. Multi-entry families (`add`, `drop`, `show`)
// each have multiple keys. Templates are pure prose with
// no placeholders — the renderer prepends "usage: " in
// code, not the catalog, because spacing is alignment-
// sensitive in the multi-entry case.
("parse.usage.add_column", &[]),
("parse.usage.add_relationship", &[]),
("parse.usage.change_column", &[]),
("parse.usage.create_table", &[]),
("parse.usage.delete", &[]),
("parse.usage.drop_column", &[]),
("parse.usage.drop_relationship", &[]),
("parse.usage.drop_table", &[]),
("parse.usage.insert", &[]),
("parse.usage.rename_column", &[]),
("parse.usage.replay", &[]),
("parse.usage.show_data", &[]),
("parse.usage.show_table", &[]),
("parse.usage.update", &[]),
// Single-token vocabulary (ADR-0021 §4). One per Keyword
// variant (declared by `Keyword::ALL`), one per Punct
// variant, one per token-class label, one per LexError
// kind. The per-Keyword and per-Punct entries are also
// validated against the enums by
// `keyword_and_punct_have_complete_token_vocabulary`.
("parse.token.end_of_input", &[]),
("parse.token.error.bad_flag", &[]),
("parse.token.error.unknown_char", &["found"]),
("parse.token.error.unterminated_string", &[]),
("parse.token.flag", &[]),
("parse.token.identifier", &[]),
("parse.token.keyword.action", &[]),
("parse.token.keyword.add", &[]),
("parse.token.keyword.as", &[]),
("parse.token.keyword.cascade", &[]),
("parse.token.keyword.change", &[]),
("parse.token.keyword.column", &[]),
("parse.token.keyword.create", &[]),
("parse.token.keyword.data", &[]),
("parse.token.keyword.delete", &[]),
("parse.token.keyword.drop", &[]),
("parse.token.keyword.false", &[]),
("parse.token.keyword.from", &[]),
("parse.token.keyword.in", &[]),
("parse.token.keyword.insert", &[]),
("parse.token.keyword.into", &[]),
("parse.token.keyword.no", &[]),
("parse.token.keyword.null", &[]),
("parse.token.keyword.on", &[]),
("parse.token.keyword.pk", &[]),
("parse.token.keyword.relationship", &[]),
("parse.token.keyword.rename", &[]),
("parse.token.keyword.replay", &[]),
("parse.token.keyword.restrict", &[]),
("parse.token.keyword.set", &[]),
("parse.token.keyword.show", &[]),
("parse.token.keyword.table", &[]),
("parse.token.keyword.to", &[]),
("parse.token.keyword.true", &[]),
("parse.token.keyword.update", &[]),
("parse.token.keyword.values", &[]),
("parse.token.keyword.where", &[]),
("parse.token.keyword.with", &[]),
("parse.token.number", &[]),
("parse.token.punct.close_paren", &[]),
("parse.token.punct.colon", &[]),
("parse.token.punct.comma", &[]),
("parse.token.punct.dot", &[]),
("parse.token.punct.equals", &[]),
("parse.token.punct.open_paren", &[]),
("parse.token.string_literal", &[]),
// ---- Project lifecycle event notes ----
("project.export_failed", &["error"]),
("project.export_ok", &["path"]),
("project.export_usage", &[]),
("project.import_empty_target", &[]),
("project.import_usage", &[]),
("project.import_zip_missing", &["path"]),
("project.load_path_missing", &["path"]),
("project.saveas_target_exists", &["path"]),
("project.rebuild_failed", &["error"]),
("project.rebuild_ok", &["summary"]),
("project.switch_failed", &["error"]),
("project.switched_ok", &["display_name"]),
// ---- Advanced-mode placeholder ----
("advanced_mode.not_implemented", &["input"]),
// ---- DSL failure wrapper / running echo ----
("dsl.failed", &["verb", "subject", "rendered"]),
("dsl.running", &["input"]),
// ---- Persistence-fatal banner ----
("fatal.persistence", &["operation", "path", "message"]),
// ---- Modal labels ----
("modal.generic_cancelled", &["title"]),
("modal.load_cancelled", &[]),
("modal.load_picker_empty", &[]),
("modal.load_picker_nothing", &[]),
("modal.load_picker_path_prompt", &[]),
("modal.load_picker_title", &[]),
("modal.path_entry_empty_name", &[]),
("modal.path_entry_empty_path", &[]),
("modal.rebuild_cancelled", &[]),
("modal.rebuild_confirm_prompt", &[]),
("modal.rebuild_confirm_title", &[]),
// ---- Status bar + panels ----
("panel.hint_empty", &[]),
("panel.tables_empty", &[]),
("panel.tables_title", &[]),
("status.no_project", &[]),
("status.project_label", &[]),
// ---- Save / save-as surfaces ----
("save.already_saved", &[]),
("save.path_prompt", &[]),
("save.title_as", &[]),
("save.title_save", &[]),
// ---- Shortcut hint labels ----
("shortcut.advanced_once", &[]),
("shortcut.back_to_list", &[]),
("shortcut.browse_path", &[]),
("shortcut.cancel", &[]),
("shortcut.cancel_one_shot", &[]),
("shortcut.confirm", &[]),
("shortcut.load", &[]),
("shortcut.no", &[]),
("shortcut.quit", &[]),
("shortcut.select", &[]),
("shortcut.submit", &[]),
("shortcut.switch", &[]),
("shortcut.yes", &[]),
// ---- mode / messages banners ----
("messages.set_short", &[]),
("messages.set_verbose", &[]),
("messages.show", &["current"]),
("messages.unknown", &["value"]),
("mode.set_advanced", &[]),
("mode.set_simple", &[]),
("mode.show_advanced", &[]),
("mode.show_simple", &[]),
("mode.unknown", &["value"]),
("mode.usage", &[]),
// ---- DSL command success summaries (ADR-0019 §9 sweep) ----
("ok.rows_deleted", &["count"]),
("ok.rows_inserted", &["count"]),
("ok.rows_updated", &["count"]),
("ok.summary", &["verb", "subject"]),
// ---- Client-side success notes (ADR-0017 §6, ADR-0018 §9) ----
("client_side.auto_fill_add_serial", &["count"]),
("client_side.auto_fill_add_shortid", &["count"]),
("client_side.auto_fill_transition", &["count", "kind"]),
("client_side.transformed", &["count"]),
("client_side.transformed_lossy", &["count", "lossy"]),
// ---- Replay command surfaces (ADR-0019 §9 sweep) ----
("replay.command_echo", &["command"]),
("replay.completed", &["path", "count"]),
("replay.error_could_not_open", &["path", "detail"]),
("replay.error_nested", &[]),
("replay.error_parse", &["detail"]),
("replay.failed_at_line", &["path", "line_number", "error"]),
("replay.failed_open", &["path", "error"]),
// ---- 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::dsl::keyword::{Keyword, Punct};
use crate::friendly::format::catalog;
use std::collections::HashSet;
/// Every `Keyword` variant must have a
/// `parse.token.keyword.<name>` entry; every `Punct`
/// variant must have a `parse.token.punct.<name>` entry.
/// Catches the case where a keyword or punct is added to
/// the macro but not to the catalog (ADR-0021 §7).
#[test]
fn keyword_and_punct_have_complete_token_vocabulary() {
let declared: HashSet<&str> =
KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect();
let mut missing: Vec<String> = Vec::new();
for &(kw, _) in Keyword::ALL {
let key = kw.catalog_token_key();
if !declared.contains(key.as_str()) {
missing.push(format!(
"Keyword::{kw:?} ⇒ catalog key `{key}` not declared in keys.rs"
));
}
}
for &(p, _, _) in Punct::ALL {
let key = p.catalog_token_key();
if !declared.contains(key.as_str()) {
missing.push(format!(
"Punct::{p:?} ⇒ catalog key `{key}` not declared in keys.rs"
));
}
}
assert!(
missing.is_empty(),
"token vocabulary incomplete:\n {}",
missing.join("\n "),
);
}
/// 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
}
}