feat(hint): H2 Phase A — hint command + F1 keybinding skeleton (ADR-0053)

The mechanism for the contextual hint, with tier-2 fallback; the
tier-3 corpus lands in later phases.

- new CommandNode `hint_id` field (all None for now)
- AppCommand::Hint + HINT grammar node + REGISTRY + dispatch
- F1 read-only overlay in handle_key (buffer/cursor/memo untouched)
- note_hint* renderers; hint_id_for_input_in_mode (shared selection
  helper refactored out of usage_keys_for_input_in_mode)
- last_error_hint_key + friendly::error_hint_class classifier
- catalogue: help.app.hint / parse.usage.hint / hint.getting_started
- +12 tests; 2483 pass / 1 ignored, clippy clean
This commit is contained in:
claude@clouddev1
2026-06-15 10:36:51 +00:00
parent 9868442889
commit 050b36391e
12 changed files with 550 additions and 32 deletions
+261
View File
@@ -271,6 +271,13 @@ pub struct App {
pub nav_focus: NavFocus, pub nav_focus: NavFocus,
pub output: VecDeque<OutputLine>, pub output: VecDeque<OutputLine>,
pub hint: Option<String>, pub hint: Option<String>,
/// Catalog class key of the most recent runtime error (H2 /
/// ADR-0053 D5), e.g. `foreign_key.child_side`. Set when a
/// friendly error is rendered, cleared on the next successful
/// command. The submitted `hint` command and empty-input F1 use
/// it to render that error's tier-3 `hint.err.<class>` block.
/// `None` → no recent error → the "getting started" pointer.
pub last_error_hint_key: Option<String>,
/// The validity indicator's currently-visible verdict /// The validity indicator's currently-visible verdict
/// (ADR-0027). `None` means the indicator shows nothing — /// (ADR-0027). `None` means the indicator shows nothing —
/// the input is clean, or it is hidden mid-typing while the /// the input is clean, or it is hidden mid-typing while the
@@ -521,6 +528,7 @@ pub const fn demo_badge_label(key: &KeyEvent) -> Option<&'static str> {
match (key.code, key.modifiers) { match (key.code, key.modifiers) {
(KeyCode::Tab, _) => Some("[TAB]"), (KeyCode::Tab, _) => Some("[TAB]"),
(KeyCode::BackTab, _) => Some("[SHIFT-TAB]"), (KeyCode::BackTab, _) => Some("[SHIFT-TAB]"),
(KeyCode::F(1), _) => Some("[F1]"),
(KeyCode::Enter, _) => Some("[ENTER]"), (KeyCode::Enter, _) => Some("[ENTER]"),
(KeyCode::Esc, _) => Some("[ESC]"), (KeyCode::Esc, _) => Some("[ESC]"),
(KeyCode::Up, _) => Some("[UP]"), (KeyCode::Up, _) => Some("[UP]"),
@@ -557,6 +565,7 @@ impl App {
nav_focus: NavFocus::Input, nav_focus: NavFocus::Input,
output: VecDeque::with_capacity(OUTPUT_CAPACITY), output: VecDeque::with_capacity(OUTPUT_CAPACITY),
hint: None, hint: None,
last_error_hint_key: None,
input_indicator: None, input_indicator: None,
tables: Vec::new(), tables: Vec::new(),
relationships: Vec::new(), relationships: Vec::new(),
@@ -1208,6 +1217,21 @@ impl App {
return self.handle_nav_key(key); return self.handle_nav_key(key);
} }
// H2 / ADR-0053: F1 is a read-only contextual-hint overlay —
// it emits into the output journal and must NOT touch the input
// buffer, cursor, or the completion memo, so it sits ahead of
// the memo-clearing completion match below. Non-empty input →
// a hint for the command being typed; empty input → expand on
// the most recent error (or a getting-started pointer).
if key.code == KeyCode::F(1) {
if self.input.trim().is_empty() {
self.note_hint_for_recent_error();
} else {
self.note_hint_for_input();
}
return Vec::new();
}
// ADR-0022 stage 8 — non-modal completion. Tab / // ADR-0022 stage 8 — non-modal completion. Tab /
// Shift-Tab cycle; Esc / Backspace undo the whole // Shift-Tab cycle; Esc / Backspace undo the whole
// last-Tab insertion in one keystroke while the memo // last-Tab insertion in one keystroke while the memo
@@ -1774,6 +1798,13 @@ impl App {
// recallable. The canonical (un-prefixed) text is what reaches // recallable. The canonical (un-prefixed) text is what reaches
// the journal via `ExecuteDsl.source`. // the journal via `ExecuteDsl.source`.
let is_app = matches!(&parsed, Ok(Command::App(_))); let is_app = matches!(&parsed, Ok(Command::App(_)));
// H2 / ADR-0053 D5: a new *DSL* command supersedes the previous
// runtime error for `hint`. App commands (incl. `hint` itself)
// and parse errors leave it intact, so `hint` still expands the
// last real error after, say, a `help` in between.
if matches!(&parsed, Ok(cmd) if !matches!(cmd, Command::App(_))) {
self.last_error_hint_key = None;
}
let advanced = submission_mode.is_advanced() && !is_app; let advanced = submission_mode.is_advanced() && !is_app;
let ring_line = if advanced { let ring_line = if advanced {
format!(": {effective_input}") format!(": {effective_input}")
@@ -1814,6 +1845,13 @@ impl App {
} }
Vec::new() Vec::new()
} }
// H2 / ADR-0053: a submitted `hint` acts on the most recent
// runtime error (the buffer is empty post-submit). The
// live-input surface is the F1 keybinding (handle_key).
AppCommand::Hint => {
self.note_hint_for_recent_error();
Vec::new()
}
AppCommand::Rebuild => vec![Action::PrepareRebuild], AppCommand::Rebuild => vec![Action::PrepareRebuild],
AppCommand::Save => self.handle_save_command(false), AppCommand::Save => self.handle_save_command(false),
AppCommand::SaveAs => self.handle_save_command(true), AppCommand::SaveAs => self.handle_save_command(true),
@@ -2422,6 +2460,10 @@ impl App {
// runtime built before posting the event. // runtime built before posting the event.
let ctx = self.build_translate_context(command, facts); let ctx = self.build_translate_context(command, facts);
let rendered = crate::friendly::translate_error(&error, &ctx).render(); let rendered = crate::friendly::translate_error(&error, &ctx).render();
// H2 / ADR-0053 D5: remember this error's tier-3 class so a
// following `hint` (or empty-input F1) can expand on it.
self.last_error_hint_key =
crate::friendly::error_hint_class(&error, &ctx).map(String::from);
warn!( warn!(
verb = command.verb(), verb = command.verb(),
error = %rendered, error = %rendered,
@@ -3091,6 +3133,94 @@ impl App {
} }
} }
// ── H2 / ADR-0053: contextual `hint` ────────────────────────
// Phase A wires the two surfaces (F1 → live input; the `hint`
// command → most recent error) plus the tier-2 fallback. The
// tier-3 corpus (`hint.cmd.*` / `hint.err.*`) is authored in later
// phases; until a block exists, `emit_tier3_block` returns false
// and the surface degrades to the ambient prose / getting-started
// pointer — never blank.
/// F1 with a non-empty buffer: a tier-3 hint for the command form
/// being typed, else the tier-2 ambient prose (ADR-0053 D2).
/// Read-only — callers guarantee the buffer/cursor/memo are left
/// untouched.
fn note_hint_for_input(&mut self) {
// `feedback_view` strips the `:` one-shot sigil and
// `effective_mode` reflects the one-shot advanced surface, so
// the hint matches the command the user is actually typing.
let (view, cursor, _off) = self.feedback_view();
let probe = view.to_string();
let mode = self.effective_mode().as_mode();
if let Some(id) = crate::dsl::grammar::hint_id_for_input_in_mode(&probe, mode)
&& self.emit_tier3_block(&format!("hint.cmd.{id}"))
{
return;
}
// Tier-2 fallback: surface the ambient prose as a persistent
// line (computed exactly as the live panel does).
let ambient = crate::input_render::ambient_hint_in_mode(
&probe,
cursor,
self.last_completion.as_ref(),
&self.schema_cache,
mode,
);
match ambient {
Some(crate::input_render::AmbientHint::Prose(text)) => {
self.push_category_three_prose(text);
}
Some(crate::input_render::AmbientHint::Candidates { items, .. }) => {
let names = items
.iter()
.map(|c| c.text.clone())
.collect::<Vec<_>>()
.join(", ");
self.push_category_three_prose(crate::t!("hint.ambient_expected", expected = names));
}
None => self.note_getting_started(),
}
}
/// The `hint` command (and empty-input F1): expand on the most
/// recent runtime error, else point the user at how to start
/// (ADR-0053 D2/D5).
fn note_hint_for_recent_error(&mut self) {
if let Some(class) = self.last_error_hint_key.clone()
&& self.emit_tier3_block(&format!("hint.err.{class}"))
{
return;
}
self.note_getting_started();
}
fn note_getting_started(&mut self) {
self.note_system(crate::t!("hint.getting_started"));
}
/// Render a tier-3 block (`<stem>.what` / `.example` / `.concept`)
/// when it has been authored; returns `false` if the `what` part is
/// absent so the caller can fall back to tier 2. `what` is
/// mandatory, `example`/`concept` optional (ADR-0053 D3). Styling
/// polish (the framed block) lands with the corpus.
fn emit_tier3_block(&mut self, stem: &str) -> bool {
let cat = crate::friendly::catalog();
if cat.get(&format!("{stem}.what")).is_none() {
return false;
}
self.note_system(crate::friendly::translate(&format!("{stem}.what"), &[]));
if cat.get(&format!("{stem}.example")).is_some() {
self.note_system(crate::friendly::translate(&format!("{stem}.example"), &[]));
}
if cat.get(&format!("{stem}.concept")).is_some() {
self.push_category_three_prose(crate::friendly::translate(
&format!("{stem}.concept"),
&[],
));
}
true
}
fn note_system(&mut self, text: impl Into<String>) { fn note_system(&mut self, text: impl Into<String>) {
self.push_multiline(text.into(), OutputKind::System); self.push_multiline(text.into(), OutputKind::System);
} }
@@ -5539,6 +5669,137 @@ mod tests {
assert!(last.text.contains("Ghost"), "{}", last.text); assert!(last.text.contains("Ghost"), "{}", last.text);
} }
// ── H2 / ADR-0053: contextual `hint` (Phase A skeleton) ──────
fn f1(app: &mut App) -> Vec<Action> {
app.update(key(KeyCode::F(1)))
}
fn no_such_table_failure() -> AppEvent {
AppEvent::DslFailed {
command: Command::DropTable {
name: "Ghost".to_string(),
},
error: crate::db::DbError::Sqlite {
message: "no such table: Ghost".to_string(),
kind: crate::db::SqliteErrorKind::NoSuchTable,
},
facts: crate::friendly::FailureContext::default(),
source: String::new(),
advanced: false,
}
}
#[test]
fn hint_command_parses_to_app_hint() {
use crate::dsl::{parse_command, AppCommand, Command};
assert!(matches!(
parse_command("hint"),
Ok(Command::App(AppCommand::Hint))
));
}
#[test]
fn hint_command_with_no_recent_error_shows_getting_started() {
let mut app = App::new();
type_str(&mut app, "hint");
submit(&mut app);
assert!(output_contains(&app, "press F1"), "{}", error_lines(&app));
}
#[test]
fn f1_on_empty_input_with_no_error_shows_getting_started() {
let mut app = App::new();
let before = app.output.len();
f1(&mut app);
assert!(app.output.len() > before, "F1 must emit something");
assert!(output_contains(&app, "press F1"));
}
#[test]
fn f1_is_a_read_only_overlay() {
let mut app = App::new();
type_str(&mut app, "insert into T");
let input = app.input.clone();
let cursor = app.input_cursor;
let before = app.output.len();
f1(&mut app);
assert_eq!(app.input, input, "F1 must not change the buffer");
assert_eq!(app.input_cursor, cursor, "F1 must not move the cursor");
assert!(app.output.len() > before, "F1 emits a hint line");
}
#[test]
fn f1_preserves_the_completion_memo() {
let mut app = App::new();
type_str(&mut app, "show ");
app.update(key(KeyCode::Tab));
assert!(app.last_completion.is_some(), "precondition: Tab sets the memo");
let input = app.input.clone();
f1(&mut app);
assert!(app.last_completion.is_some(), "F1 must not clear the memo");
assert_eq!(app.input, input, "F1 must not change the buffer");
}
#[test]
fn dsl_failure_sets_hint_class_and_a_later_dsl_command_clears_it() {
let mut app = App::new();
app.update(no_such_table_failure());
assert_eq!(app.last_error_hint_key.as_deref(), Some("not_found"));
// A new DSL command supersedes the previous error.
type_str(&mut app, "drop table Ghost");
submit(&mut app);
assert_eq!(app.last_error_hint_key, None);
}
#[test]
fn app_command_does_not_clear_the_hint_class() {
let mut app = App::new();
app.update(no_such_table_failure());
assert_eq!(app.last_error_hint_key.as_deref(), Some("not_found"));
// `help` (an app command) leaves the last error intact, so a
// following `hint` still expands on it.
type_str(&mut app, "help");
submit(&mut app);
assert_eq!(
app.last_error_hint_key.as_deref(),
Some("not_found"),
"an app command must not clear the last error's hint class"
);
}
#[test]
fn hint_after_error_emits_a_hint_without_panicking() {
// Phase A: no tier-3 `hint.err.*` content exists yet, so the
// error path falls back to the getting-started pointer. (Phase C
// replaces this with the real error block.)
let mut app = App::new();
app.update(no_such_table_failure());
let before = app.output.len();
type_str(&mut app, "hint");
submit(&mut app);
assert!(app.output.len() > before, "hint must emit something");
}
#[test]
fn help_list_includes_hint() {
let mut app = App::new();
type_str(&mut app, "help");
submit(&mut app);
assert!(
output_contains(&app, "explain the most recent error"),
"help list must advertise the hint command"
);
}
#[test]
fn help_hint_describes_the_hint_command() {
let mut app = App::new();
type_str(&mut app, "help hint");
submit(&mut app);
assert!(output_contains(&app, "explain the most recent error"));
}
#[test] #[test]
fn messages_command_toggles_verbosity_and_reports() { fn messages_command_toggles_verbosity_and_reports() {
let mut app = App::new(); let mut app = App::new();
+6
View File
@@ -552,6 +552,11 @@ pub enum AppCommand {
Help { Help {
topic: Option<String>, topic: Option<String>,
}, },
/// Show a contextual tier-3 hint (H2 / ADR-0053). No argument:
/// when submitted, it expands on the most recent runtime error
/// (the buffer is empty post-submit). The live-input surface is
/// the F1 keybinding, handled in `App::handle_key`, not here.
Hint,
/// Rebuild `playground.db` from `project.yaml` + data/, with /// Rebuild `playground.db` from `project.yaml` + data/, with
/// confirmation modal. /// confirmation modal.
Rebuild, Rebuild,
@@ -1013,6 +1018,7 @@ impl Command {
Self::App(app) => match app { Self::App(app) => match app {
AppCommand::Quit => "quit", AppCommand::Quit => "quit",
AppCommand::Help { .. } => "help", AppCommand::Help { .. } => "help",
AppCommand::Hint => "hint",
AppCommand::Rebuild => "rebuild", AppCommand::Rebuild => "rebuild",
AppCommand::Save => "save", AppCommand::Save => "save",
AppCommand::SaveAs => "save as", AppCommand::SaveAs => "save as",
+25
View File
@@ -177,6 +177,9 @@ const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result<Command, Va
const fn build_undo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> { const fn build_undo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Undo)) Ok(Command::App(AppCommand::Undo))
} }
const fn build_hint(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Hint))
}
const fn build_redo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> { const fn build_redo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Redo)) Ok(Command::App(AppCommand::Redo))
@@ -263,6 +266,7 @@ pub static QUIT: CommandNode = CommandNode {
shape: EMPTY_SEQ, shape: EMPTY_SEQ,
ast_builder: build_quit, ast_builder: build_quit,
help_id: Some("app.quit"), help_id: Some("app.quit"),
hint_id: None,
usage_ids: &["parse.usage.quit"],}; usage_ids: &["parse.usage.quit"],};
pub static HELP: CommandNode = CommandNode { pub static HELP: CommandNode = CommandNode {
@@ -270,13 +274,24 @@ pub static HELP: CommandNode = CommandNode {
shape: HELP_TOPIC_OPT, shape: HELP_TOPIC_OPT,
ast_builder: build_help, ast_builder: build_help,
help_id: Some("app.help"), help_id: Some("app.help"),
hint_id: None,
usage_ids: &["parse.usage.help"],}; usage_ids: &["parse.usage.help"],};
pub static HINT: CommandNode = CommandNode {
entry: Word::keyword("hint"),
shape: EMPTY_SEQ,
ast_builder: build_hint,
help_id: Some("app.hint"),
// hint_id assigned in Phase C with the tier-3 corpus (ADR-0053).
hint_id: None,
usage_ids: &["parse.usage.hint"],};
pub static REBUILD: CommandNode = CommandNode { pub static REBUILD: CommandNode = CommandNode {
entry: Word::keyword("rebuild"), entry: Word::keyword("rebuild"),
shape: EMPTY_SEQ, shape: EMPTY_SEQ,
ast_builder: build_rebuild, ast_builder: build_rebuild,
help_id: Some("app.rebuild"), help_id: Some("app.rebuild"),
hint_id: None,
usage_ids: &["parse.usage.rebuild"],}; usage_ids: &["parse.usage.rebuild"],};
pub static SAVE: CommandNode = CommandNode { pub static SAVE: CommandNode = CommandNode {
@@ -284,6 +299,7 @@ pub static SAVE: CommandNode = CommandNode {
shape: SAVE_AS_OPT, shape: SAVE_AS_OPT,
ast_builder: build_save, ast_builder: build_save,
help_id: Some("app.save"), help_id: Some("app.save"),
hint_id: None,
usage_ids: &["parse.usage.save"],}; usage_ids: &["parse.usage.save"],};
pub static NEW: CommandNode = CommandNode { pub static NEW: CommandNode = CommandNode {
@@ -291,6 +307,7 @@ pub static NEW: CommandNode = CommandNode {
shape: EMPTY_SEQ, shape: EMPTY_SEQ,
ast_builder: build_new, ast_builder: build_new,
help_id: Some("app.new"), help_id: Some("app.new"),
hint_id: None,
usage_ids: &["parse.usage.new"],}; usage_ids: &["parse.usage.new"],};
pub static LOAD: CommandNode = CommandNode { pub static LOAD: CommandNode = CommandNode {
@@ -298,6 +315,7 @@ pub static LOAD: CommandNode = CommandNode {
shape: EMPTY_SEQ, shape: EMPTY_SEQ,
ast_builder: build_load, ast_builder: build_load,
help_id: Some("app.load"), help_id: Some("app.load"),
hint_id: None,
usage_ids: &["parse.usage.load"],}; usage_ids: &["parse.usage.load"],};
pub static EXPORT: CommandNode = CommandNode { pub static EXPORT: CommandNode = CommandNode {
@@ -305,6 +323,7 @@ pub static EXPORT: CommandNode = CommandNode {
shape: EXPORT_PATH_OPT, shape: EXPORT_PATH_OPT,
ast_builder: build_export, ast_builder: build_export,
help_id: Some("app.export"), help_id: Some("app.export"),
hint_id: None,
usage_ids: &["parse.usage.export"],}; usage_ids: &["parse.usage.export"],};
pub static IMPORT: CommandNode = CommandNode { pub static IMPORT: CommandNode = CommandNode {
@@ -312,6 +331,7 @@ pub static IMPORT: CommandNode = CommandNode {
shape: IMPORT_BODY_OPT, shape: IMPORT_BODY_OPT,
ast_builder: build_import, ast_builder: build_import,
help_id: Some("app.import"), help_id: Some("app.import"),
hint_id: None,
usage_ids: &["parse.usage.import"],}; usage_ids: &["parse.usage.import"],};
pub static MODE: CommandNode = CommandNode { pub static MODE: CommandNode = CommandNode {
@@ -319,6 +339,7 @@ pub static MODE: CommandNode = CommandNode {
shape: MODE_VALUE, shape: MODE_VALUE,
ast_builder: build_mode, ast_builder: build_mode,
help_id: Some("app.mode"), help_id: Some("app.mode"),
hint_id: None,
usage_ids: &["parse.usage.mode"],}; usage_ids: &["parse.usage.mode"],};
pub static MESSAGES: CommandNode = CommandNode { pub static MESSAGES: CommandNode = CommandNode {
@@ -326,6 +347,7 @@ pub static MESSAGES: CommandNode = CommandNode {
shape: MESSAGES_VALUE_OPT, shape: MESSAGES_VALUE_OPT,
ast_builder: build_messages, ast_builder: build_messages,
help_id: Some("app.messages"), help_id: Some("app.messages"),
hint_id: None,
usage_ids: &["parse.usage.messages"],}; usage_ids: &["parse.usage.messages"],};
pub static UNDO: CommandNode = CommandNode { pub static UNDO: CommandNode = CommandNode {
@@ -333,6 +355,7 @@ pub static UNDO: CommandNode = CommandNode {
shape: EMPTY_SEQ, shape: EMPTY_SEQ,
ast_builder: build_undo, ast_builder: build_undo,
help_id: Some("app.undo"), help_id: Some("app.undo"),
hint_id: None,
usage_ids: &["parse.usage.undo"],}; usage_ids: &["parse.usage.undo"],};
pub static REDO: CommandNode = CommandNode { pub static REDO: CommandNode = CommandNode {
@@ -340,6 +363,7 @@ pub static REDO: CommandNode = CommandNode {
shape: EMPTY_SEQ, shape: EMPTY_SEQ,
ast_builder: build_redo, ast_builder: build_redo,
help_id: Some("app.redo"), help_id: Some("app.redo"),
hint_id: None,
usage_ids: &["parse.usage.redo"],}; usage_ids: &["parse.usage.redo"],};
pub static COPY: CommandNode = CommandNode { pub static COPY: CommandNode = CommandNode {
@@ -347,4 +371,5 @@ pub static COPY: CommandNode = CommandNode {
shape: COPY_VALUE_OPT, shape: COPY_VALUE_OPT,
ast_builder: build_copy, ast_builder: build_copy,
help_id: Some("app.copy"), help_id: Some("app.copy"),
hint_id: None,
usage_ids: &["parse.usage.copy"],}; usage_ids: &["parse.usage.copy"],};
+13
View File
@@ -1790,6 +1790,7 @@ pub static SHOW: CommandNode = CommandNode {
shape: SHOW_SHAPE, shape: SHOW_SHAPE,
ast_builder: build_show, ast_builder: build_show,
help_id: Some("data.show"), help_id: Some("data.show"),
hint_id: None,
usage_ids: &[ usage_ids: &[
"parse.usage.show_data", "parse.usage.show_data",
"parse.usage.show_table", "parse.usage.show_table",
@@ -1805,6 +1806,7 @@ pub static SEED: CommandNode = CommandNode {
shape: SEED_SHAPE, shape: SEED_SHAPE,
ast_builder: build_seed, ast_builder: build_seed,
help_id: Some("data.seed"), help_id: Some("data.seed"),
hint_id: None,
usage_ids: &["parse.usage.seed"], usage_ids: &["parse.usage.seed"],
}; };
@@ -1813,6 +1815,7 @@ pub static INSERT: CommandNode = CommandNode {
shape: INSERT_SHAPE, shape: INSERT_SHAPE,
ast_builder: build_insert, ast_builder: build_insert,
help_id: Some("data.insert"), help_id: Some("data.insert"),
hint_id: None,
usage_ids: &["parse.usage.insert"],}; usage_ids: &["parse.usage.insert"],};
pub static UPDATE: CommandNode = CommandNode { pub static UPDATE: CommandNode = CommandNode {
@@ -1820,6 +1823,7 @@ pub static UPDATE: CommandNode = CommandNode {
shape: UPDATE_SHAPE, shape: UPDATE_SHAPE,
ast_builder: build_update, ast_builder: build_update,
help_id: Some("data.update"), help_id: Some("data.update"),
hint_id: None,
usage_ids: &["parse.usage.update"],}; usage_ids: &["parse.usage.update"],};
pub static DELETE: CommandNode = CommandNode { pub static DELETE: CommandNode = CommandNode {
@@ -1827,6 +1831,7 @@ pub static DELETE: CommandNode = CommandNode {
shape: DELETE_SHAPE, shape: DELETE_SHAPE,
ast_builder: build_delete, ast_builder: build_delete,
help_id: Some("data.delete"), help_id: Some("data.delete"),
hint_id: None,
usage_ids: &["parse.usage.delete"],}; usage_ids: &["parse.usage.delete"],};
pub static REPLAY: CommandNode = CommandNode { pub static REPLAY: CommandNode = CommandNode {
@@ -1834,6 +1839,7 @@ pub static REPLAY: CommandNode = CommandNode {
shape: REPLAY_PATH, shape: REPLAY_PATH,
ast_builder: build_replay, ast_builder: build_replay,
help_id: Some("data.replay"), help_id: Some("data.replay"),
hint_id: None,
usage_ids: &["parse.usage.replay"],}; usage_ids: &["parse.usage.replay"],};
pub static EXPLAIN: CommandNode = CommandNode { pub static EXPLAIN: CommandNode = CommandNode {
@@ -1841,6 +1847,7 @@ pub static EXPLAIN: CommandNode = CommandNode {
shape: EXPLAIN_SHAPE, shape: EXPLAIN_SHAPE,
ast_builder: build_explain, ast_builder: build_explain,
help_id: Some("data.explain"), help_id: Some("data.explain"),
hint_id: None,
usage_ids: &["parse.usage.explain"],}; usage_ids: &["parse.usage.explain"],};
/// `explain` over advanced-mode SQL (ADR-0039). /// `explain` over advanced-mode SQL (ADR-0039).
@@ -1860,6 +1867,7 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode {
// too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE` // too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE`
// precedent; otherwise `note_help` would print `explain` twice. // precedent; otherwise `note_help` would print `explain` twice.
help_id: None, help_id: None,
hint_id: None,
usage_ids: &[],}; usage_ids: &[],};
/// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032). /// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032).
@@ -1875,6 +1883,7 @@ pub static SELECT: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL), shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL),
ast_builder: build_select, ast_builder: build_select,
help_id: None, help_id: None,
hint_id: None,
usage_ids: &["parse.usage.select"],}; usage_ids: &["parse.usage.select"],};
/// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c). /// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c).
@@ -1889,6 +1898,7 @@ pub static WITH: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL), shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL),
ast_builder: build_select, ast_builder: build_select,
help_id: None, help_id: None,
hint_id: None,
usage_ids: &["parse.usage.with"],}; usage_ids: &["parse.usage.with"],};
/// SQL `INSERT` — the `Advanced`-category node of the shared /// SQL `INSERT` — the `Advanced`-category node of the shared
@@ -1906,6 +1916,7 @@ pub static SQL_INSERT: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE), shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE),
ast_builder: build_sql_insert, ast_builder: build_sql_insert,
help_id: None, help_id: None,
hint_id: None,
usage_ids: &[], usage_ids: &[],
}; };
@@ -1919,6 +1930,7 @@ pub static SQL_UPDATE: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE), shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE),
ast_builder: build_sql_update, ast_builder: build_sql_update,
help_id: None, help_id: None,
hint_id: None,
usage_ids: &[], usage_ids: &[],
}; };
@@ -1934,6 +1946,7 @@ pub static SQL_DELETE: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE), shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE),
ast_builder: build_sql_delete, ast_builder: build_sql_delete,
help_id: None, help_id: None,
hint_id: None,
usage_ids: &[], usage_ids: &[],
}; };
+11
View File
@@ -968,6 +968,7 @@ pub static DROP: CommandNode = CommandNode {
shape: DROP_SHAPE, shape: DROP_SHAPE,
ast_builder: build_drop, ast_builder: build_drop,
help_id: Some("ddl.drop"), help_id: Some("ddl.drop"),
hint_id: None,
usage_ids: &[ usage_ids: &[
"parse.usage.drop_table", "parse.usage.drop_table",
"parse.usage.drop_column", "parse.usage.drop_column",
@@ -981,6 +982,7 @@ pub static ADD: CommandNode = CommandNode {
shape: ADD_SHAPE, shape: ADD_SHAPE,
ast_builder: build_add, ast_builder: build_add,
help_id: Some("ddl.add"), help_id: Some("ddl.add"),
hint_id: None,
usage_ids: &[ usage_ids: &[
"parse.usage.add_column", "parse.usage.add_column",
"parse.usage.add_relationship", "parse.usage.add_relationship",
@@ -993,6 +995,7 @@ pub static RENAME: CommandNode = CommandNode {
shape: RENAME_COLUMN, shape: RENAME_COLUMN,
ast_builder: build_rename_column, ast_builder: build_rename_column,
help_id: Some("ddl.rename"), help_id: Some("ddl.rename"),
hint_id: None,
usage_ids: &["parse.usage.rename_column"],}; usage_ids: &["parse.usage.rename_column"],};
pub static CHANGE: CommandNode = CommandNode { pub static CHANGE: CommandNode = CommandNode {
@@ -1000,6 +1003,7 @@ pub static CHANGE: CommandNode = CommandNode {
shape: CHANGE_COLUMN, shape: CHANGE_COLUMN,
ast_builder: build_change_column, ast_builder: build_change_column,
help_id: Some("ddl.change"), help_id: Some("ddl.change"),
hint_id: None,
usage_ids: &["parse.usage.change_column"],}; usage_ids: &["parse.usage.change_column"],};
// ================================================================= // =================================================================
@@ -1360,6 +1364,7 @@ pub static CREATE: CommandNode = CommandNode {
shape: CREATE_TABLE, shape: CREATE_TABLE,
ast_builder: build_create_table, ast_builder: build_create_table,
help_id: Some("ddl.create"), help_id: Some("ddl.create"),
hint_id: None,
usage_ids: &["parse.usage.create_table"],}; usage_ids: &["parse.usage.create_table"],};
// ================================================================= // =================================================================
@@ -1428,6 +1433,7 @@ pub static CREATE_M2N: CommandNode = CommandNode {
shape: CREATE_M2N_SHAPE, shape: CREATE_M2N_SHAPE,
ast_builder: build_create_m2n, ast_builder: build_create_m2n,
help_id: Some("ddl.create_m2n"), help_id: Some("ddl.create_m2n"),
hint_id: None,
usage_ids: &["parse.usage.create_m2n"], usage_ids: &["parse.usage.create_m2n"],
}; };
@@ -1858,6 +1864,7 @@ pub static SQL_CREATE_TABLE: CommandNode = CommandNode {
shape: Node::Subgrammar(&super::sql_create_table::SQL_CREATE_TABLE_SHAPE), shape: Node::Subgrammar(&super::sql_create_table::SQL_CREATE_TABLE_SHAPE),
ast_builder: build_sql_create_table, ast_builder: build_sql_create_table,
help_id: Some("ddl.sql_create_table"), help_id: Some("ddl.sql_create_table"),
hint_id: None,
usage_ids: &["parse.usage.sql_create_table"], usage_ids: &["parse.usage.sql_create_table"],
}; };
@@ -1877,6 +1884,7 @@ pub static SQL_DROP_TABLE: CommandNode = CommandNode {
shape: SQL_DROP_TABLE_SHAPE, shape: SQL_DROP_TABLE_SHAPE,
ast_builder: build_sql_drop_table, ast_builder: build_sql_drop_table,
help_id: Some("ddl.sql_drop_table"), help_id: Some("ddl.sql_drop_table"),
hint_id: None,
usage_ids: &["parse.usage.sql_drop_table"], usage_ids: &["parse.usage.sql_drop_table"],
}; };
@@ -1896,6 +1904,7 @@ pub static SQL_DROP_INDEX: CommandNode = CommandNode {
shape: SQL_DROP_INDEX_SHAPE, shape: SQL_DROP_INDEX_SHAPE,
ast_builder: build_sql_drop_index, ast_builder: build_sql_drop_index,
help_id: Some("ddl.sql_drop_index"), help_id: Some("ddl.sql_drop_index"),
hint_id: None,
usage_ids: &["parse.usage.sql_drop_index"], usage_ids: &["parse.usage.sql_drop_index"],
}; };
@@ -1977,6 +1986,7 @@ pub static SQL_CREATE_INDEX: CommandNode = CommandNode {
shape: SQL_CREATE_INDEX_SHAPE, shape: SQL_CREATE_INDEX_SHAPE,
ast_builder: build_sql_create_index, ast_builder: build_sql_create_index,
help_id: Some("ddl.sql_create_index"), help_id: Some("ddl.sql_create_index"),
hint_id: None,
usage_ids: &["parse.usage.sql_create_index"], usage_ids: &["parse.usage.sql_create_index"],
}; };
@@ -2535,6 +2545,7 @@ pub static SQL_ALTER_TABLE: CommandNode = CommandNode {
shape: SQL_ALTER_TABLE_SHAPE, shape: SQL_ALTER_TABLE_SHAPE,
ast_builder: build_sql_alter_table, ast_builder: build_sql_alter_table,
help_id: Some("ddl.sql_alter_table"), help_id: Some("ddl.sql_alter_table"),
hint_id: None,
usage_ids: &["parse.usage.sql_alter_table"], usage_ids: &["parse.usage.sql_alter_table"],
}; };
+68 -31
View File
@@ -530,6 +530,15 @@ pub struct CommandNode {
/// so a newly-registered command appears in `help` /// so a newly-registered command appears in `help`
/// automatically (ADR-0024 §help_id). /// automatically (ADR-0024 §help_id).
pub help_id: Option<&'static str>, pub help_id: Option<&'static str>,
/// Catalog key stem (`hint.cmd.<id>`) for this command form's
/// **tier-3** contextual hint (ADR-0053 / H2). Unlike `help_id`
/// — which is `None` on advanced-SQL forms purely to dedup the
/// `help` list — `hint_id` is 1:1 with command *forms*, so each
/// advanced-SQL form carries its own id and renders SQL-syntax
/// content distinct from its simple-DSL sibling. `None` until a
/// form's tier-3 block is authored (the surface falls back to
/// tier-2 ambient/error text).
pub hint_id: Option<&'static str>,
/// Catalog keys under `parse.usage.*` to render in the /// Catalog keys under `parse.usage.*` to render in the
/// "usage:" block when a parse error fires for this command /// "usage:" block when a parse error fires for this command
/// (ADR-0021 §1, ADR-0024 §architecture). Multi-form families /// (ADR-0021 §1, ADR-0024 §architecture). Multi-form families
@@ -574,32 +583,69 @@ pub fn usage_keys_for_input_in_mode(
source: &str, source: &str,
mode: crate::mode::Mode, mode: crate::mode::Mode,
) -> Option<(&'static str, Vec<&'static str>)> { ) -> Option<(&'static str, Vec<&'static str>)> {
let pick = selected_nodes_for_input_in_mode(source, mode);
if pick.is_empty() {
return None;
}
let mut keys: Vec<&'static str> = Vec::new();
for (_, node, _) in &pick {
for k in node.usage_ids {
if !keys.contains(k) {
keys.push(*k);
}
}
}
if keys.is_empty() {
return None;
}
let entry = pick[0].1.entry.primary;
Some((entry, keys))
}
/// The tier-3 `hint_id` of the command form `source` is currently
/// typing, in `mode` (H2 / ADR-0053).
///
/// Reuses the same mode-aware
/// selection as [`usage_keys_for_input_in_mode`] and returns the
/// **mode-primary** node's `hint_id` — so an advanced-SQL form
/// resolves to its *own* id, not its simple-DSL sibling's. `None` if
/// no entry word matches, or the chosen form has no tier-3 block yet
/// (the caller then falls back to tier-2 ambient text).
#[must_use]
pub fn hint_id_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> {
selected_nodes_for_input_in_mode(source, mode)
.first()
.and_then(|(_, node, _)| node.hint_id)
}
/// Shared mode-aware command-form selection for the entry word at the
/// start of `source`.
///
/// Extracted so the usage-key and hint-id lookups agree on which form
/// the user is typing.
///
/// Advanced mode: every candidate form is reachable — the SQL nodes
/// are primary, and the DSL nodes remain valid via fallback (verified:
/// `create table … with pk` and `drop column …` both run in advanced
/// mode). Mode-primary (Advanced) first, so a hint never hides input
/// that works. Simple mode: only the DSL forms — the SQL-only forms
/// hit the "this is SQL" rail and are not reachable. (ADR-0042 G3.)
/// Degenerate guard: an advanced-only word in simple mode leaves the
/// selection empty; fall back to all candidates.
fn selected_nodes_for_input_in_mode(
source: &str,
mode: crate::mode::Mode,
) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace}; use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
let start = skip_whitespace(source, 0); let start = skip_whitespace(source, 0);
let (kw_start, kw_end) = consume_ident(source, start)?; let Some((kw_start, kw_end)) = consume_ident(source, start) else {
return Vec::new();
};
let word = &source[kw_start..kw_end]; let word = &source[kw_start..kw_end];
let candidates = commands_for_entry_word(word); let candidates = commands_for_entry_word(word);
if candidates.is_empty() { if candidates.is_empty() {
return None; return Vec::new();
} }
let union = |nodes: &[(usize, &'static CommandNode, CommandCategory)]| -> Vec<&'static str> {
let mut keys: Vec<&'static str> = Vec::new();
for (_, node, _) in nodes {
for k in node.usage_ids {
if !keys.contains(k) {
keys.push(*k);
}
}
}
keys
};
// Advanced mode: every candidate form is reachable — the SQL
// nodes are primary, and the DSL nodes remain valid via fallback
// (verified: `create table … with pk` and `drop column …` both
// run in advanced mode). Show them all, mode-primary (Advanced)
// first, so the usage hint never hides input that works. Simple
// mode: only the DSL forms — the SQL-only forms hit the "this is
// SQL" rail and are not reachable. (ADR-0042 G3.)
let selected: Vec<(usize, &'static CommandNode, CommandCategory)> = let selected: Vec<(usize, &'static CommandNode, CommandCategory)> =
if mode == crate::mode::Mode::Advanced { if mode == crate::mode::Mode::Advanced {
let mut v: Vec<_> = candidates let mut v: Vec<_> = candidates
@@ -621,17 +667,7 @@ pub fn usage_keys_for_input_in_mode(
.filter(|(_, _, c)| *c == CommandCategory::Simple) .filter(|(_, _, c)| *c == CommandCategory::Simple)
.collect() .collect()
}; };
// Degenerate guard: an advanced-only word in simple mode (not if selected.is_empty() { candidates } else { selected }
// normally reachable — it hits the SQL rail first) leaves
// `selected` empty; fall back to all candidates so a usage block
// still renders rather than the available-commands fallback.
let pick = if selected.is_empty() { candidates } else { selected };
let keys = union(&pick);
if keys.is_empty() {
return None;
}
let entry = pick[0].1.entry.primary;
Some((entry, keys))
} }
/// The single usage template most relevant to `source`, when /// The single usage template most relevant to `source`, when
@@ -712,6 +748,7 @@ pub fn entry_words_alphabetised() -> Vec<&'static str> {
pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
(&app::QUIT, CommandCategory::Simple), (&app::QUIT, CommandCategory::Simple),
(&app::HELP, CommandCategory::Simple), (&app::HELP, CommandCategory::Simple),
(&app::HINT, CommandCategory::Simple),
(&app::REBUILD, CommandCategory::Simple), (&app::REBUILD, CommandCategory::Simple),
(&app::SAVE, CommandCategory::Simple), (&app::SAVE, CommandCategory::Simple),
(&app::NEW, CommandCategory::Simple), (&app::NEW, CommandCategory::Simple),
+2
View File
@@ -6910,6 +6910,7 @@ mod dispatch_3a_tests {
shape: Node::Word(Word::keyword("dsltail")), shape: Node::Word(Word::keyword("dsltail")),
ast_builder: dsl_builder, ast_builder: dsl_builder,
help_id: None, help_id: None,
hint_id: None,
usage_ids: &[], usage_ids: &[],
}; };
static SMOKE_SQL: CommandNode = CommandNode { static SMOKE_SQL: CommandNode = CommandNode {
@@ -6917,6 +6918,7 @@ mod dispatch_3a_tests {
shape: Node::Word(Word::keyword("sqltail")), shape: Node::Word(Word::keyword("sqltail")),
ast_builder: sql_builder, ast_builder: sql_builder,
help_id: None, help_id: None,
hint_id: None,
usage_ids: &[], usage_ids: &[],
}; };
+3
View File
@@ -180,6 +180,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.unknown_topic", &["topic"]), ("help.unknown_topic", &["topic"]),
("help.app.quit", &[]), ("help.app.quit", &[]),
("help.app.help", &[]), ("help.app.help", &[]),
("help.app.hint", &[]),
("help.app.rebuild", &[]), ("help.app.rebuild", &[]),
("help.app.save", &[]), ("help.app.save", &[]),
("help.app.new", &[]), ("help.app.new", &[]),
@@ -222,6 +223,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
&["message", "usage"], &["message", "usage"],
), ),
("hint.ambient_expected", &["expected"]), ("hint.ambient_expected", &["expected"]),
("hint.getting_started", &[]),
( (
"hint.ambient_invalid_ident", "hint.ambient_invalid_ident",
&["kind", "found"], &["kind", "found"],
@@ -299,6 +301,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.usage.rename_column", &[]), ("parse.usage.rename_column", &[]),
("parse.usage.export", &[]), ("parse.usage.export", &[]),
("parse.usage.help", &[]), ("parse.usage.help", &[]),
("parse.usage.hint", &[]),
("parse.usage.import", &[]), ("parse.usage.import", &[]),
("parse.usage.copy", &[]), ("parse.usage.copy", &[]),
("parse.usage.load", &[]), ("parse.usage.load", &[]),
+1 -1
View File
@@ -35,7 +35,7 @@ pub mod translate;
pub use error::{DiagnosticTable, FriendlyError}; pub use error::{DiagnosticTable, FriendlyError};
pub use format::{catalog, Catalog}; pub use format::{catalog, Catalog};
pub use translate::{FailureContext, Operation, TranslateContext, Verbosity}; pub use translate::{error_hint_class, FailureContext, Operation, TranslateContext, Verbosity};
// `translate::translate` and `format::translate` are different // `translate::translate` and `format::translate` are different
// callables — the former is the structured DbError → FriendlyError // callables — the former is the structured DbError → FriendlyError
+6
View File
@@ -256,6 +256,8 @@ help:
help: |- help: |-
help — show this command list help — show this command list
help <command> — detailed help for one command (e.g. `help insert`) help <command> — detailed help for one command (e.g. `help insert`)
hint: |-
hint — explain the most recent error (press F1 for a hint on what you're typing)
rebuild: |- rebuild: |-
rebuild — rebuild the project database from project.yaml + data/ (with confirmation) rebuild — rebuild the project database from project.yaml + data/ (with confirmation)
save: |- save: |-
@@ -386,6 +388,9 @@ hint:
ambient_complete: "Submit with Enter" ambient_complete: "Submit with Enter"
ambient_expected: "Next: {expected}" ambient_expected: "Next: {expected}"
ambient_error_with_usage: "{message} — usage: {usage}" ambient_error_with_usage: "{message} — usage: {usage}"
# H2 / ADR-0053: shown by `hint` / F1 when there is nothing specific
# to expand on (no recent error, empty input).
getting_started: "Start typing a command and press F1 for a hint, or type `help` for the full command list."
# Invalid identifier in a schema slot (ADR-0022 stage 8e # Invalid identifier in a schema slot (ADR-0022 stage 8e
# + the user's #5). Voice mirrors ADR-0019's "no such # + the user's #5). Voice mirrors ADR-0019's "no such
# {kind}" wording for consistency with engine errors. # {kind}" wording for consistency with engine errors.
@@ -617,6 +622,7 @@ parse:
# description. # description.
quit: "quit" quit: "quit"
help: "help [<command>]" help: "help [<command>]"
hint: "hint"
rebuild: "rebuild" rebuild: "rebuild"
save: "save | save as" save: "save | save as"
new: "new" new: "new"
+153
View File
@@ -253,6 +253,73 @@ pub fn translate(error: &DbError, ctx: &TranslateContext) -> FriendlyError {
fe fe
} }
/// The tier-3 hint class (`hint.err.<class>`) for an error.
///
/// The same classification [`translate`] performs, surfaced as a
/// stable key for the contextual `hint` (H2 / ADR-0053 D5). Returns
/// `None` for internal / fatal errors that carry no learner-facing
/// hint (persistence, IO, worker-gone).
///
/// **Keep in sync with [`translate`] / `translate_sqlite` /
/// `translate_constraint` / `translate_foreign_key`** — the unit tests
/// below pin each class.
#[must_use]
pub fn error_hint_class(error: &DbError, ctx: &TranslateContext) -> Option<&'static str> {
match error {
DbError::Sqlite { message, kind } => sqlite_hint_class(message, *kind, ctx),
DbError::Unsupported(_) | DbError::InvalidValue(_) => Some("invalid_value"),
DbError::PersistenceFatal { .. }
| DbError::RebuildRowFailed { .. }
| DbError::Io(_)
| DbError::WorkerGone => None,
}
}
fn sqlite_hint_class(
message: &str,
kind: SqliteErrorKind,
ctx: &TranslateContext,
) -> Option<&'static str> {
if matches!(ctx.operation, Some(Operation::ChangeColumnType)) {
return Some("type_mismatch");
}
Some(match kind {
SqliteErrorKind::NoSuchTable | SqliteErrorKind::NoSuchColumn => "not_found",
SqliteErrorKind::AlreadyExists => "already_exists",
SqliteErrorKind::UniqueViolation => constraint_hint_class(message, ctx),
SqliteErrorKind::Other => "generic",
})
}
fn constraint_hint_class(message: &str, ctx: &TranslateContext) -> &'static str {
let lower = message.to_ascii_lowercase();
if lower.contains("unique constraint failed") {
"unique"
} else if lower.contains("foreign key constraint failed") {
fk_hint_class(ctx)
} else if lower.contains("not null constraint failed") {
"not_null"
} else if lower.contains("check constraint failed") {
"check"
} else {
"generic"
}
}
const fn fk_hint_class(ctx: &TranslateContext) -> &'static str {
// Mirrors `translate_foreign_key`'s side disambiguation.
if ctx.parent_table.is_some() {
return "foreign_key.child_side";
}
if ctx.child_table.is_some() {
return "foreign_key.parent_side";
}
match ctx.operation {
Some(Operation::Delete) => "foreign_key.parent_side",
_ => "foreign_key.child_side",
}
}
fn translate_sqlite( fn translate_sqlite(
message: &str, message: &str,
kind: SqliteErrorKind, kind: SqliteErrorKind,
@@ -798,6 +865,92 @@ mod tests {
} }
} }
// ── H2 / ADR-0053: error → tier-3 hint class ────────────────
#[test]
fn hint_class_maps_runtime_error_kinds() {
use crate::db::{DbError, SqliteErrorKind};
let sqlite = |kind, msg: &str| DbError::Sqlite {
message: msg.to_string(),
kind,
};
let d = TranslateContext::default;
assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::NoSuchTable, "no such table: X"), &d()),
Some("not_found")
);
assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::NoSuchColumn, "no such column: X"), &d()),
Some("not_found")
);
assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::AlreadyExists, "already exists"), &d()),
Some("already_exists")
);
assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::Other, "boom"), &d()),
Some("generic")
);
// Constraint-violation message splitting.
let cv = |msg: &str| sqlite(SqliteErrorKind::UniqueViolation, msg);
assert_eq!(
error_hint_class(&cv("UNIQUE constraint failed: T.c"), &d()),
Some("unique")
);
assert_eq!(
error_hint_class(&cv("NOT NULL constraint failed: T.c"), &d()),
Some("not_null")
);
assert_eq!(
error_hint_class(&cv("CHECK constraint failed: T"), &d()),
Some("check")
);
// change-column op routes any engine error to type_mismatch.
assert_eq!(
error_hint_class(
&sqlite(SqliteErrorKind::Other, "x"),
&ctx_with(Operation::ChangeColumnType)
),
Some("type_mismatch")
);
// App-level refusals and internal/fatal errors.
assert_eq!(
error_hint_class(&DbError::InvalidValue("bad".to_string()), &d()),
Some("invalid_value")
);
assert_eq!(error_hint_class(&DbError::WorkerGone, &d()), None);
}
#[test]
fn hint_class_resolves_foreign_key_sides() {
use crate::db::{DbError, SqliteErrorKind};
let fk = || DbError::Sqlite {
message: "FOREIGN KEY constraint failed".to_string(),
kind: SqliteErrorKind::UniqueViolation,
};
// Enrichment: parent_table populated → child-side.
let ctx = TranslateContext {
parent_table: Some("Parent".to_string()),
..TranslateContext::default()
};
assert_eq!(error_hint_class(&fk(), &ctx), Some("foreign_key.child_side"));
// child_table populated → parent-side.
let ctx = TranslateContext {
child_table: Some("Child".to_string()),
..TranslateContext::default()
};
assert_eq!(error_hint_class(&fk(), &ctx), Some("foreign_key.parent_side"));
// No enrichment: operation is the tiebreaker.
assert_eq!(
error_hint_class(&fk(), &ctx_with(Operation::Delete)),
Some("foreign_key.parent_side")
);
assert_eq!(
error_hint_class(&fk(), &ctx_with(Operation::Insert)),
Some("foreign_key.child_side")
);
}
fn sqlite(message: &str, kind: SqliteErrorKind) -> DbError { fn sqlite(message: &str, kind: SqliteErrorKind) -> DbError {
DbError::Sqlite { DbError::Sqlite {
message: message.to_string(), message: message.to_string(),
+1
View File
@@ -250,6 +250,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
App(app) => match app { App(app) => match app {
AppCommand::Quit => "App(Quit)".into(), AppCommand::Quit => "App(Quit)".into(),
AppCommand::Help { .. } => "App(Help)".into(), AppCommand::Help { .. } => "App(Help)".into(),
AppCommand::Hint => "App(Hint)".into(),
AppCommand::Rebuild => "App(Rebuild)".into(), AppCommand::Rebuild => "App(Rebuild)".into(),
AppCommand::Save => "App(Save)".into(), AppCommand::Save => "App(Save)".into(),
AppCommand::SaveAs => "App(SaveAs)".into(), AppCommand::SaveAs => "App(SaveAs)".into(),