//! 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::{DiagnosticTable, 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, AddIndex, DropIndex, AddConstraint, DropConstraint, 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::AddIndex => "add index", Self::DropIndex => "drop index", Self::AddConstraint => "add constraint", Self::DropConstraint => "drop constraint", Self::Query => "query", Self::Rebuild => "rebuild", Self::Replay => "replay", Self::Other => "operation", } } } /// Schema-resolved facts about a failure (ADR-0019 §6). /// /// Built by the runtime (where the `Database` handle is /// available) and passed to the App via /// [`crate::event::AppEvent::DslFailed`]. The App combines a /// `FailureContext` with its current verbosity and the /// operation derived from the originating `Command` to build a /// [`TranslateContext`]. /// /// Every field is optional — the runtime fills what it can /// resolve and leaves the rest `None`. The translator falls /// back to its `{name}`-form placeholders where data is /// missing. #[derive(Debug, Clone, Default)] pub struct FailureContext { /// Operation table from the command (may differ from /// `parent_table` for FK violations). pub table: Option, /// Column name resolved from engine error (`UNIQUE /// constraint failed: T.col` etc.) or via FK relationship /// lookup. pub column: Option, /// User's attempted value for the offending column. pub value: Option, /// For child-side FK violations: the parent table the FK /// references. pub parent_table: Option, /// For child-side FK violations: the parent column the /// FK references. pub parent_column: Option, /// For parent-side FK violations: a child table that /// references this row. pub child_table: Option, /// Pinpointed offending row(s) per ADR-0019 §6 / ADR-0017 /// §7. Rendered through the bordered diagnostic-table /// renderer when present. pub diagnostic_table: Option, /// For a `CHECK` violation: the column's compiled `CHECK` /// expression, resolved from the schema (ADR-0029 §10). Lets /// the friendly error name the rule the value broke. pub check_rule: Option, } /// Context the translator uses to pick catalog keys and fill /// placeholders. Every field is optional — the translator falls /// back to its `{name}`-form placeholders where context is /// missing. #[derive(Debug, Clone, Default)] pub struct TranslateContext { pub operation: Option, pub table: Option, pub column: Option, /// 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, /// For child-side FK violations: the parent table the FK /// references. Surfaced as `{parent_table}` in /// `error.foreign_key.child_side.*`. pub parent_table: Option, /// For child-side FK violations: the parent column the FK /// references. Surfaced as `{parent_column}`. pub parent_column: Option, pub src_type: Option, pub target_type: Option, /// User-attempted value for INSERT/UPDATE. pub value: Option, /// Pinpointed offending row(s); rendered onto the /// `FriendlyError::diagnostic_table` field when present. pub diagnostic_table: Option, /// For a `CHECK` violation: the column's compiled `CHECK` /// expression (ADR-0029 §10). When present, the friendly /// error names the rule the value broke. pub check_rule: Option, pub verbosity: Verbosity, } impl TranslateContext { /// 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() } } /// Combine schema-resolved facts with operation and /// verbosity to build the full translator input. #[must_use] pub fn from_facts( operation: Operation, verbosity: Verbosity, facts: FailureContext, ) -> Self { Self { operation: Some(operation), table: facts.table, column: facts.column, child_table: facts.child_table, parent_table: facts.parent_table, parent_column: facts.parent_column, src_type: None, target_type: None, value: facts.value, diagnostic_table: facts.diagnostic_table, check_rule: facts.check_rule, verbosity, } } } /// Classify `error` and produce a structured [`FriendlyError`]. /// See module docs for the classification flow. #[must_use] pub fn translate(error: &DbError, ctx: &TranslateContext) -> FriendlyError { let mut fe = 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", ), }; // Attach the row pinpoint when the runtime resolved one. // The translator never builds the table itself — it only // forwards what enrichment supplied. if fe.diagnostic_table.is_none() { fe.diagnostic_table = ctx.diagnostic_table.clone(); } fe } 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 enrichment-driven first // (`parent_table` populated → child-side; `child_table` // populated → parent-side), with operation as the // tiebreaker when enrichment didn't run. // // - Insert always points "outward" → child-side. // - Delete always points "inward" → parent-side. // - Update can be either; we let the enrichment payload // choose, defaulting to child-side (the more pedagogically // common case for a learner). if ctx.parent_table.is_some() { return match ctx.operation { Some(Operation::Update) => fk_child_side_update(ctx), _ => fk_child_side_insert(ctx), }; } if ctx.child_table.is_some() { return match ctx.operation { Some(Operation::Update) => fk_parent_side_update(ctx), _ => fk_parent_side_delete(ctx), }; } match ctx.operation { Some(Operation::Delete) => fk_parent_side_delete(ctx), Some(Operation::Update) => fk_child_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_parent_table(ctx); let parent_column = ctx_parent_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_child_side_update(ctx: &TranslateContext) -> FriendlyError { let parent_table = ctx_parent_table(ctx); let parent_column = ctx_parent_column(ctx); let value = ctx_value(ctx); fe( t!( "error.foreign_key.child_side.update.headline", parent_table = parent_table, parent_column = parent_column, value = value ), verbose_hint( ctx, t!( "error.foreign_key.child_side.update.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(ctx); 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(ctx); 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 a `CHECK` failure by the column the // constraint sits on; the runtime's enrichment resolves the // table, the offending value, and — the teaching moment — // the rule itself (ADR-0029 §10). When the rule could not // be resolved, the plain hint stands on its own. let table = ctx_table(ctx); let column = ctx_column(ctx); let is_update = matches!(ctx.operation, Some(Operation::Update)); let headline = if is_update { t!("error.check.update.headline", table = table, column = column) } else { t!("error.check.insert.headline", table = table, column = column) }; let hint = ctx.check_rule.as_ref().map_or_else( || { if is_update { t!("error.check.update.hint", column = column) } else { t!("error.check.insert.hint", column = column) } }, |rule| { let value = ctx_value(ctx); if is_update { t!( "error.check.update.hint_with_rule", value = value, rule = rule, column = column ) } else { t!( "error.check.insert.hint_with_rule", value = value, rule = rule, column = column ) } }, ); fe(headline, verbose_hint(ctx, hint)) } // ---- 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. // // ADR-0032 §11.5 engine-error translations: pattern-match // the engine's message text for the four Phase-2 cases that // arrive as `SqliteErrorKind::Other` and route each to its // engine-neutral catalog entry. The classifier // intentionally doesn't grow new SqliteErrorKind variants // for these — they share a single fallback bucket and are // distinguished by text pattern at translation time. let lower = message.to_ascii_lowercase(); if lower.contains("misuse of aggregate") { return headline_only(t!("engine.aggregate_misuse", name = "?")); } if lower.contains("group by") || lower.contains("must appear in") { return headline_only(t!("engine.group_by_required")); } if (lower.contains("union") || lower.contains("intersect") || lower.contains("except")) && lower.contains("result columns") { // Last-resort safety net — the pre-flight pass in 2d.1 // catches this in most cases; if the engine surfaces it // anyway, route it through the engine-neutral key. return headline_only(t!( "engine.compound_arity_mismatch", op = "set operator" )); } if lower.contains("scalar subquery") || lower.contains("more than one row") { return headline_only(t!("engine.scalar_subquery_too_many_rows")); } if lower.contains("recursive") && (lower.contains("cte") || lower.contains("union")) { return headline_only(t!("engine.recursive_cte_malformed")); } 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: 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) -> FriendlyError { FriendlyError { headline, hint, diagnostic_table: None, } } fn verbose_hint(ctx: &TranslateContext, hint: String) -> Option { 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. With runtime-side // enrichment (ADR-0019 §6) populating `FailureContext`, // these fallbacks rarely render in practice. fn ctx_table(ctx: &TranslateContext) -> String { ctx.table.clone().unwrap_or_else(|| "{table}".to_string()) } fn ctx_column(ctx: &TranslateContext) -> String { ctx.column.clone().unwrap_or_else(|| "{column}".to_string()) } fn ctx_value(ctx: &TranslateContext) -> String { ctx.value.clone().unwrap_or_else(|| "{value}".to_string()) } fn ctx_parent_table(ctx: &TranslateContext) -> String { ctx.parent_table.clone().unwrap_or_else(|| "{parent_table}".to_string()) } fn ctx_parent_column(ctx: &TranslateContext) -> String { ctx.parent_column.clone().unwrap_or_else(|| "{parent_column}".to_string()) } fn ctx_child_table(ctx: &TranslateContext) -> String { ctx.child_table.clone().unwrap_or_else(|| "{child_table}".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 { 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.parent_table = Some("Customers".to_string()); ctx.parent_column = Some("id".to_string()); 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("Customers")); 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".to_string()); ctx.child_table = Some("Orders".to_string()); 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".to_string()); ctx.column = Some("age".to_string()); let f = translate(&err, &ctx); assert!(f.headline.contains("check constraint refused")); assert!(f.headline.contains("People")); assert!(f.headline.contains("age")); } #[test] fn check_with_a_resolved_rule_names_the_rule_and_value() { // ADR-0029 §10: when enrichment resolves the column's // compiled CHECK expression and the offending value, // the hint names both. let err = sqlite( "CHECK constraint failed: score", SqliteErrorKind::UniqueViolation, ); let mut ctx = ctx_with(Operation::Insert); ctx.table = Some("T".to_string()); ctx.column = Some("score".to_string()); ctx.value = Some("-5".to_string()); ctx.check_rule = Some("\"score\" >= 0".to_string()); let f = translate(&err, &ctx); assert!(f.headline.contains("check constraint refused")); let hint = f.hint.expect("a verbose hint"); assert!(hint.contains("-5"), "the offending value: {hint}"); assert!(hint.contains("\"score\" >= 0"), "the rule: {hint}"); } // ---- 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); } // ---- ADR-0032 §11.5 engine.* keys ---- #[test] fn aggregate_misuse_engine_message_routes_through_catalog() { let err = sqlite( "misuse of aggregate function COUNT()", SqliteErrorKind::Other, ); let f = translate(&err, &TranslateContext::default()); assert!( f.headline.contains("aggregate"), "expected engine.aggregate_misuse wording; got {}", f.headline, ); // Engine name (SQLite) must not appear (ADR-0002 posture). assert!( !f.headline.to_lowercase().contains("sqlite"), "headline leaks engine name: {}", f.headline, ); } #[test] fn group_by_required_engine_message_routes_through_catalog() { let err = sqlite( "column must appear in the GROUP BY clause or be used in an aggregate function", SqliteErrorKind::Other, ); let f = translate(&err, &TranslateContext::default()); assert!( f.headline.contains("GROUP BY"), "expected engine.group_by_required wording; got {}", f.headline, ); } #[test] fn compound_arity_engine_message_routes_through_catalog() { let err = sqlite( "SELECTs to the left and right of UNION do not have the same number of result columns", SqliteErrorKind::Other, ); let f = translate(&err, &TranslateContext::default()); assert!( f.headline.contains("number of columns"), "expected engine.compound_arity_mismatch wording; got {}", f.headline, ); } #[test] fn scalar_subquery_too_many_rows_routes_through_catalog() { let err = sqlite( "scalar subquery returned more than one row", SqliteErrorKind::Other, ); let f = translate(&err, &TranslateContext::default()); assert!( f.headline.contains("more than one row"), "expected engine.scalar_subquery_too_many_rows wording; got {}", f.headline, ); } }