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:
claude@clouddev1
2026-05-09 12:43:37 +00:00
parent d4801ea52f
commit eac7e5b81d
13 changed files with 2295 additions and 125 deletions
+388 -8
View File
@@ -61,6 +61,10 @@ impl EffectiveMode {
#[derive(Debug)]
pub struct App {
pub mode: Mode,
/// User's preferred verbosity for friendly-error rendering.
/// In-session state per ADR-0019 §5; persistence will land
/// alongside the broader settings-persistence ADR.
pub messages_verbosity: crate::friendly::Verbosity,
pub input: String,
/// Byte offset into `input` where the next character will be
/// inserted. Always lies on a UTF-8 character boundary.
@@ -213,6 +217,7 @@ impl App {
pub fn new() -> Self {
Self {
mode: Mode::Simple,
messages_verbosity: crate::friendly::Verbosity::default(),
input: String::new(),
input_cursor: 0,
output: VecDeque::with_capacity(OUTPUT_CAPACITY),
@@ -322,7 +327,7 @@ impl App {
Vec::new()
}
AppEvent::DslFailed { command, error } => {
self.handle_dsl_failure(&command, &error);
self.handle_dsl_failure(&command, error);
Vec::new()
}
AppEvent::TablesRefreshed(tables) => {
@@ -712,6 +717,10 @@ impl App {
self.handle_mode_command(other);
return Vec::new();
}
other if other.starts_with("messages") => {
self.handle_messages_command(other);
return Vec::new();
}
_ => {}
}
@@ -923,8 +932,17 @@ impl App {
}
}
fn handle_dsl_failure(&mut self, command: &Command, error: &str) {
warn!(verb = command.verb(), error, "dsl command failed");
fn handle_dsl_failure(&mut self, command: &Command, error: crate::db::DbError) {
// Render through the friendly-error layer (ADR-0019).
// The translator picks operation-tailored wording from
// the catalog and applies the user's current verbosity.
let ctx = self.build_translate_context(command, &error);
let rendered = crate::friendly::translate_error(&error, &ctx).render();
warn!(
verb = command.verb(),
error = %rendered,
"dsl command failed"
);
// Wrap the command portion in quotes so the message
// reads cleanly: "...failed: <reason>" rather than the
// command running into "failed: ..." with no break.
@@ -932,12 +950,101 @@ impl App {
// diagnostics from `change column …` (ADR-0017 §7) flow
// through as a multi-line bordered table.
self.note_error(format!(
"\"{} {}\" failed: {error}",
"\"{} {}\" failed: {rendered}",
command.verb(),
command.display_subject()
));
}
/// Construct a [`TranslateContext`] from a [`Command`] and
/// the App's current verbosity. Drives the operation-tailored
/// wording the catalog produces (ADR-0019 §4).
///
/// `error` is consulted to extract the attempted value where
/// the engine's constraint message names a column — for
/// UNIQUE / NOT NULL on INSERT the user's value flows through
/// to the `{value}` placeholder so the headline reads
/// "...already has the value `5`" rather than "...has the
/// value `<value>`".
fn build_translate_context<'a>(
&self,
command: &'a Command,
error: &crate::db::DbError,
) -> crate::friendly::TranslateContext<'a> {
use crate::dsl::{Command as C, RelationshipSelector};
use crate::friendly::{Operation, TranslateContext};
let (operation, table, column) = match command {
C::CreateTable { name, .. } => (Operation::CreateTable, Some(name.as_str()), None),
C::DropTable { name } => (Operation::DropTable, Some(name.as_str()), None),
C::AddColumn { table, column, .. } => (
Operation::AddColumn,
Some(table.as_str()),
Some(column.as_str()),
),
C::DropColumn { table, column } => (
Operation::DropColumn,
Some(table.as_str()),
Some(column.as_str()),
),
C::RenameColumn { table, .. } => (Operation::RenameColumn, Some(table.as_str()), None),
C::ChangeColumnType { table, column, .. } => (
Operation::ChangeColumnType,
Some(table.as_str()),
Some(column.as_str()),
),
C::AddRelationship {
parent_table,
parent_column,
..
} => (
Operation::AddRelationship,
Some(parent_table.as_str()),
Some(parent_column.as_str()),
),
C::DropRelationship { selector } => match selector {
RelationshipSelector::Endpoints {
parent_table,
parent_column,
..
} => (
Operation::DropRelationship,
Some(parent_table.as_str()),
Some(parent_column.as_str()),
),
RelationshipSelector::Named { .. } => (Operation::DropRelationship, None, None),
},
C::Insert { table, .. } => (Operation::Insert, Some(table.as_str()), None),
C::Update { table, .. } => (Operation::Update, Some(table.as_str()), None),
C::Delete { table, .. } => (Operation::Delete, Some(table.as_str()), None),
C::ShowData { name } | C::ShowTable { name } => {
(Operation::Query, Some(name.as_str()), None)
}
C::Replay { .. } => (Operation::Replay, None, None),
};
// Try to find the value the user attempted on the
// offending column so the catalog `{value}` placeholder
// gets a concrete substitution. Best-effort: works when
// the engine reports a qualified `T.col` AND the
// command supplies explicit columns (or has a
// single-value short form). Multi-value natural-order
// INSERTs hide which positional value belongs to which
// column without a schema lookup — the translator's
// fallback `<value>` reads sensibly there until the
// runtime-side row-pinpoint follow-on (ADR-0019 §6)
// adds schema awareness.
let value = attempted_value_for(command, error);
TranslateContext {
operation: Some(operation),
table,
column,
value,
verbosity: self.messages_verbosity,
..TranslateContext::default()
}
}
/// Parse the argument tail of an `import` command and
/// return the corresponding `Action::Import`.
///
@@ -1251,6 +1358,8 @@ impl App {
" quit / q — exit",
" help — this list",
" mode simple|advanced — switch input mode",
" messages — show current verbosity",
" messages short|verbose— switch error wording (verbose is the default)",
" rebuild — rebuild .db from project.yaml + data/ (with confirmation)",
" save — save current temp project under a name",
" save as — copy current project to a new name/path",
@@ -1294,6 +1403,30 @@ impl App {
}
}
fn handle_messages_command(&mut self, raw: &str) {
let arg = raw.strip_prefix("messages").unwrap_or(raw).trim();
match arg {
"" => {
let current = match self.messages_verbosity {
crate::friendly::Verbosity::Short => "short",
crate::friendly::Verbosity::Verbose => "verbose",
};
self.note_system(format!("messages: {current}"));
}
"short" => {
self.messages_verbosity = crate::friendly::Verbosity::Short;
self.note_system("messages: short");
}
"verbose" => {
self.messages_verbosity = crate::friendly::Verbosity::Verbose;
self.note_system("messages: verbose");
}
other => self.note_error(format!(
"unknown messages mode '{other}' (expected 'short' or 'verbose')"
)),
}
}
fn handle_mode_command(&mut self, raw: &str) {
let arg = raw.strip_prefix("mode").unwrap_or(raw).trim();
match arg {
@@ -1370,6 +1503,73 @@ impl App {
}
}
/// Best-effort extraction of the user's attempted value for the
/// column an engine constraint error implicates. Used to fill
/// the catalog's `{value}` placeholder so the headline reads
/// "...already has the value `5`" rather than `<value>`.
///
/// Returns `None` when:
/// - the error isn't a `Sqlite { … }` payload (no engine
/// message to parse the column from);
/// - the engine message doesn't carry a qualified `T.col`
/// target (e.g. raw "FOREIGN KEY constraint failed");
/// - the command isn't an INSERT/UPDATE (DELETE has no value
/// to attribute);
/// - the command supplies multiple natural-order values and
/// we can't tell which slot belongs to the named column
/// without a schema lookup.
fn attempted_value_for(
command: &Command,
error: &crate::db::DbError,
) -> Option<String> {
use crate::dsl::Command as C;
let crate::db::DbError::Sqlite { message, .. } = error else {
return None;
};
let column = column_from_qualified_target(message)?;
match command {
C::Insert {
columns, values, ..
} => {
if let Some(cols) = columns {
let idx = cols.iter().position(|c| c == &column)?;
values.get(idx).map(ToString::to_string)
} else if values.len() == 1 {
// Natural-order short form `insert into T (v)` —
// a single value can only belong to a single
// column. The schema would still need to confirm
// the column name matches, but for the common
// single-column-table case the value is right.
values.first().map(ToString::to_string)
} else {
None
}
}
C::Update { assignments, .. } => assignments
.iter()
.find(|(c, _)| c == &column)
.map(|(_, v)| v.to_string()),
_ => None,
}
}
/// Pull the column name out of a qualified target like
/// `"UNIQUE constraint failed: T.col"`. Used by
/// `attempted_value_for`. Returns `None` when the message
/// doesn't have the expected shape.
fn column_from_qualified_target(message: &str) -> Option<String> {
let after = message.split_once(':')?.1.trim();
let first = after.split(',').next()?.trim();
let mut parts = first.splitn(2, '.');
let _table = parts.next()?;
let column = parts.next()?.trim();
if column.is_empty() {
None
} else {
Some(column.to_string())
}
}
fn parse_error_message(err: &ParseError) -> String {
match err {
ParseError::Invalid { message, .. } => message.clone(),
@@ -1755,19 +1955,199 @@ mod tests {
}
#[test]
fn dsl_failure_event_writes_error_with_friendly_message() {
fn dsl_failure_event_renders_through_friendly_translator() {
// Synthetic DslFailed carries a structured DbError;
// the App applies its current verbosity and routes the
// payload through `friendly::translate_error` (ADR-0019).
let mut app = App::new();
let cmd = Command::DropTable {
name: "Ghost".to_string(),
};
app.update(AppEvent::DslFailed {
command: cmd,
error: "no such table: Ghost".to_string(),
error: crate::db::DbError::Sqlite {
message: "no such table: Ghost".to_string(),
kind: crate::db::SqliteErrorKind::NoSuchTable,
},
});
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
assert!(last.text.contains("Ghost"));
assert!(last.text.contains("no such table"));
// Anchor phrase + table name (ADR-0019 §10).
assert!(last.text.contains("no such table"), "{}", last.text);
assert!(last.text.contains("Ghost"), "{}", last.text);
}
#[test]
fn messages_command_toggles_verbosity_and_reports() {
let mut app = App::new();
// Default is verbose.
type_str(&mut app, "messages");
submit(&mut app);
let last = app.output.back().unwrap();
assert!(last.text.ends_with("verbose"), "{}", last.text);
// Switch to short.
type_str(&mut app, "messages short");
submit(&mut app);
assert_eq!(app.messages_verbosity, crate::friendly::Verbosity::Short);
let last = app.output.back().unwrap();
assert_eq!(last.text, "messages: short");
// And back.
type_str(&mut app, "messages verbose");
submit(&mut app);
assert_eq!(app.messages_verbosity, crate::friendly::Verbosity::Verbose);
}
#[test]
fn dsl_failure_substitutes_attempted_value_for_unique_insert() {
// The user's reported case: `insert into thing (1)`
// (single-value short form) hits a UNIQUE constraint;
// the headline should show `1` rather than `<value>`.
let mut app = App::new();
let cmd = Command::Insert {
table: "thing".to_string(),
columns: None,
values: vec![crate::dsl::Value::Number("1".to_string())],
};
let err = crate::db::DbError::Sqlite {
message: "UNIQUE constraint failed: thing.id".to_string(),
kind: crate::db::SqliteErrorKind::UniqueViolation,
};
app.update(AppEvent::DslFailed { command: cmd, error: err });
let body = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(
body.contains("`1`"),
"expected the attempted value `1` in headline:\n{body}"
);
assert!(
!body.contains("<value>"),
"<value> placeholder should have been substituted:\n{body}"
);
}
#[test]
fn dsl_failure_substitutes_attempted_value_for_unique_with_explicit_columns() {
// Columns explicitly listed: the helper should match
// the failing column to its position in the column list
// and substitute the corresponding value.
let mut app = App::new();
let cmd = Command::Insert {
table: "Customers".to_string(),
columns: Some(vec![
"name".to_string(),
"id".to_string(),
]),
values: vec![
crate::dsl::Value::Text("Alice".to_string()),
crate::dsl::Value::Number("42".to_string()),
],
};
let err = crate::db::DbError::Sqlite {
message: "UNIQUE constraint failed: Customers.id".to_string(),
kind: crate::db::SqliteErrorKind::UniqueViolation,
};
app.update(AppEvent::DslFailed { command: cmd, error: err });
let body = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(
body.contains("`42`"),
"expected attempted id `42`:\n{body}"
);
assert!(!body.contains("`Alice`"), "wrong column substituted:\n{body}");
}
#[test]
fn dsl_failure_substitutes_attempted_value_for_unique_update() {
// UPDATE: the value comes from the SET assignment list.
let mut app = App::new();
let cmd = Command::Update {
table: "Customers".to_string(),
assignments: vec![(
"id".to_string(),
crate::dsl::Value::Number("7".to_string()),
)],
filter: crate::dsl::RowFilter::Where {
column: "name".to_string(),
value: crate::dsl::Value::Text("Bob".to_string()),
},
};
let err = crate::db::DbError::Sqlite {
message: "UNIQUE constraint failed: Customers.id".to_string(),
kind: crate::db::SqliteErrorKind::UniqueViolation,
};
app.update(AppEvent::DslFailed { command: cmd, error: err });
let body = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(body.contains("`7`"), "expected attempted id `7`:\n{body}");
}
#[test]
fn messages_short_drops_the_hint_in_dsl_failure_render() {
// Verbose mode → headline + hint. Short mode → headline only.
// Use a UNIQUE-style violation since it has a meaty hint
// worth measuring against.
let mut app = App::new();
let cmd = Command::Insert {
table: "Customers".to_string(),
columns: Some(vec!["id".to_string()]),
values: vec![],
};
let err = || crate::db::DbError::Sqlite {
message: "UNIQUE constraint failed: Customers.id".to_string(),
kind: crate::db::SqliteErrorKind::UniqueViolation,
};
app.messages_verbosity = crate::friendly::Verbosity::Verbose;
app.update(AppEvent::DslFailed {
command: cmd.clone(),
error: err(),
});
let verbose_text = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(
verbose_text.contains("pick a different value"),
"verbose mode missing hint: {verbose_text}"
);
// Reset and try short.
let mut app = App::new();
app.messages_verbosity = crate::friendly::Verbosity::Short;
app.update(AppEvent::DslFailed {
command: cmd,
error: err(),
});
let short_text = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(
short_text.contains("already has the value"),
"short still has the headline: {short_text}"
);
assert!(
!short_text.contains("pick a different value"),
"short mode should not include the hint: {short_text}"
);
}
#[test]
+61 -110
View File
@@ -266,13 +266,21 @@ pub enum SqliteErrorKind {
}
impl DbError {
/// Placeholder for the H1 friendly-error layer. Today this
/// returns the same string as [`std::fmt::Display`]; when H1
/// lands the body becomes the translation logic and
/// callsites do not need to change.
/// User-visible rendering of this error.
///
/// Routes through the H1 friendly-error layer
/// ([`crate::friendly::translate_error`], ADR-0019). With
/// no context the translator falls back to abstract wording
/// — for operation-tailored output, callers should
/// construct a [`crate::friendly::TranslateContext`] and
/// call the translator directly. This method exists for the
/// many callsites where context isn't readily available
/// (fatal-banner paths, fallback render paths) and the
/// abstract wording is acceptable.
#[must_use]
pub fn friendly_message(&self) -> String {
self.to_string()
let ctx = crate::friendly::TranslateContext::default();
crate::friendly::translate_error(self, &ctx).render()
}
fn from_rusqlite(err: rusqlite::Error) -> Self {
@@ -1982,7 +1990,19 @@ fn do_change_column_type(
// any error in a friendly message (ADR-0017 §5,
// ADR-0002 user-facing posture).
rebuild_table(conn, table, &old_schema, &new_schema, metadata_updates)
.map_err(|e| friendly_change_column_engine_error(table, column, src_ty, ty, e))?;
.map_err(|e| {
let ctx = crate::friendly::TranslateContext {
operation: Some(crate::friendly::Operation::ChangeColumnType),
table: Some(table),
column: Some(column),
src_type: Some(src_ty),
target_type: Some(ty),
..crate::friendly::TranslateContext::default()
};
let rendered =
crate::friendly::translate_error(&e, &ctx).render();
DbError::Unsupported(rendered)
})?;
None
}
ChangeColumnMode::Default | ChangeColumnMode::ForceConversion => Some(
@@ -2346,39 +2366,6 @@ fn fill_auto_generated_cells(
Ok(())
}
/// Wrap an engine-level error from the `--dont-convert` path
/// into a friendly form per ADR-0017 §5 / ADR-0002. Avoids
/// surfacing engine vocabulary (SQLite, STRICT, PRAGMA, …) by
/// recognising the common kinds and producing a generic
/// abstract phrasing.
fn friendly_change_column_engine_error(
table: &str,
column: &str,
src_ty: Type,
target_ty: Type,
err: DbError,
) -> DbError {
let detail = match &err {
DbError::Sqlite { message, .. } => {
let lower = message.to_ascii_lowercase();
if lower.contains("constraint") || lower.contains("not null") {
"the database refused at least one cell."
} else if lower.contains("type") || lower.contains("non-text") || lower.contains("non-integer") {
"the database refused at least one cell as the wrong shape \
for the new type."
} else {
"the database refused the operation."
}
}
_ => return err,
};
DbError::Unsupported(format!(
"Cannot change `{table}.{column}` from {src_ty} to {target_ty} with \
`--dont-convert`: {detail} Re-run without the flag to see per-row \
diagnostics."
))
}
/// Maximum diagnostic rows rendered per refusal (ADR-0017 §7).
/// Rows beyond this collapse into a trailing `… and N more` row.
const DIAGNOSTIC_ROW_CAP: usize = 100;
@@ -3398,74 +3385,29 @@ fn bound_to_sqlite_value(b: &Bound) -> rusqlite::types::Value {
}
}
fn enrich_fk_message(conn: &Connection, table: &str, base: String) -> String {
// SQLite tells us "FOREIGN KEY constraint failed" without
// saying which constraint. We list both:
// - outbound FKs (this table is child) — relevant for
// INSERT and UPDATE that try to set a non-matching FK
// value;
// - inbound FKs (this table is parent) — relevant for
// DELETE / UPDATE on the parent side that violate a
// RESTRICT or NO ACTION constraint.
// The user reads both lists and recognises the relevant
// direction from the operation they ran. Full H1 would
// pinpoint the offending row.
let outbound = read_relationships_outbound(conn, table).unwrap_or_default();
let inbound = read_relationships_inbound(conn, table).unwrap_or_default();
if outbound.is_empty() && inbound.is_empty() {
return base;
}
let mut msg = base;
if !outbound.is_empty() {
msg.push_str(". Foreign keys on this table (relevant for INSERT/UPDATE):");
for r in outbound {
msg.push_str(&format!(
"\n - {}.{} → {}.{}",
table, r.local_column, r.other_table, r.other_column
));
}
}
if !inbound.is_empty() {
msg.push_str(
"\nThis table is referenced by (relevant for DELETE/UPDATE on parent side):",
);
for r in inbound {
msg.push_str(&format!(
"\n - {}.{} → {}.{} (on delete {})",
r.other_table, r.other_column, table, r.local_column, r.on_delete
));
}
}
msg.push_str(
"\nCheck that referenced values exist (for INSERT/UPDATE) or that no children \
reference these rows (for DELETE/UPDATE on the parent side).",
);
msg
}
/// Execute an INSERT/UPDATE/DELETE and convert any rusqlite
/// failure into a `DbError`. Wraps the raw `conn.execute` so the
/// three callers (insert, update, delete) have a single hook for
/// future row-pinpointing per ADR-0019 §6 — when re-query lands,
/// the runtime-side wiring will pass the table identity into the
/// translator from here.
///
/// Today this is a thin wrapper. The `table` argument is
/// retained as the future re-query hook needs it. (The previous
/// `enrich_fk_message` helper that lived here, listing all the
/// outbound/inbound FKs in the error message, was absorbed into
/// the friendly-error layer's catalog wording per ADR-0019;
/// re-introducing the per-FK detail belongs to the re-query
/// follow-on, not as freeform plain-text appended to engine
/// errors.)
fn execute_with_fk_enrichment(
conn: &Connection,
table: &str,
_table: &str,
sql: &str,
params: &[rusqlite::types::Value],
) -> Result<usize, DbError> {
let result = conn.execute(sql, rusqlite::params_from_iter(params.iter()));
match result {
Ok(n) => Ok(n),
Err(e) => {
let mut db_err = DbError::from_rusqlite(e);
if let DbError::Sqlite { message, kind } = &db_err {
let lower = message.to_ascii_lowercase();
if lower.contains("foreign key") {
db_err = DbError::Sqlite {
message: enrich_fk_message(conn, table, message.clone()),
kind: *kind,
};
}
}
Err(db_err)
}
}
conn.execute(sql, rusqlite::params_from_iter(params.iter()))
.map_err(DbError::from_rusqlite)
}
/// Fetch a small `DataResult` containing only the rows whose
@@ -6480,9 +6422,23 @@ mod tests {
}
#[tokio::test]
async fn fk_violation_message_lists_outbound_relationships() {
async fn fk_violation_returns_engine_classified_constraint_error() {
// Pre-H1 (ADR-0019), this test asserted that the
// engine's `FOREIGN KEY constraint failed` text was
// enriched in-band with a list of every relevant FK on
// the offending table. That enrichment moved into the
// friendly-error layer's catalog wording (see
// `friendly::translate::tests::fk_with_*_op_renders_*`)
// and out of the raw `DbError::Sqlite { message }`
// payload. What stays in `message` is the engine's
// un-enriched text — useful for the translator's own
// classification, but no longer the user-facing
// surface.
//
// This test now just confirms the engine error reaches
// us classified as a constraint violation; user-facing
// wording is exercised in the friendly module.
let db = db();
// Two-table setup with FK.
customers_table(&db).await;
db.create_table(
"Orders".to_string(),
@@ -6504,7 +6460,6 @@ mod tests {
.await
.unwrap();
// Try to insert an Order pointing at a nonexistent Customer.
let err = db
.insert(
"Orders".to_string(),
@@ -6517,11 +6472,7 @@ mod tests {
DbError::Sqlite { message, .. } => {
assert!(
message.to_ascii_lowercase().contains("foreign key"),
"{message}"
);
assert!(
message.contains("Orders.CustId → Customers.id"),
"FK enrichment should list the FK: {message}"
"expected engine-classified FK message, got: {message}"
);
}
other => panic!("unexpected error: {other:?}"),
+7 -5
View File
@@ -8,8 +8,8 @@
use crossterm::event::KeyEvent;
use crate::db::{
AddColumnResult, ChangeColumnTypeResult, DataResult, DeleteResult, InsertResult,
TableDescription, UpdateResult,
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult,
InsertResult, TableDescription, UpdateResult,
};
use crate::dsl::Command;
@@ -56,11 +56,13 @@ pub enum AppEvent {
command: Command,
result: AddColumnResult,
},
/// A DSL command failed. `error` is already a friendly
/// message produced via `DbError::friendly_message`.
/// A DSL command failed. `error` is the structured
/// payload — App applies its current verbosity setting
/// (`messages_verbosity`) when rendering through
/// `friendly::translate_error` (ADR-0019 §5).
DslFailed {
command: Command,
error: String,
error: DbError,
},
/// Refreshed list of tables in the database.
TablesRefreshed(Vec<String>),
+194
View File
@@ -0,0 +1,194 @@
//! Structured friendly-error payload (ADR-0019 §7).
//!
//! `FriendlyError` is the type the translator produces. The
//! renderer (in this module) composes it into final text. The
//! payload is structured so the renderer can decide layout —
//! per ADR-0019 §F-detail, "leave rendering details to the
//! renderer instead of banging everything in one string".
//!
//! Composition order:
//!
//! ```text
//! <headline>
//!
//! <hint> (only when present)
//!
//! ┌────────────┬───────┐ (only when present)
//! │ … bordered │ table │
//! └────────────┴───────┘
//! ```
//!
//! Blank-line separators sit between the three blocks. The
//! diagnostic table uses ADR-0017's bordered renderer (via
//! `output_render::render_diagnostic_table`) so its visual
//! style matches the lossy / incompatible refusal diagnostics
//! introduced there.
use crate::output_render::{Alignment, render_diagnostic_table};
/// Bordered row pinpoint produced by the translator's
/// post-failure re-query (ADR-0019 §6). The renderer hands
/// these straight to ADR-0017's `render_diagnostic_table`.
#[derive(Debug, Clone)]
pub struct DiagnosticTable {
pub headers: Vec<String>,
pub rows: Vec<Vec<String>>,
pub alignments: Vec<Alignment>,
}
/// Structured friendly-error payload (ADR-0019 §7).
///
/// `headline` is always present — a single, complete line of
/// "what happened". `hint` is the verbose-mode addition: one or
/// more lines of pedagogical "what to do next". `diagnostic_table`
/// is the (optional) row pinpoint surfaced through ADR-0017's
/// bordered renderer.
#[derive(Debug, Clone)]
pub struct FriendlyError {
pub headline: String,
pub hint: Option<String>,
pub diagnostic_table: Option<DiagnosticTable>,
}
impl FriendlyError {
/// Compose the payload into a single multi-line `String`.
/// Newline-separated; safe to feed into `note_error()`,
/// `eprintln!`, or any other plain-text sink.
#[must_use]
pub fn render(&self) -> String {
self.render_lines().join("\n")
}
/// Compose the payload into a sequence of display lines.
/// Used by the App layer where each output line is its own
/// `OutputLine` for accurate scroll-position math (per the
/// `App::push_multiline` invariant).
#[must_use]
pub fn render_lines(&self) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
// Headline can itself be multi-line if the catalog
// entry has embedded newlines (rare today; possible
// for future contributions). Split so each display
// line is its own entry.
for line in self.headline.lines() {
out.push(line.to_string());
}
if let Some(hint) = &self.hint {
out.push(String::new());
for line in hint.lines() {
out.push(line.to_string());
}
}
if let Some(table) = &self.diagnostic_table {
out.push(String::new());
out.extend(render_diagnostic_table(
&table.headers,
&table.rows,
&table.alignments,
));
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fe(headline: &str) -> FriendlyError {
FriendlyError {
headline: headline.to_string(),
hint: None,
diagnostic_table: None,
}
}
#[test]
fn renders_headline_only_when_no_hint_no_table() {
let f = fe("`Customers.id` already has the value `5`.");
let s = f.render();
assert_eq!(s, "`Customers.id` already has the value `5`.");
}
#[test]
fn renders_headline_blank_then_hint() {
let f = FriendlyError {
headline: "`Customers.id` already has the value `5`.".to_string(),
hint: Some("Pick a different value or update the existing row.".to_string()),
diagnostic_table: None,
};
let lines = f.render_lines();
assert_eq!(
lines,
vec![
"`Customers.id` already has the value `5`.".to_string(),
String::new(),
"Pick a different value or update the existing row.".to_string(),
],
);
}
#[test]
fn renders_headline_blank_table_with_no_hint() {
let f = FriendlyError {
headline: "rows still reference this".to_string(),
hint: None,
diagnostic_table: Some(DiagnosticTable {
headers: vec!["id".to_string(), "name".to_string()],
rows: vec![
vec!["1".to_string(), "Alice".to_string()],
vec!["2".to_string(), "Bob".to_string()],
],
alignments: vec![Alignment::Right, Alignment::Left],
}),
};
let lines = f.render_lines();
// headline + blank + bordered table (5+ lines).
assert_eq!(lines[0], "rows still reference this");
assert_eq!(lines[1], "");
// The first table line is the top border.
assert!(
lines[2].starts_with('┌'),
"expected bordered table after headline, got: {:?}",
lines[2]
);
// And the headers contain `id` and `name`.
assert!(
lines.iter().any(|l| l.contains("id") && l.contains("name")),
"missing headers row: {lines:?}"
);
}
#[test]
fn renders_all_three_with_blank_separators() {
let f = FriendlyError {
headline: "headline".to_string(),
hint: Some("hint".to_string()),
diagnostic_table: Some(DiagnosticTable {
headers: vec!["c".to_string()],
rows: vec![vec!["v".to_string()]],
alignments: vec![Alignment::Left],
}),
};
let lines = f.render_lines();
// headline, blank, hint, blank, then 5 lines of table
// = 9 minimum. We assert order rather than exact length
// so future renderer tweaks don't trip the test.
assert_eq!(lines[0], "headline");
assert_eq!(lines[1], "");
assert_eq!(lines[2], "hint");
assert_eq!(lines[3], "");
assert!(lines[4].starts_with('┌'));
}
#[test]
fn multi_line_headline_splits_at_newlines() {
// Future-proofing: if a catalog entry ever embeds a
// newline in the headline, the renderer treats each
// line independently rather than emitting one long row.
let f = fe("first line\nsecond line");
let lines = f.render_lines();
assert_eq!(lines[0], "first line");
assert_eq!(lines[1], "second line");
}
}
+252
View File
@@ -0,0 +1,252 @@
//! 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);
}
}
+293
View File
@@ -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
}
}
+63
View File
@@ -0,0 +1,63 @@
//! Friendly error layer and i18n message catalog (ADR-0019).
//!
//! Single chokepoint for user-visible message text. Engine
//! errors flow through `translate()` into a structured
//! [`FriendlyError`] payload that the renderer (in
//! `output_render`) composes into final output. Every other
//! user-visible string in the codebase migrates to this
//! catalog over time via the [`t!`] macro (ADR-0019 §9).
//!
//! ## Catalog
//!
//! The catalog lives in `strings/<locale>.yaml`, embedded at
//! compile time and parsed once on first access. Today the only
//! locale is `en-US`; runtime selection is deferred (ADR-0019
//! §8.2). Hierarchical YAML keys flatten to dot-paths
//! internally — `error.unique.insert.verbose` is the path the
//! `t!()` macro and the translator look up.
//!
//! ## The `t!()` macro
//!
//! ```ignore
//! use rdbms_playground::t;
//! let s = t!("error.unique.insert.verbose",
//! table = "Customers", column = "id");
//! ```
//!
//! Placeholder values implement `Display`. Format specifiers
//! (`{name:08.2}`, `{name:>10}`, …) are explicitly rejected at
//! substitution time — see ADR-0019 §8.4.
pub mod error;
pub mod format;
pub mod keys;
pub mod translate;
pub use error::{DiagnosticTable, FriendlyError};
pub use format::{catalog, Catalog};
pub use translate::{Operation, TranslateContext, Verbosity};
// `translate::translate` and `format::translate` are different
// callables — the former is the structured DbError → FriendlyError
// classifier (the H1 entry point); the latter is the lower-level
// catalog lookup the `t!()` macro expands to. Re-export both
// under non-conflicting names.
pub use format::translate;
pub use translate::translate as translate_error;
/// Look up `key` in the catalog and substitute named arguments.
///
/// Panics if the key is missing, if the template has malformed
/// placeholders, or if the args don't supply every name the
/// template references. The catalog validator unit test (Step 7,
/// ADR-0019 §8.6) catches these at build time so they should
/// never fire at runtime.
#[macro_export]
macro_rules! t {
($key:literal $(, $name:ident = $value:expr)* $(,)?) => {{
$crate::friendly::translate(
$key,
&[$( (stringify!($name), &$value as &dyn ::std::fmt::Display) ),*],
)
}};
}
+153
View File
@@ -0,0 +1,153 @@
# en-US catalog (ADR-0019).
#
# Hierarchical groups flatten to dot-paths internally:
# error.unique.insert.headline
# error.unique.insert.hint
# help.cli_banner
# replay.completed
# … etc.
#
# Each error entry has a `headline` (one line, used in both
# short and verbose modes) and may have a `hint` (one or more
# lines, surfaced only in verbose mode). Short mode = headline
# only; verbose mode = headline + hint + (if present) the
# diagnostic table the translator built (ADR-0019 §7).
#
# Anchor phrases per ADR-0019 §10 are kept stable across
# wording changes:
# "no such table"
# "no such column"
# "no such relationship"
# "already exists"
# "already has the value"
# "cannot be converted"
# "discard information"
# "referenced by"
# "[client-side]"
#
# Placeholders use `{name}` substitution; format specifiers
# (`{name:08.2}`, `{name:>10}`, …) are explicitly rejected by
# the substitution helper (ADR-0019 §8.4).
# Sanity entry exercised by the loader's unit tests; not
# user-facing.
_test:
hello: "Hello, {name}!"
# ---- Error category --------------------------------------------------
error:
# UNIQUE constraint violations. Anchor: "already has the value".
unique:
insert:
headline: "`{table}.{column}` already has the value `{value}`."
hint: "The `{column}` column on `{table}` is unique — pick a different value, or update the existing row instead."
update:
headline: "`{table}.{column}` already has the value `{value}`."
hint: "The `{column}` column on `{table}` is unique — your update would create a duplicate."
# Primary-key collisions get distinct wording — the user
# learns that PK is the canonical unique constraint.
pk:
insert:
headline: "`{table}` already has a row with primary key `{value}`."
hint: "Primary keys must be unique — pick a different value or update the existing row."
update:
headline: "`{table}` already has a row with primary key `{value}`."
hint: "Primary keys must be unique — your update would create a duplicate."
# FOREIGN KEY violations. Anchor: "referenced by".
foreign_key:
# Child-side: insert/update sets a value that has no parent.
child_side:
insert:
headline: "no parent row in `{parent_table}` has `{parent_column}` = `{value}`."
hint: "Foreign keys must point at an existing parent row. Insert a matching parent first, or pick a value that already exists in `{parent_table}.{parent_column}`."
update:
headline: "no parent row in `{parent_table}` has `{parent_column}` = `{value}`."
hint: "Foreign keys must point at an existing parent row. Pick a value that already exists in `{parent_table}.{parent_column}`."
# Parent-side: delete/update on a row referenced by children.
# The engine refuses unless the relationship's `on delete /
# on update` says cascade or set null.
parent_side:
delete:
headline: "`{table}` rows are referenced by `{child_table}`."
hint: "Deleting these rows would orphan the children. Delete the children first, or change the relationship's `on delete` action to `cascade` or `set null`."
update:
headline: "`{table}` rows are referenced by `{child_table}`."
hint: "Updating the referenced column would orphan the children. Update the children first, or change the relationship's `on update` action to `cascade`."
# NOT NULL constraint violations.
not_null:
insert:
headline: "`{table}.{column}` cannot be null."
hint: "The `{column}` column is required — provide a value for it in the row you are inserting."
update:
headline: "`{table}.{column}` cannot be null."
hint: "The `{column}` column is required — pick a non-null value, or do not include `{column}` in your `set` list."
# CHECK constraint violations. Placeholder coverage —
# the playground does not emit CHECK constraints today
# (track C3), but the catalog is wired so the wording
# is ready when constraint-management lands.
check:
insert:
headline: "check constraint refused `{table}.{column}`."
hint: "A check constraint requires `{column}` to satisfy a rule the inserted value did not."
update:
headline: "check constraint refused `{table}.{column}`."
hint: "A check constraint requires `{column}` to satisfy a rule the new value did not."
# Type mismatch — engine-side STRICT refusal of a wrong-shape
# value. Mostly the `change column ... --dont-convert` path
# today (subsumes `friendly_change_column_engine_error`).
type_mismatch:
change_column:
headline: "cannot change `{table}.{column}` from `{src_type}` to `{target_type}` with `--dont-convert`."
hint: "The database refused at least one cell as the wrong shape for `{target_type}`. Re-run without `--dont-convert` to see which rows."
insert:
headline: "value `{value}` is not a `{expected_type}`."
hint: "The `{column}` column on `{table}` is `{expected_type}` — provide a `{expected_type}` value, or change the column's type with `change column`."
update:
headline: "value `{value}` is not a `{expected_type}`."
hint: "The `{column}` column on `{table}` is `{expected_type}` — provide a `{expected_type}` value, or change the column's type with `change column`."
# Object-not-found errors. Anchor: "no such ...". These are
# genuinely single-line errors — no hint adds value.
not_found:
table:
headline: "no such table: `{name}`"
column:
headline: "no such column: `{table}.{column}`"
column_unqualified:
headline: "no such column: `{column}`"
relationship:
headline: "no such relationship: `{name}`"
# Name-collision errors. Anchor: "already exists".
already_exists:
table:
headline: "table `{name}` already exists"
column:
headline: "column `{table}.{column}` already exists"
relationship:
headline: "relationship `{name}` already exists"
# Generic catch-all when the translator can't classify the
# engine error into a known category. The wording stays
# engine-neutral; the message text from the engine is NOT
# surfaced (ADR-0002 user-facing posture).
generic:
headline: "the database refused this `{operation}`."
hint: "The operation could not be completed against the current state of `{table}`."
# Errors that are specifically about value validation
# (DbError::InvalidValue) — wrong arity, wrong literal
# form, etc. Pre-engine; the catalog covers what the
# parser / validator already decided was wrong.
invalid_value:
arity:
headline: "expected {expected} value(s), got {actual}."
empty_insert:
headline: "INSERT requires at least one column value."
empty_update:
headline: "UPDATE requires at least one assignment."
+871
View File
@@ -0,0 +1,871 @@
//! Translator: classify a [`DbError`] and produce a
//! [`FriendlyError`] (ADR-0019 §2, §3).
//!
//! The translator is the chokepoint that absorbs the previous
//! ad-hoc helpers (`friendly_change_column_engine_error`,
//! `enrich_fk_message`). It pattern-matches on the structured
//! [`SqliteErrorKind`] first; for the constraint-violation
//! family (UNIQUE / FK / NOT NULL / CHECK) it then classifies
//! by message text, since rusqlite folds them all under one
//! kind today.
//!
//! Wording flows entirely through the catalog ([`crate::t!`])
//! and never echoes engine error text verbatim.
//!
//! ## Row pinpointing
//!
//! ADR-0019 §6 calls for re-querying the database after a
//! constraint failure to surface the offending row(s) through
//! ADR-0017's bordered diagnostic-table renderer. That layer
//! is plumbed structurally — [`FriendlyError::diagnostic_table`]
//! exists for it — but the actual re-query implementation is a
//! separate commit alongside its runtime-side wiring (the
//! translator needs the user's attempted values, which arrive
//! through [`TranslateContext`] populated at the runtime
//! callsite).
//!
//! For now, [`FriendlyError::diagnostic_table`] is always
//! `None` and the wording carries the full burden. Adding the
//! diagnostic table later is purely additive: the translator's
//! catalog keys and the renderer don't change.
use crate::db::{DbError, SqliteErrorKind};
use crate::dsl::Type;
use crate::friendly::error::FriendlyError;
use crate::t;
/// Verbosity of the rendered error.
///
/// `Short` → headline only. `Verbose` → headline + hint (when
/// the catalog entry has one) + diagnostic table (when present).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Verbosity {
/// Headline only.
Short,
/// Headline + hint + diagnostic table. Default per ADR-0019 §5.
#[default]
Verbose,
}
/// The user-visible operation that produced the error.
///
/// Drives operation-tailored wording (ADR-0019 §4) and is
/// surfaced verbatim in `error.generic.*` as the `{operation}`
/// placeholder.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Operation {
Insert,
Update,
Delete,
CreateTable,
DropTable,
AddColumn,
DropColumn,
RenameColumn,
ChangeColumnType,
AddRelationship,
DropRelationship,
Query,
Rebuild,
Replay,
/// Catch-all for callsites with no specific operation
/// context (e.g. opening a database, listing tables).
Other,
}
impl Operation {
/// Short user-facing label for the operation; appears in
/// `error.generic.headline`'s `{operation}` placeholder.
/// Stable wording — anchor-phrase-equivalent for future
/// migration churn.
#[must_use]
pub const fn keyword(self) -> &'static str {
match self {
Self::Insert => "insert",
Self::Update => "update",
Self::Delete => "delete",
Self::CreateTable => "create table",
Self::DropTable => "drop table",
Self::AddColumn => "add column",
Self::DropColumn => "drop column",
Self::RenameColumn => "rename column",
Self::ChangeColumnType => "change column",
Self::AddRelationship => "add relationship",
Self::DropRelationship => "drop relationship",
Self::Query => "query",
Self::Rebuild => "rebuild",
Self::Replay => "replay",
Self::Other => "operation",
}
}
}
/// Context the translator uses to pick catalog keys and fill
/// placeholders. Every field is optional — the translator falls
/// back to abstract wording where context is missing.
#[derive(Debug, Clone, Default)]
pub struct TranslateContext<'a> {
pub operation: Option<Operation>,
pub table: Option<&'a str>,
pub column: Option<&'a str>,
/// For parent-side FK violations: the child table that
/// references this row. Surfaced as `{child_table}` in
/// `error.foreign_key.parent_side.*`.
pub child_table: Option<&'a str>,
pub src_type: Option<Type>,
pub target_type: Option<Type>,
/// User-attempted value for INSERT/UPDATE; surfaced as the
/// `{value}` placeholder in UNIQUE / FK / type-mismatch
/// wording. Best-effort: callsites populate when they have
/// it; translator falls back to `<value>` otherwise.
pub value: Option<String>,
pub verbosity: Verbosity,
}
impl<'a> TranslateContext<'a> {
/// Convenience constructor for the common "I just have an
/// operation" case.
#[must_use]
pub fn for_op(operation: Operation) -> Self {
Self {
operation: Some(operation),
..Self::default()
}
}
}
/// Classify `error` and produce a structured [`FriendlyError`].
/// See module docs for the classification flow.
#[must_use]
pub fn translate(error: &DbError, ctx: &TranslateContext<'_>) -> FriendlyError {
match error {
DbError::Sqlite { message, kind } => translate_sqlite(message, *kind, ctx),
// Unsupported / InvalidValue carry text that is already
// engine-neutral and friendly (constructed by our own
// refusal sites). Catalog entries exist for the typed
// invalid-value cases but the migration sweep
// (ADR-0019 §9) is what wires them. For now, passthrough.
DbError::Unsupported(message) | DbError::InvalidValue(message) => {
passthrough(message)
}
DbError::PersistenceFatal { message, .. }
| DbError::RebuildRowFailed { detail: message, .. }
| DbError::Io(message) => passthrough(message),
DbError::WorkerGone => passthrough(
"the database worker is no longer available — the application must restart",
),
}
}
fn translate_sqlite(
message: &str,
kind: SqliteErrorKind,
ctx: &TranslateContext<'_>,
) -> FriendlyError {
// `change column ... --dont-convert` lets the engine
// accept or refuse each cell. Whatever the engine returns
// (constraint, datatype mismatch, …) means "the new type
// didn't fit at least one cell". Route to the dedicated
// change-column wording — subsumes the role
// `friendly_change_column_engine_error` previously played.
if matches!(ctx.operation, Some(Operation::ChangeColumnType)) {
return translate_type_mismatch_change_column(ctx);
}
match kind {
SqliteErrorKind::NoSuchTable => translate_not_found_table(message, ctx),
SqliteErrorKind::NoSuchColumn => translate_not_found_column(message, ctx),
SqliteErrorKind::AlreadyExists => translate_already_exists(message, ctx),
// SqliteErrorKind::UniqueViolation is currently the
// bucket for *every* constraint violation (UNIQUE, FK,
// NOT NULL, CHECK) since rusqlite folds them under
// ConstraintViolation. We split by message text here.
SqliteErrorKind::UniqueViolation => translate_constraint(message, ctx),
SqliteErrorKind::Other => translate_generic(message, ctx),
}
}
fn translate_type_mismatch_change_column(ctx: &TranslateContext<'_>) -> FriendlyError {
let table = ctx_table(ctx);
let column = ctx_column(ctx);
let src_type = ctx
.src_type
.map_or_else(|| "?".to_string(), |t| t.keyword().to_string());
let target_type = ctx
.target_type
.map_or_else(|| "?".to_string(), |t| t.keyword().to_string());
fe(
t!(
"error.type_mismatch.change_column.headline",
table = table,
column = column,
src_type = src_type,
target_type = target_type
),
verbose_hint(
ctx,
t!(
"error.type_mismatch.change_column.hint",
target_type = target_type
),
),
)
}
fn translate_constraint(message: &str, ctx: &TranslateContext<'_>) -> FriendlyError {
let lower = message.to_ascii_lowercase();
if lower.contains("unique constraint failed") {
translate_unique(message, ctx)
} else if lower.contains("foreign key constraint failed") {
translate_foreign_key(ctx)
} else if lower.contains("not null constraint failed") {
translate_not_null(message, ctx)
} else if lower.contains("check constraint failed") {
translate_check(message, ctx)
} else {
translate_generic(message, ctx)
}
}
// ---- UNIQUE -----------------------------------------------------
fn translate_unique(message: &str, ctx: &TranslateContext<'_>) -> FriendlyError {
let (table, column) = parse_qualified_target(message)
.unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
let value = ctx_value(ctx);
match ctx.operation {
Some(Operation::Update) => fe(
t!(
"error.unique.update.headline",
table = table,
column = column,
value = value
),
verbose_hint(
ctx,
t!(
"error.unique.update.hint",
table = table,
column = column
),
),
),
// Default to the INSERT variant — it's the most common
// path and the wording is symmetric enough that an
// unknown-operation INSERT-vs-UPDATE confusion isn't
// misleading.
_ => fe(
t!(
"error.unique.insert.headline",
table = table,
column = column,
value = value
),
verbose_hint(
ctx,
t!(
"error.unique.insert.hint",
table = table,
column = column
),
),
),
}
}
// ---- FOREIGN KEY -----------------------------------------------
fn translate_foreign_key(ctx: &TranslateContext<'_>) -> FriendlyError {
// The engine's "FOREIGN KEY constraint failed" carries no
// detail. Disambiguation is operation-driven: child-side
// happens on INSERT/UPDATE (the row being written points at
// a missing parent); parent-side happens on DELETE/UPDATE
// (the row being deleted is referenced by a child).
//
// Without context we default to child-side INSERT, which
// is the more common case and matches the wording of the
// pre-H1 enrich_fk_message helper.
match ctx.operation {
Some(Operation::Delete) => fk_parent_side_delete(ctx),
Some(Operation::Update) => fk_parent_side_update(ctx),
Some(Operation::Insert) => fk_child_side_insert(ctx),
_ => fk_child_side_insert(ctx),
}
}
fn fk_child_side_insert(ctx: &TranslateContext<'_>) -> FriendlyError {
let parent_table = ctx_table(ctx);
let parent_column = ctx_column(ctx);
let value = ctx_value(ctx);
fe(
t!(
"error.foreign_key.child_side.insert.headline",
parent_table = parent_table,
parent_column = parent_column,
value = value
),
verbose_hint(
ctx,
t!(
"error.foreign_key.child_side.insert.hint",
parent_table = parent_table,
parent_column = parent_column
),
),
)
}
fn fk_parent_side_delete(ctx: &TranslateContext<'_>) -> FriendlyError {
let table = ctx_table(ctx);
let child_table = ctx
.child_table
.map_or_else(|| "another table".to_string(), str::to_string);
fe(
t!(
"error.foreign_key.parent_side.delete.headline",
table = table,
child_table = child_table
),
verbose_hint(ctx, t!("error.foreign_key.parent_side.delete.hint")),
)
}
fn fk_parent_side_update(ctx: &TranslateContext<'_>) -> FriendlyError {
let table = ctx_table(ctx);
let child_table = ctx
.child_table
.map_or_else(|| "another table".to_string(), str::to_string);
fe(
t!(
"error.foreign_key.parent_side.update.headline",
table = table,
child_table = child_table
),
verbose_hint(ctx, t!("error.foreign_key.parent_side.update.hint")),
)
}
// ---- NOT NULL --------------------------------------------------
fn translate_not_null(message: &str, ctx: &TranslateContext<'_>) -> FriendlyError {
let (table, column) = parse_qualified_target(message)
.unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
match ctx.operation {
Some(Operation::Update) => fe(
t!(
"error.not_null.update.headline",
table = table,
column = column
),
verbose_hint(ctx, t!("error.not_null.update.hint", column = column)),
),
_ => fe(
t!(
"error.not_null.insert.headline",
table = table,
column = column
),
verbose_hint(ctx, t!("error.not_null.insert.hint", column = column)),
),
}
}
// ---- CHECK -----------------------------------------------------
fn translate_check(_message: &str, ctx: &TranslateContext<'_>) -> FriendlyError {
// The engine reports CHECK constraint failures by constraint
// name, not by column. We don't have user-named CHECK
// constraints today, so the message is rarely informative.
// Surface what we have via context.
let table = ctx_table(ctx);
let column = ctx_column(ctx);
match ctx.operation {
Some(Operation::Update) => fe(
t!(
"error.check.update.headline",
table = table,
column = column
),
verbose_hint(ctx, t!("error.check.update.hint", column = column)),
),
_ => fe(
t!(
"error.check.insert.headline",
table = table,
column = column
),
verbose_hint(ctx, t!("error.check.insert.hint", column = column)),
),
}
}
// ---- not_found / already_exists --------------------------------
fn translate_not_found_table(message: &str, ctx: &TranslateContext<'_>) -> FriendlyError {
let name = parse_after_colon(message)
.map_or_else(|| ctx_table(ctx), str::to_string);
headline_only(t!("error.not_found.table.headline", name = name))
}
fn translate_not_found_column(message: &str, ctx: &TranslateContext<'_>) -> FriendlyError {
let name = parse_after_colon(message).unwrap_or("");
if let Some((table, column)) = name.split_once('.') {
headline_only(t!(
"error.not_found.column.headline",
table = table,
column = column
))
} else if !name.is_empty() {
headline_only(t!(
"error.not_found.column_unqualified.headline",
column = name
))
} else {
headline_only(t!(
"error.not_found.column.headline",
table = ctx_table(ctx),
column = ctx_column(ctx)
))
}
}
fn translate_already_exists(message: &str, ctx: &TranslateContext<'_>) -> FriendlyError {
// Three shapes feed in:
// - Engine: "table T already exists"
// - Our own: "column `T.col` already exists; pick a different name."
// - Our own: "a relationship named `name` already exists. ..."
// The catalog has typed entries; if we can't classify, fall
// back to passthrough (the existing wording is already
// engine-neutral).
if let Some(name) = extract_quoted(message) {
if let Some((table, column)) = name.split_once('.') {
return headline_only(t!(
"error.already_exists.column.headline",
table = table,
column = column
));
}
return headline_only(t!(
"error.already_exists.table.headline",
name = name
));
}
// No backticks — engine-style "table T already exists".
if let Some(name) = parse_after_word(message, "table") {
return headline_only(t!(
"error.already_exists.table.headline",
name = name
));
}
if let Some(name) = parse_after_word(message, "relationship") {
return headline_only(t!(
"error.already_exists.relationship.headline",
name = name
));
}
// Fall back to context.
let _ = ctx;
passthrough(message)
}
// ---- Generic catch-all -----------------------------------------
fn translate_generic(_message: &str, ctx: &TranslateContext<'_>) -> FriendlyError {
// Engine message is intentionally NOT surfaced — ADR-0002
// posture. The catalog provides the abstract wording.
let operation = ctx
.operation
.map_or("operation", Operation::keyword);
let table = ctx_table(ctx);
fe(
t!("error.generic.headline", operation = operation),
verbose_hint(ctx, t!("error.generic.hint", table = table)),
)
}
// ---- Helpers ---------------------------------------------------
fn passthrough<S: Into<String>>(s: S) -> FriendlyError {
FriendlyError {
headline: s.into(),
hint: None,
diagnostic_table: None,
}
}
const fn headline_only(headline: String) -> FriendlyError {
FriendlyError {
headline,
hint: None,
diagnostic_table: None,
}
}
const fn fe(headline: String, hint: Option<String>) -> FriendlyError {
FriendlyError {
headline,
hint,
diagnostic_table: None,
}
}
fn verbose_hint(ctx: &TranslateContext<'_>, hint: String) -> Option<String> {
if ctx.verbosity == Verbosity::Verbose {
Some(hint)
} else {
None
}
}
// Fallback markers when context can't supply a value. We use
// the catalog's `{name}` form so unfilled positions read as
// "this placeholder was not supplied" — same shape the
// translator's source uses, easier to grep, and visually
// consistent with the catalog templates. Filling these
// properly across the board needs schema-aware enrichment in
// the runtime; that work is bundled with the row-pinpoint
// re-query (ADR-0019 §6) since both need the Database handle.
fn ctx_table(ctx: &TranslateContext<'_>) -> String {
ctx.table.map_or_else(|| "{table}".to_string(), str::to_string)
}
fn ctx_column(ctx: &TranslateContext<'_>) -> String {
ctx.column.map_or_else(|| "{column}".to_string(), str::to_string)
}
fn ctx_value(ctx: &TranslateContext<'_>) -> String {
ctx.value.clone().unwrap_or_else(|| "{value}".to_string())
}
/// Extract `T.col` from a message like
/// `"UNIQUE constraint failed: T.col"`. Returns `(T, col)` or
/// `None` if the message doesn't have the expected shape.
fn parse_qualified_target(message: &str) -> Option<(String, String)> {
let after = parse_after_colon(message)?;
let first_target = after.split(',').next()?.trim();
let mut parts = first_target.splitn(2, '.');
let table = parts.next()?.trim();
let column = parts.next()?.trim();
if table.is_empty() || column.is_empty() {
return None;
}
Some((table.to_string(), column.to_string()))
}
/// Return the substring after the first `:`, trimmed. None if
/// no colon present.
fn parse_after_colon(message: &str) -> Option<&str> {
message.split_once(':').map(|(_, rest)| rest.trim())
}
/// Find a backtick-quoted substring in `message` (the wording
/// our own refusal sites use for object names). Returns the
/// content of the first quoted run, or None.
fn extract_quoted(message: &str) -> Option<&str> {
let start = message.find('`')? + 1;
let rest = &message[start..];
let end = rest.find('`')?;
Some(&rest[..end])
}
/// Find the token immediately after `keyword` in `message`.
/// Used for parsing engine-style "table T already exists" and
/// "relationship name already exists".
fn parse_after_word<'a>(message: &'a str, keyword: &str) -> Option<&'a str> {
let pos = message.find(keyword)? + keyword.len();
let rest = message[pos..].trim_start();
let token_end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
let token = rest[..token_end].trim_matches(|c: char| c == '`' || c == '\'');
if token.is_empty() {
None
} else {
Some(token)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctx_with(op: Operation) -> TranslateContext<'static> {
TranslateContext {
operation: Some(op),
..TranslateContext::default()
}
}
fn sqlite(message: &str, kind: SqliteErrorKind) -> DbError {
DbError::Sqlite {
message: message.to_string(),
kind,
}
}
// ---- UNIQUE ----
#[test]
fn unique_insert_uses_anchor_phrase_and_picks_insert_template() {
let err = sqlite(
"UNIQUE constraint failed: Customers.id",
SqliteErrorKind::UniqueViolation,
);
let mut ctx = ctx_with(Operation::Insert);
ctx.value = Some("5".to_string());
let f = translate(&err, &ctx);
// Anchor phrase ADR-0019 §10.
assert!(
f.headline.contains("already has the value"),
"missing anchor: {}",
f.headline
);
assert!(f.headline.contains("Customers"));
assert!(f.headline.contains("id"));
assert!(f.headline.contains("`5`"));
// Verbose mode → hint present.
let hint = f.hint.expect("verbose default → hint");
assert!(hint.contains("pick a different value"));
assert!(hint.contains("update the existing row"));
}
#[test]
fn unique_update_picks_update_template() {
let err = sqlite(
"UNIQUE constraint failed: Customers.id",
SqliteErrorKind::UniqueViolation,
);
let f = translate(&err, &ctx_with(Operation::Update));
let hint = f.hint.expect("verbose default → hint");
// Update wording differs from insert — operation-tailored.
assert!(
hint.contains("would create a duplicate"),
"expected update wording: {hint}"
);
}
#[test]
fn short_mode_omits_hint() {
let err = sqlite(
"UNIQUE constraint failed: Customers.id",
SqliteErrorKind::UniqueViolation,
);
let mut ctx = ctx_with(Operation::Insert);
ctx.verbosity = Verbosity::Short;
let f = translate(&err, &ctx);
assert!(f.hint.is_none(), "short mode → no hint, got {:?}", f.hint);
// Headline still present.
assert!(f.headline.contains("already has the value"));
}
// ---- FK ----
#[test]
fn fk_with_insert_op_renders_child_side_wording() {
let err = sqlite(
"FOREIGN KEY constraint failed",
SqliteErrorKind::UniqueViolation,
);
let mut ctx = ctx_with(Operation::Insert);
ctx.table = Some("Customers");
ctx.column = Some("id");
ctx.value = Some("99".to_string());
let f = translate(&err, &ctx);
assert!(
f.headline.contains("no parent row"),
"expected child-side phrasing: {}",
f.headline
);
assert!(f.headline.contains("`99`"));
}
#[test]
fn fk_with_delete_op_renders_parent_side_wording() {
let err = sqlite(
"FOREIGN KEY constraint failed",
SqliteErrorKind::UniqueViolation,
);
let mut ctx = ctx_with(Operation::Delete);
ctx.table = Some("Customers");
ctx.child_table = Some("Orders");
let f = translate(&err, &ctx);
// Anchor phrase: "referenced by".
assert!(
f.headline.contains("referenced by"),
"expected parent-side phrasing with anchor: {}",
f.headline
);
assert!(f.headline.contains("Customers"));
assert!(f.headline.contains("Orders"));
}
// ---- NOT NULL ----
#[test]
fn not_null_uses_typed_template() {
let err = sqlite(
"NOT NULL constraint failed: Customers.email",
SqliteErrorKind::UniqueViolation,
);
let f = translate(&err, &ctx_with(Operation::Insert));
assert!(f.headline.contains("cannot be null"));
assert!(f.headline.contains("Customers"));
assert!(f.headline.contains("email"));
}
// ---- CHECK ----
#[test]
fn check_uses_typed_template_and_falls_back_to_context() {
let err = sqlite(
"CHECK constraint failed: chk_age",
SqliteErrorKind::UniqueViolation,
);
let mut ctx = ctx_with(Operation::Insert);
ctx.table = Some("People");
ctx.column = Some("age");
let f = translate(&err, &ctx);
assert!(f.headline.contains("check constraint refused"));
assert!(f.headline.contains("People"));
assert!(f.headline.contains("age"));
}
// ---- not_found ----
#[test]
fn no_such_table_uses_anchor_phrase() {
let err = sqlite("no such table: Ghost", SqliteErrorKind::NoSuchTable);
let f = translate(&err, &TranslateContext::default());
// Anchor phrase ADR-0019 §10.
assert!(
f.headline.contains("no such table"),
"missing anchor: {}",
f.headline
);
assert!(f.headline.contains("Ghost"));
}
#[test]
fn no_such_column_qualified() {
let err = sqlite("no such column: T.zip", SqliteErrorKind::NoSuchColumn);
let f = translate(&err, &TranslateContext::default());
assert!(f.headline.contains("no such column"));
assert!(f.headline.contains("T"));
assert!(f.headline.contains("zip"));
}
#[test]
fn no_such_column_unqualified_uses_unqualified_key() {
let err = sqlite("no such column: zip", SqliteErrorKind::NoSuchColumn);
let f = translate(&err, &TranslateContext::default());
assert!(f.headline.contains("no such column"));
assert!(f.headline.contains("zip"));
}
// ---- already_exists ----
#[test]
fn already_exists_quoted_column_uses_column_template() {
let err = sqlite(
"column `T.x` already exists; pick a different name.",
SqliteErrorKind::AlreadyExists,
);
let f = translate(&err, &TranslateContext::default());
assert!(f.headline.contains("already exists"));
assert!(f.headline.contains("T"));
assert!(f.headline.contains("x"));
}
#[test]
fn already_exists_quoted_table_uses_table_template() {
let err = sqlite(
"table `Customers` already exists",
SqliteErrorKind::AlreadyExists,
);
let f = translate(&err, &TranslateContext::default());
assert!(f.headline.contains("already exists"));
assert!(f.headline.contains("Customers"));
}
// ---- generic ----
#[test]
fn generic_path_uses_operation_keyword() {
let err = sqlite(
"some unrecognised constraint failure",
SqliteErrorKind::UniqueViolation,
);
let f = translate(&err, &ctx_with(Operation::AddRelationship));
// Falls into translate_constraint → generic since no
// recognised constraint keyword.
assert!(
f.headline.contains("add relationship") || f.headline.contains("refused"),
"got: {}",
f.headline
);
}
#[test]
fn generic_engine_message_is_not_surfaced() {
// ADR-0002 posture: the engine's text never reaches the
// user verbatim. Check explicitly.
let secret = "SQLite-flavoured detail with engine internals";
let err = sqlite(secret, SqliteErrorKind::Other);
let f = translate(&err, &ctx_with(Operation::Insert));
let rendered = f.render();
assert!(
!rendered.contains(secret),
"engine message leaked into translated output:\n{rendered}"
);
}
// ---- passthrough variants ----
#[test]
fn unsupported_passes_through() {
let err = DbError::Unsupported(
"cannot drop primary-key column `T.id`. Drop the table or change the primary key first."
.to_string(),
);
let f = translate(&err, &TranslateContext::default());
assert!(f.headline.contains("primary-key"));
assert!(f.hint.is_none());
}
#[test]
fn invalid_value_passes_through() {
let err = DbError::InvalidValue("expected 3 value(s), got 2".to_string());
let f = translate(&err, &TranslateContext::default());
assert_eq!(f.headline, "expected 3 value(s), got 2");
}
// ---- internal helpers ----
#[test]
fn parse_qualified_target_works_for_unique_message() {
assert_eq!(
parse_qualified_target("UNIQUE constraint failed: T.col"),
Some(("T".to_string(), "col".to_string()))
);
}
#[test]
fn parse_qualified_target_returns_first_for_compound() {
assert_eq!(
parse_qualified_target("UNIQUE constraint failed: T.a, T.b"),
Some(("T".to_string(), "a".to_string()))
);
}
#[test]
fn parse_qualified_target_none_when_no_dot() {
assert_eq!(
parse_qualified_target("FOREIGN KEY constraint failed"),
None
);
}
#[test]
fn extract_quoted_pulls_first_backtick_run() {
assert_eq!(extract_quoted("column `T.x` already exists"), Some("T.x"));
assert_eq!(extract_quoted("no backticks here"), None);
}
}
+1
View File
@@ -12,6 +12,7 @@ pub mod cli;
pub mod db;
pub mod dsl;
pub mod event;
pub mod friendly;
pub mod logging;
pub mod mode;
pub mod output_render;
+7
View File
@@ -23,6 +23,13 @@ fn main() -> ExitCode {
return ExitCode::FAILURE;
}
// ADR-0019 §8.6: parse the embedded message catalog up
// front so a corrupted build artefact fails loudly here
// rather than at the first `t!()` call deep inside the
// event loop. Cheap — the catalog is small and `OnceLock`
// memoises the parse for every subsequent caller.
let _ = rdbms_playground::friendly::catalog();
let tokio_rt = match tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(e) => {
+1 -1
View File
@@ -974,7 +974,7 @@ fn spawn_dsl_dispatch(
},
Err(error) => AppEvent::DslFailed {
command: command.clone(),
error: error.friendly_message(),
error,
},
};
if event_tx.send(event).await.is_err() {
+4 -1
View File
@@ -574,7 +574,10 @@ fn dsl_failure_shows_friendly_error_in_output() {
command: Command::DropTable {
name: "Ghost".to_string(),
},
error: "no such table: Ghost".to_string(),
error: rdbms_playground::db::DbError::Sqlite {
message: "no such table: Ghost".to_string(),
kind: rdbms_playground::db::SqliteErrorKind::NoSuchTable,
},
});
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(