720511ef29
Final pass of the i18n migration sweep. Every user-visible
string in `src/` now flows through the catalog via `t!()`.
## Categories migrated in this commit
- **help.cli_banner** — the entire `cli::HELP_TEXT` const,
formerly a 40-line `&'static str`, is now a YAML block in
the catalog. The const is replaced by a thin
`cli::help_text() -> String` wrapper that performs the
catalog lookup. `main.rs` calls `help_text()` for both
`--help` output and the args-parse error path. The two
integration tests that referenced `HELP_TEXT` directly are
updated.
- **help.in_app_body** — the in-app `help` command's body is
one YAML block; `note_help` becomes 5 lines that iterate
the lines and emit each as its own output row (preserving
the renderer's "one logical line = one display row"
invariant for accurate scroll math).
- **modal.*** — load picker, rebuild confirm, and save-as
path-entry strings: rebuild_cancelled, load_cancelled,
generic_cancelled, load_picker_nothing,
path_entry_empty_name, path_entry_empty_path.
- **dsl.failed** — the `"<verb> <subject>" failed: <rendered>`
wrapper around the friendly-error layer's translated
message.
- **dsl.running** — the `running: <input>` echo line shown
above each command's response. (Note: the en-US prefix
"running: " is hardcoded in the parse-error caret-padding
calculation. Translators changing the prefix must keep the
width consistent — documented inline.)
- **advanced_mode.not_implemented** — the placeholder echo
shown when SQL hits the unimplemented advanced-mode path
(Q1 territory).
- **fatal.persistence** — the FATAL banner for
PersistenceFatal events (ADR-0015 §8).
- **project.{load_path_missing,saveas_target_exists,**
**import_zip_missing}** — runtime-side project-switch
validation errors that surface via ProjectSwitchFailed.
## Catalog start-up ordering
`main.rs` now calls `friendly::catalog()` at the very top
(before args parsing) so `help_text()` works in both the
success path and the args-error path. A corrupted build
artefact still fails loudly with a useful panic; the
practical risk is essentially zero since the catalog is
`include_str!`'d at compile time and validated by the unit
test before shipping.
## Remaining literals
The only `note_*` calls in `src/` that still pass plain
strings are inside `#[cfg(test)]` modules — synthetic test
fixtures, not user-visible. The codebase passes the "every
user-visible string flows through the catalog" bar.
## Tally
610 tests passing (no change in count — pure refactor).
Clippy clean with nursery lints.
## What this closes
ADR-0019 §9 (migration sweep) — done.
ADR-0019 itself is now fully implemented:
- §1-§5: catalog + translator + voice + verbosity ✓ (`eac7e5b`)
- §6: row pinpointing + schema enrichment ✓ (`431645a`)
- §9: migration sweep ✓ (this + `aff528a`)
- §10: anchor phrases preserved throughout ✓
- The five "Out of scope" items remain explicitly bounded
to future ADRs (advanced-mode SQL, settings persistence,
pluralisation, runtime locale, value formatting,
constraint management).
183 lines
5.7 KiB
Rust
183 lines
5.7 KiB
Rust
//! 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::<Vec<_>>()
|
|
.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());
|
|
}
|
|
}
|