//! 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.` entry; every `Punct` /// variant must have a `parse.token.punct.` 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 = 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 = 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 { 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 } }