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:
+261
@@ -271,6 +271,13 @@ pub struct App {
|
||||
pub nav_focus: NavFocus,
|
||||
pub output: VecDeque<OutputLine>,
|
||||
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
|
||||
/// (ADR-0027). `None` means the indicator shows nothing —
|
||||
/// 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) {
|
||||
(KeyCode::Tab, _) => Some("[TAB]"),
|
||||
(KeyCode::BackTab, _) => Some("[SHIFT-TAB]"),
|
||||
(KeyCode::F(1), _) => Some("[F1]"),
|
||||
(KeyCode::Enter, _) => Some("[ENTER]"),
|
||||
(KeyCode::Esc, _) => Some("[ESC]"),
|
||||
(KeyCode::Up, _) => Some("[UP]"),
|
||||
@@ -557,6 +565,7 @@ impl App {
|
||||
nav_focus: NavFocus::Input,
|
||||
output: VecDeque::with_capacity(OUTPUT_CAPACITY),
|
||||
hint: None,
|
||||
last_error_hint_key: None,
|
||||
input_indicator: None,
|
||||
tables: Vec::new(),
|
||||
relationships: Vec::new(),
|
||||
@@ -1208,6 +1217,21 @@ impl App {
|
||||
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 /
|
||||
// Shift-Tab cycle; Esc / Backspace undo the whole
|
||||
// last-Tab insertion in one keystroke while the memo
|
||||
@@ -1774,6 +1798,13 @@ impl App {
|
||||
// recallable. The canonical (un-prefixed) text is what reaches
|
||||
// the journal via `ExecuteDsl.source`.
|
||||
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 ring_line = if advanced {
|
||||
format!(": {effective_input}")
|
||||
@@ -1814,6 +1845,13 @@ impl App {
|
||||
}
|
||||
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::Save => self.handle_save_command(false),
|
||||
AppCommand::SaveAs => self.handle_save_command(true),
|
||||
@@ -2422,6 +2460,10 @@ impl App {
|
||||
// runtime built before posting the event.
|
||||
let ctx = self.build_translate_context(command, facts);
|
||||
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!(
|
||||
verb = command.verb(),
|
||||
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>) {
|
||||
self.push_multiline(text.into(), OutputKind::System);
|
||||
}
|
||||
@@ -5539,6 +5669,137 @@ mod tests {
|
||||
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]
|
||||
fn messages_command_toggles_verbosity_and_reports() {
|
||||
let mut app = App::new();
|
||||
|
||||
@@ -552,6 +552,11 @@ pub enum AppCommand {
|
||||
Help {
|
||||
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
|
||||
/// confirmation modal.
|
||||
Rebuild,
|
||||
@@ -1013,6 +1018,7 @@ impl Command {
|
||||
Self::App(app) => match app {
|
||||
AppCommand::Quit => "quit",
|
||||
AppCommand::Help { .. } => "help",
|
||||
AppCommand::Hint => "hint",
|
||||
AppCommand::Rebuild => "rebuild",
|
||||
AppCommand::Save => "save",
|
||||
AppCommand::SaveAs => "save as",
|
||||
|
||||
@@ -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> {
|
||||
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> {
|
||||
Ok(Command::App(AppCommand::Redo))
|
||||
@@ -263,6 +266,7 @@ pub static QUIT: CommandNode = CommandNode {
|
||||
shape: EMPTY_SEQ,
|
||||
ast_builder: build_quit,
|
||||
help_id: Some("app.quit"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.quit"],};
|
||||
|
||||
pub static HELP: CommandNode = CommandNode {
|
||||
@@ -270,13 +274,24 @@ pub static HELP: CommandNode = CommandNode {
|
||||
shape: HELP_TOPIC_OPT,
|
||||
ast_builder: build_help,
|
||||
help_id: Some("app.help"),
|
||||
hint_id: None,
|
||||
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 {
|
||||
entry: Word::keyword("rebuild"),
|
||||
shape: EMPTY_SEQ,
|
||||
ast_builder: build_rebuild,
|
||||
help_id: Some("app.rebuild"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.rebuild"],};
|
||||
|
||||
pub static SAVE: CommandNode = CommandNode {
|
||||
@@ -284,6 +299,7 @@ pub static SAVE: CommandNode = CommandNode {
|
||||
shape: SAVE_AS_OPT,
|
||||
ast_builder: build_save,
|
||||
help_id: Some("app.save"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.save"],};
|
||||
|
||||
pub static NEW: CommandNode = CommandNode {
|
||||
@@ -291,6 +307,7 @@ pub static NEW: CommandNode = CommandNode {
|
||||
shape: EMPTY_SEQ,
|
||||
ast_builder: build_new,
|
||||
help_id: Some("app.new"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.new"],};
|
||||
|
||||
pub static LOAD: CommandNode = CommandNode {
|
||||
@@ -298,6 +315,7 @@ pub static LOAD: CommandNode = CommandNode {
|
||||
shape: EMPTY_SEQ,
|
||||
ast_builder: build_load,
|
||||
help_id: Some("app.load"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.load"],};
|
||||
|
||||
pub static EXPORT: CommandNode = CommandNode {
|
||||
@@ -305,6 +323,7 @@ pub static EXPORT: CommandNode = CommandNode {
|
||||
shape: EXPORT_PATH_OPT,
|
||||
ast_builder: build_export,
|
||||
help_id: Some("app.export"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.export"],};
|
||||
|
||||
pub static IMPORT: CommandNode = CommandNode {
|
||||
@@ -312,6 +331,7 @@ pub static IMPORT: CommandNode = CommandNode {
|
||||
shape: IMPORT_BODY_OPT,
|
||||
ast_builder: build_import,
|
||||
help_id: Some("app.import"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.import"],};
|
||||
|
||||
pub static MODE: CommandNode = CommandNode {
|
||||
@@ -319,6 +339,7 @@ pub static MODE: CommandNode = CommandNode {
|
||||
shape: MODE_VALUE,
|
||||
ast_builder: build_mode,
|
||||
help_id: Some("app.mode"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.mode"],};
|
||||
|
||||
pub static MESSAGES: CommandNode = CommandNode {
|
||||
@@ -326,6 +347,7 @@ pub static MESSAGES: CommandNode = CommandNode {
|
||||
shape: MESSAGES_VALUE_OPT,
|
||||
ast_builder: build_messages,
|
||||
help_id: Some("app.messages"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.messages"],};
|
||||
|
||||
pub static UNDO: CommandNode = CommandNode {
|
||||
@@ -333,6 +355,7 @@ pub static UNDO: CommandNode = CommandNode {
|
||||
shape: EMPTY_SEQ,
|
||||
ast_builder: build_undo,
|
||||
help_id: Some("app.undo"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.undo"],};
|
||||
|
||||
pub static REDO: CommandNode = CommandNode {
|
||||
@@ -340,6 +363,7 @@ pub static REDO: CommandNode = CommandNode {
|
||||
shape: EMPTY_SEQ,
|
||||
ast_builder: build_redo,
|
||||
help_id: Some("app.redo"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.redo"],};
|
||||
|
||||
pub static COPY: CommandNode = CommandNode {
|
||||
@@ -347,4 +371,5 @@ pub static COPY: CommandNode = CommandNode {
|
||||
shape: COPY_VALUE_OPT,
|
||||
ast_builder: build_copy,
|
||||
help_id: Some("app.copy"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.copy"],};
|
||||
|
||||
@@ -1790,6 +1790,7 @@ pub static SHOW: CommandNode = CommandNode {
|
||||
shape: SHOW_SHAPE,
|
||||
ast_builder: build_show,
|
||||
help_id: Some("data.show"),
|
||||
hint_id: None,
|
||||
usage_ids: &[
|
||||
"parse.usage.show_data",
|
||||
"parse.usage.show_table",
|
||||
@@ -1805,6 +1806,7 @@ pub static SEED: CommandNode = CommandNode {
|
||||
shape: SEED_SHAPE,
|
||||
ast_builder: build_seed,
|
||||
help_id: Some("data.seed"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.seed"],
|
||||
};
|
||||
|
||||
@@ -1813,6 +1815,7 @@ pub static INSERT: CommandNode = CommandNode {
|
||||
shape: INSERT_SHAPE,
|
||||
ast_builder: build_insert,
|
||||
help_id: Some("data.insert"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.insert"],};
|
||||
|
||||
pub static UPDATE: CommandNode = CommandNode {
|
||||
@@ -1820,6 +1823,7 @@ pub static UPDATE: CommandNode = CommandNode {
|
||||
shape: UPDATE_SHAPE,
|
||||
ast_builder: build_update,
|
||||
help_id: Some("data.update"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.update"],};
|
||||
|
||||
pub static DELETE: CommandNode = CommandNode {
|
||||
@@ -1827,6 +1831,7 @@ pub static DELETE: CommandNode = CommandNode {
|
||||
shape: DELETE_SHAPE,
|
||||
ast_builder: build_delete,
|
||||
help_id: Some("data.delete"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.delete"],};
|
||||
|
||||
pub static REPLAY: CommandNode = CommandNode {
|
||||
@@ -1834,6 +1839,7 @@ pub static REPLAY: CommandNode = CommandNode {
|
||||
shape: REPLAY_PATH,
|
||||
ast_builder: build_replay,
|
||||
help_id: Some("data.replay"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.replay"],};
|
||||
|
||||
pub static EXPLAIN: CommandNode = CommandNode {
|
||||
@@ -1841,6 +1847,7 @@ pub static EXPLAIN: CommandNode = CommandNode {
|
||||
shape: EXPLAIN_SHAPE,
|
||||
ast_builder: build_explain,
|
||||
help_id: Some("data.explain"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.explain"],};
|
||||
|
||||
/// `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`
|
||||
// precedent; otherwise `note_help` would print `explain` twice.
|
||||
help_id: None,
|
||||
hint_id: None,
|
||||
usage_ids: &[],};
|
||||
|
||||
/// 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),
|
||||
ast_builder: build_select,
|
||||
help_id: None,
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.select"],};
|
||||
|
||||
/// `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),
|
||||
ast_builder: build_select,
|
||||
help_id: None,
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.with"],};
|
||||
|
||||
/// 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),
|
||||
ast_builder: build_sql_insert,
|
||||
help_id: None,
|
||||
hint_id: None,
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
@@ -1919,6 +1930,7 @@ pub static SQL_UPDATE: CommandNode = CommandNode {
|
||||
shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE),
|
||||
ast_builder: build_sql_update,
|
||||
help_id: None,
|
||||
hint_id: None,
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
@@ -1934,6 +1946,7 @@ pub static SQL_DELETE: CommandNode = CommandNode {
|
||||
shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE),
|
||||
ast_builder: build_sql_delete,
|
||||
help_id: None,
|
||||
hint_id: None,
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
|
||||
@@ -968,6 +968,7 @@ pub static DROP: CommandNode = CommandNode {
|
||||
shape: DROP_SHAPE,
|
||||
ast_builder: build_drop,
|
||||
help_id: Some("ddl.drop"),
|
||||
hint_id: None,
|
||||
usage_ids: &[
|
||||
"parse.usage.drop_table",
|
||||
"parse.usage.drop_column",
|
||||
@@ -981,6 +982,7 @@ pub static ADD: CommandNode = CommandNode {
|
||||
shape: ADD_SHAPE,
|
||||
ast_builder: build_add,
|
||||
help_id: Some("ddl.add"),
|
||||
hint_id: None,
|
||||
usage_ids: &[
|
||||
"parse.usage.add_column",
|
||||
"parse.usage.add_relationship",
|
||||
@@ -993,6 +995,7 @@ pub static RENAME: CommandNode = CommandNode {
|
||||
shape: RENAME_COLUMN,
|
||||
ast_builder: build_rename_column,
|
||||
help_id: Some("ddl.rename"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.rename_column"],};
|
||||
|
||||
pub static CHANGE: CommandNode = CommandNode {
|
||||
@@ -1000,6 +1003,7 @@ pub static CHANGE: CommandNode = CommandNode {
|
||||
shape: CHANGE_COLUMN,
|
||||
ast_builder: build_change_column,
|
||||
help_id: Some("ddl.change"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.change_column"],};
|
||||
|
||||
// =================================================================
|
||||
@@ -1360,6 +1364,7 @@ pub static CREATE: CommandNode = CommandNode {
|
||||
shape: CREATE_TABLE,
|
||||
ast_builder: build_create_table,
|
||||
help_id: Some("ddl.create"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.create_table"],};
|
||||
|
||||
// =================================================================
|
||||
@@ -1428,6 +1433,7 @@ pub static CREATE_M2N: CommandNode = CommandNode {
|
||||
shape: CREATE_M2N_SHAPE,
|
||||
ast_builder: build_create_m2n,
|
||||
help_id: Some("ddl.create_m2n"),
|
||||
hint_id: None,
|
||||
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),
|
||||
ast_builder: build_sql_create_table,
|
||||
help_id: Some("ddl.sql_create_table"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.sql_create_table"],
|
||||
};
|
||||
|
||||
@@ -1877,6 +1884,7 @@ pub static SQL_DROP_TABLE: CommandNode = CommandNode {
|
||||
shape: SQL_DROP_TABLE_SHAPE,
|
||||
ast_builder: build_sql_drop_table,
|
||||
help_id: Some("ddl.sql_drop_table"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.sql_drop_table"],
|
||||
};
|
||||
|
||||
@@ -1896,6 +1904,7 @@ pub static SQL_DROP_INDEX: CommandNode = CommandNode {
|
||||
shape: SQL_DROP_INDEX_SHAPE,
|
||||
ast_builder: build_sql_drop_index,
|
||||
help_id: Some("ddl.sql_drop_index"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.sql_drop_index"],
|
||||
};
|
||||
|
||||
@@ -1977,6 +1986,7 @@ pub static SQL_CREATE_INDEX: CommandNode = CommandNode {
|
||||
shape: SQL_CREATE_INDEX_SHAPE,
|
||||
ast_builder: build_sql_create_index,
|
||||
help_id: Some("ddl.sql_create_index"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.sql_create_index"],
|
||||
};
|
||||
|
||||
@@ -2535,6 +2545,7 @@ pub static SQL_ALTER_TABLE: CommandNode = CommandNode {
|
||||
shape: SQL_ALTER_TABLE_SHAPE,
|
||||
ast_builder: build_sql_alter_table,
|
||||
help_id: Some("ddl.sql_alter_table"),
|
||||
hint_id: None,
|
||||
usage_ids: &["parse.usage.sql_alter_table"],
|
||||
};
|
||||
|
||||
|
||||
+68
-31
@@ -530,6 +530,15 @@ pub struct CommandNode {
|
||||
/// so a newly-registered command appears in `help`
|
||||
/// automatically (ADR-0024 §help_id).
|
||||
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
|
||||
/// "usage:" block when a parse error fires for this command
|
||||
/// (ADR-0021 §1, ADR-0024 §architecture). Multi-form families
|
||||
@@ -574,32 +583,69 @@ pub fn usage_keys_for_input_in_mode(
|
||||
source: &str,
|
||||
mode: crate::mode::Mode,
|
||||
) -> 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};
|
||||
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 candidates = commands_for_entry_word(word);
|
||||
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)> =
|
||||
if mode == crate::mode::Mode::Advanced {
|
||||
let mut v: Vec<_> = candidates
|
||||
@@ -621,17 +667,7 @@ pub fn usage_keys_for_input_in_mode(
|
||||
.filter(|(_, _, c)| *c == CommandCategory::Simple)
|
||||
.collect()
|
||||
};
|
||||
// Degenerate guard: an advanced-only word in simple mode (not
|
||||
// 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))
|
||||
if selected.is_empty() { candidates } else { selected }
|
||||
}
|
||||
|
||||
/// 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)] = &[
|
||||
(&app::QUIT, CommandCategory::Simple),
|
||||
(&app::HELP, CommandCategory::Simple),
|
||||
(&app::HINT, CommandCategory::Simple),
|
||||
(&app::REBUILD, CommandCategory::Simple),
|
||||
(&app::SAVE, CommandCategory::Simple),
|
||||
(&app::NEW, CommandCategory::Simple),
|
||||
|
||||
@@ -6910,6 +6910,7 @@ mod dispatch_3a_tests {
|
||||
shape: Node::Word(Word::keyword("dsltail")),
|
||||
ast_builder: dsl_builder,
|
||||
help_id: None,
|
||||
hint_id: None,
|
||||
usage_ids: &[],
|
||||
};
|
||||
static SMOKE_SQL: CommandNode = CommandNode {
|
||||
@@ -6917,6 +6918,7 @@ mod dispatch_3a_tests {
|
||||
shape: Node::Word(Word::keyword("sqltail")),
|
||||
ast_builder: sql_builder,
|
||||
help_id: None,
|
||||
hint_id: None,
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
|
||||
@@ -180,6 +180,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("help.unknown_topic", &["topic"]),
|
||||
("help.app.quit", &[]),
|
||||
("help.app.help", &[]),
|
||||
("help.app.hint", &[]),
|
||||
("help.app.rebuild", &[]),
|
||||
("help.app.save", &[]),
|
||||
("help.app.new", &[]),
|
||||
@@ -222,6 +223,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
&["message", "usage"],
|
||||
),
|
||||
("hint.ambient_expected", &["expected"]),
|
||||
("hint.getting_started", &[]),
|
||||
(
|
||||
"hint.ambient_invalid_ident",
|
||||
&["kind", "found"],
|
||||
@@ -299,6 +301,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("parse.usage.rename_column", &[]),
|
||||
("parse.usage.export", &[]),
|
||||
("parse.usage.help", &[]),
|
||||
("parse.usage.hint", &[]),
|
||||
("parse.usage.import", &[]),
|
||||
("parse.usage.copy", &[]),
|
||||
("parse.usage.load", &[]),
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ pub mod translate;
|
||||
|
||||
pub use error::{DiagnosticTable, FriendlyError};
|
||||
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
|
||||
// callables — the former is the structured DbError → FriendlyError
|
||||
|
||||
@@ -256,6 +256,8 @@ help:
|
||||
help: |-
|
||||
help — show this command list
|
||||
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 the project database from project.yaml + data/ (with confirmation)
|
||||
save: |-
|
||||
@@ -386,6 +388,9 @@ hint:
|
||||
ambient_complete: "Submit with Enter"
|
||||
ambient_expected: "Next: {expected}"
|
||||
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
|
||||
# + the user's #5). Voice mirrors ADR-0019's "no such
|
||||
# {kind}" wording for consistency with engine errors.
|
||||
@@ -617,6 +622,7 @@ parse:
|
||||
# description.
|
||||
quit: "quit"
|
||||
help: "help [<command>]"
|
||||
hint: "hint"
|
||||
rebuild: "rebuild"
|
||||
save: "save | save as"
|
||||
new: "new"
|
||||
|
||||
@@ -253,6 +253,73 @@ pub fn translate(error: &DbError, ctx: &TranslateContext) -> FriendlyError {
|
||||
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(
|
||||
message: &str,
|
||||
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 {
|
||||
DbError::Sqlite {
|
||||
message: message.to_string(),
|
||||
|
||||
@@ -250,6 +250,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
|
||||
App(app) => match app {
|
||||
AppCommand::Quit => "App(Quit)".into(),
|
||||
AppCommand::Help { .. } => "App(Help)".into(),
|
||||
AppCommand::Hint => "App(Hint)".into(),
|
||||
AppCommand::Rebuild => "App(Rebuild)".into(),
|
||||
AppCommand::Save => "App(Save)".into(),
|
||||
AppCommand::SaveAs => "App(SaveAs)".into(),
|
||||
|
||||
Reference in New Issue
Block a user