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
+3
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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"
+153
View File
@@ -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(),