diff --git a/tests/engine_vocabulary_audit.rs b/tests/engine_vocabulary_audit.rs new file mode 100644 index 0000000..6758ceb --- /dev/null +++ b/tests/engine_vocabulary_audit.rs @@ -0,0 +1,182 @@ +//! 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()); + } +}