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:
+388
-8
@@ -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]
|
||||
|
||||
@@ -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
@@ -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>),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) ),*],
|
||||
)
|
||||
}};
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user