diff --git a/src/app.rs b/src/app.rs index a9beaab..00b47fd 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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: " 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 ``". + 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 `` 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 ``. +/// +/// 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 { + 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 { + 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 ``. + 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::>() + .join("\n"); + assert!( + body.contains("`1`"), + "expected the attempted value `1` in headline:\n{body}" + ); + assert!( + !body.contains(""), + " 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::>() + .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::>() + .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::>() + .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::>() + .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] diff --git a/src/db.rs b/src/db.rs index 900788c..f989c0b 100644 --- a/src/db.rs +++ b/src/db.rs @@ -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 { - 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:?}"), diff --git a/src/event.rs b/src/event.rs index 345470d..5cbb79a 100644 --- a/src/event.rs +++ b/src/event.rs @@ -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), diff --git a/src/friendly/error.rs b/src/friendly/error.rs new file mode 100644 index 0000000..9d9a1cf --- /dev/null +++ b/src/friendly/error.rs @@ -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 +//! +//! +//! (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, + pub rows: Vec>, + pub alignments: Vec, +} + +/// 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, + pub diagnostic_table: Option, +} + +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 { + let mut out: Vec = 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"); + } +} diff --git a/src/friendly/format.rs b/src/friendly/format.rs new file mode 100644 index 0000000..ca3c338 --- /dev/null +++ b/src/friendly/format.rs @@ -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, +} + +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 { + self.entries.keys().map(String::as_str) + } +} + +fn flatten( + value: &serde_yml::Value, + prefix: String, + out: &mut HashMap, +) { + 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 = 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::>() + ); + } + + #[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); + } +} diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs new file mode 100644 index 0000000..e8eee79 --- /dev/null +++ b/src/friendly/keys.rs @@ -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 = Vec::new(); + + for (key, expected) in KEYS_AND_PLACEHOLDERS { + let Some(template) = cat.get(key) else { + errors.push(format!("catalog missing key `{key}`")); + continue; + }; + + // Placeholder set check (declared ↔ used). + let actual = collect_placeholders(template); + let expected_set: HashSet<&str> = expected.iter().copied().collect(); + for name in &expected_set { + if !actual.contains(*name) { + errors.push(format!( + "key `{key}`: declared placeholder `{{{name}}}` is not used in template:\n{template}" + )); + } + } + for name in &actual { + if !expected_set.contains(name.as_str()) { + errors.push(format!( + "key `{key}`: template uses `{{{name}}}` but it isn't declared in keys.rs:\n{template}" + )); + } + } + + // Format-specifier check (ADR-0019 §8.4). Look for + // `{name:...}` shapes — the substitute helper would + // panic at runtime, but catching it at test time + // means we never ship a binary that can hit that + // panic. + if has_format_specifier(template) { + errors.push(format!( + "key `{key}`: template contains a `{{name:...}}` format specifier:\n{template}" + )); + } + + // Engine-vocabulary check (ADR-0002 user-facing + // posture, regression-tested in + // tests/engine_vocabulary_audit.rs). + for needle in FORBIDDEN_ENGINE_VOCABULARY { + if template.contains(needle) { + errors.push(format!( + "key `{key}`: template contains forbidden token `{needle}`:\n{template}" + )); + } + } + } + + let declared: HashSet<&str> = + KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect(); + for key in cat.keys() { + if key.starts_with("_test.") { + continue; + } + if !declared.contains(key) { + errors.push(format!( + "catalog has key `{key}` but it isn't declared in keys::KEYS_AND_PLACEHOLDERS" + )); + } + } + + assert!( + errors.is_empty(), + "catalog validation failed:\n {}", + errors.join("\n ") + ); + } + + /// Mirror of `tests/engine_vocabulary_audit.rs::FORBIDDEN`, + /// duplicated here so the catalog validator is self-contained + /// (no dependency on the integration-test binary). + const FORBIDDEN_ENGINE_VOCABULARY: &[&str] = &[ + "SQLite", "sqlite", "rusqlite", "STRICT", "PRAGMA", + ]; + + /// Detect a `{name:...}` format-specifier placeholder. + /// Doubled braces `{{` / `}}` are escapes — must skip them. + fn has_format_specifier(template: &str) -> bool { + let mut chars = template.chars().peekable(); + while let Some(c) = chars.next() { + if c == '{' { + if chars.peek() == Some(&'{') { + chars.next(); + continue; + } + while let Some(&nc) = chars.peek() { + if nc == '}' { + break; + } + if nc == ':' { + return true; + } + chars.next(); + } + } else if c == '}' && chars.peek() == Some(&'}') { + chars.next(); + } + } + false + } + + /// Walk `template` and pull out every `{name}` placeholder. + /// Mirrors the substitution helper's parse — if the helper + /// accepts a placeholder, this collects it. + fn collect_placeholders(template: &str) -> HashSet { + let mut out = HashSet::new(); + let mut chars = template.chars().peekable(); + while let Some(c) = chars.next() { + if c == '{' { + if chars.peek() == Some(&'{') { + chars.next(); + continue; + } + let mut name = String::new(); + while let Some(&nc) = chars.peek() { + if nc == '}' { + chars.next(); + break; + } + chars.next(); + name.push(nc); + } + if !name.is_empty() && !name.contains(':') { + out.insert(name); + } + } else if c == '}' && chars.peek() == Some(&'}') { + chars.next(); + } + } + out + } +} diff --git a/src/friendly/mod.rs b/src/friendly/mod.rs new file mode 100644 index 0000000..738a074 --- /dev/null +++ b/src/friendly/mod.rs @@ -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/.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) ),*], + ) + }}; +} diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml new file mode 100644 index 0000000..111fbec --- /dev/null +++ b/src/friendly/strings/en-US.yaml @@ -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." diff --git a/src/friendly/translate.rs b/src/friendly/translate.rs new file mode 100644 index 0000000..f4ef2dc --- /dev/null +++ b/src/friendly/translate.rs @@ -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, + 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, + pub target_type: Option, + /// 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 `` otherwise. + pub value: Option, + 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: 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. 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); + } +} diff --git a/src/lib.rs b/src/lib.rs index d3f7a19..7dcd3e9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/main.rs b/src/main.rs index 5e43087..5eae090 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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) => { diff --git a/src/runtime.rs b/src/runtime.rs index 4d56ab6..0e9fe84 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -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() { diff --git a/tests/walking_skeleton.rs b/tests/walking_skeleton.rs index ea59c9d..b2983ed 100644 --- a/tests/walking_skeleton.rs +++ b/tests/walking_skeleton.rs @@ -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!(