//! ADR-0002 user-facing posture: regression audit. //! //! ADR-0002's "User-facing posture" section commits to never //! exposing the underlying engine's name in user-visible //! strings. The chosen product (and its idioms — STRICT, //! PRAGMA, the rusqlite crate) is an implementation detail; //! students should leave with knowledge of relational concepts, //! not of one specific RDBMS. //! //! This test file exists so that a future change can't silently //! regress that posture. The strings asserted here are a //! representative cross-section of user-reachable surfaces: //! //! - CLI usage banner (`HELP_TEXT`). //! - In-app `help` output (`note_help`). //! - DSL parse-error wording. //! - Realistic `DbError` payloads carried via //! `friendly_message()` (the surface the runtime forwards to //! `AppEvent::DslFailed`). //! //! See ADR-0002 §"User-facing posture" for the contract. //! Code comments and ADR prose are explicitly allowed to name //! the engine — only user-facing strings are policed. use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use rdbms_playground::app::App; use rdbms_playground::cli::help_text; use rdbms_playground::db::{DbError, SqliteErrorKind}; use rdbms_playground::dsl::parse_command; use rdbms_playground::event::AppEvent; const FORBIDDEN: &[&str] = &[ // Product names. "SQLite", "sqlite", // Crate name. "rusqlite", // Engine-specific keywords / idioms. "STRICT", "PRAGMA", ]; /// Report the first forbidden token found in `s`, with byte /// offset, so failure output points at exactly what leaked. fn engine_vocab_leak(s: &str) -> Option<(&'static str, usize)> { for needle in FORBIDDEN { if let Some(pos) = s.find(needle) { return Some((needle, pos)); } } None } fn assert_clean(label: &str, s: &str) { if let Some((needle, pos)) = engine_vocab_leak(s) { panic!( "ADR-0002 leak in {label}: found `{needle}` at byte {pos} in:\n{s}" ); } } const fn key(code: KeyCode) -> AppEvent { AppEvent::Key(KeyEvent { code, modifiers: KeyModifiers::NONE, kind: KeyEventKind::Press, state: crossterm::event::KeyEventState::NONE, }) } fn type_str(app: &mut App, s: &str) { for c in s.chars() { app.update(key(KeyCode::Char(c))); } } fn submit(app: &mut App) { app.update(key(KeyCode::Enter)); } fn collect_output(app: &App) -> String { app.output .iter() .map(|l| l.text.as_str()) .collect::>() .join("\n") } #[test] fn cli_help_text_uses_no_engine_vocabulary() { assert_clean("CLI help_text()", &help_text()); } #[test] fn in_app_help_uses_no_engine_vocabulary() { let mut app = App::new(); type_str(&mut app, "help"); submit(&mut app); assert_clean("in-app help", &collect_output(&app)); } #[test] fn parse_errors_use_no_engine_vocabulary() { // A representative set of failing inputs: structural // (missing colon, wrong keyword), unknown type, and the // change-column flag conflict. All must produce // engine-free messages. let inputs: &[&str] = &[ // structural: column-name-first typo (the parser // tiny-win recipe from handoff-5). "change column Tag in Customers: Tag (text)", // unknown type token. "create table T with pk id:varchar", // mutually exclusive flags on change column. "change column T: c (int) --force-conversion --dont-convert", // missing required clause. "create table T", // garbage. "this is not a command", ]; for input in inputs { let err = parse_command(input) .expect_err(&format!("expected parse failure for `{input}`")); let rendered = format!("{err:?}"); assert_clean(&format!("parse error for `{input}`"), &rendered); } } #[test] fn db_error_friendly_message_uses_no_engine_vocabulary() { // A representative set of `DbError` payloads, mirroring the // shapes the runtime actually surfaces via // `AppEvent::DslFailed { error: DbError::friendly_message }`. // These cover the three code-constructed variants: Sqlite // (engine-classified, message comes from rusqlite or our own // hand-rolled "no such ..."), Unsupported (refusals), and // InvalidValue (input validation). let cases: Vec<(&str, DbError)> = vec![ ( "no-such-table", DbError::Sqlite { message: "no such table: Customers".to_string(), kind: SqliteErrorKind::NoSuchTable, }, ), ( "no-such-column", DbError::Sqlite { message: "no such column: Customers.zip".to_string(), kind: SqliteErrorKind::NoSuchColumn, }, ), ( "unique-violation", DbError::Sqlite { message: "UNIQUE constraint failed: T.id".to_string(), kind: SqliteErrorKind::UniqueViolation, }, ), ( "fk-violation", DbError::Sqlite { message: "FOREIGN KEY constraint failed".to_string(), kind: SqliteErrorKind::Other, }, ), ( "unsupported-refusal", DbError::Unsupported( "cannot drop primary-key column `T.id`. \ Drop the table or change the primary key first." .to_string(), ), ), ( "invalid-value", DbError::InvalidValue("expected 3 value(s), got 2".to_string()), ), ]; for (label, err) in cases { assert_clean(label, &err.friendly_message()); } }