round-5 follow-up: completion + i18n sweep

Four user-reported gaps from the round-4 testing pass:

1. Empty-prompt hint reworded from "(no active hint)" to
   "Type a command — press Tab for options, `help` for a
   list" (6 snapshots updated to reflect 80-col truncation).

2. App-lifecycle commands (quit/q, help, rebuild, save/save as,
   new, load, export, import, mode, messages) now flow through
   the DSL parser:
   - 15 new keywords + catalog token entries
   - new Command::App(AppCommand) AST with 11 variants
   - parse-first dispatch in submit() (app commands work in
     both simple and advanced modes)
   - pre-chumsky source-slice for `export <path>` /
     `import <zip> [as <target>]` mirrors the replay precedent
   - UsageEntry registry entries so parse errors surface
     relevant usage templates
   - `mode <bad>` / `messages <bad>` use try_map for the
     friendly "unknown mode/messages" wording

3. DSL completion gaps:
   - `1:n` surfaces as a composite candidate at `add `
   - --all-rows / --create-fk / --force-conversion /
     --dont-convert surface as new CandidateKind::Flag
     candidates (coloured with tok_flag in hint panel)
   - filter_clause .labelled() wrap removed so chumsky's
     expected-set surfaces the constituent options

4. Hardcoded user-facing strings migrated to catalog:
   - 4 parser custom errors (incl. the known "tables need at
     least one column" wart)
   - UnknownType Display now via parse.custom.unknown_type
   - UI panel titles + mode labels (Output / Hint / SIMPLE /
     ADVANCED / Advanced:)
   - app.rs cascade rendering (action labels + summary)
   - runtime --resume CLI stderr
   - db.rs change-column diagnostic tables (7 headers + 3
     wrapper summaries + force-conversion hint)

Tests: 765 → 769 passing, 0 failed, 1 ignored (same doctest
as before). Clippy clean with -D warnings.

Deferred:
- ~25 thiserror #[error] attributes still hand-rolled
  (DbError, ArgsError, ArchiveError, PersistenceError,
  LockError). Tracked separately.
- DSL/SQL relationship in advanced mode — clarified
  implicitly via parse-first dispatch; broader ADR
  amendment to follow.
- Post-complete-parse completion gap (e.g. `save ` Tab
  can't offer `as` because `save` parses bare; same shape
  as `--create-fk` after a complete `add relationship`).
This commit is contained in:
claude@clouddev1
2026-05-13 15:58:29 +00:00
parent 1eb2e0d01f
commit 1e06490572
22 changed files with 1077 additions and 189 deletions
+130 -109
View File
@@ -889,61 +889,17 @@ impl App {
return Vec::new();
}
// Canonical app-level commands recognised in both modes.
// Track-2's full lifecycle command set lands across
// Iterations 4 (rebuild, save, save as, new, load) and
// 5 (export, import).
match effective_input.as_str() {
"quit" | "q" => return vec![Action::Quit],
"help" => {
self.note_help();
return Vec::new();
}
"rebuild" => return vec![Action::PrepareRebuild],
"save" => {
return self.handle_save_command(false);
}
"save as" => {
return self.handle_save_command(true);
}
"new" => {
return vec![Action::NewProject {
source: "new".to_string(),
}];
}
"load" => {
return vec![Action::OpenLoadPicker];
}
"export" => {
return vec![Action::Export {
target: None,
source: "export".to_string(),
}];
}
other if other.starts_with("export ") => {
let target = other["export ".len()..].trim();
if target.is_empty() {
self.note_error(crate::t!("project.export_usage"));
return Vec::new();
}
return vec![Action::Export {
target: Some(target.to_string()),
source: format!("export {target}"),
}];
}
other if other.starts_with("import ") || other == "import" => {
let rest = other.strip_prefix("import").unwrap_or(other);
return self.handle_import_command(rest);
}
other if other.starts_with("mode") => {
self.handle_mode_command(other);
return Vec::new();
}
other if other.starts_with("messages") => {
self.handle_messages_command(other);
return Vec::new();
}
_ => {}
// Parse-first: app-level commands and DSL commands now
// share the chumsky parser (per the round-5 refactor).
// App commands work in both modes — they're not gated by
// `effective_mode`. Anything that parses to a non-App
// variant falls through to the existing mode-specific
// path: simple → DSL execution; advanced → SQL placeholder.
// Anything that fails to parse falls through too — the
// simple-mode path renders the friendly parse error, the
// advanced-mode path renders the SQL placeholder.
if let Ok(Command::App(app_cmd)) = parse_command(&effective_input) {
return self.dispatch_app_command(app_cmd, &effective_input);
}
// For everything else: dispatch by effective mode.
@@ -968,6 +924,79 @@ impl App {
}
}
/// Dispatch a parsed app-lifecycle command. Works in both
/// simple and advanced modes; the parse-first refactor
/// (round-5) routes app commands here before the
/// mode-specific DSL/SQL paths.
fn dispatch_app_command(
&mut self,
cmd: crate::dsl::AppCommand,
source: &str,
) -> Vec<Action> {
use crate::dsl::{AppCommand, MessagesValue, ModeValue};
match cmd {
AppCommand::Quit => vec![Action::Quit],
AppCommand::Help => {
self.note_help();
Vec::new()
}
AppCommand::Rebuild => vec![Action::PrepareRebuild],
AppCommand::Save => self.handle_save_command(false),
AppCommand::SaveAs => self.handle_save_command(true),
AppCommand::New => vec![Action::NewProject {
source: "new".to_string(),
}],
AppCommand::Load => vec![Action::OpenLoadPicker],
AppCommand::Export { path } => path.map_or_else(
|| {
vec![Action::Export {
target: None,
source: "export".to_string(),
}]
},
|target| {
vec![Action::Export {
source: format!("export {target}"),
target: Some(target),
}]
},
),
AppCommand::Import { path, target } => {
// The path-bearing import goes through the
// pre-chumsky source-slice (parser.rs), which
// already validated non-empty path. Bare
// `import` returns from chumsky with an empty
// path string — surface the usage error.
if path.is_empty() {
self.note_error(crate::t!("project.import_usage"));
return Vec::new();
}
vec![Action::Import {
zip_path: path,
as_target: target,
source: source.to_string(),
}]
}
AppCommand::Mode { value } => {
let arg = match value {
ModeValue::Simple => "simple",
ModeValue::Advanced => "advanced",
};
self.handle_mode_command(&format!("mode {arg}"));
Vec::new()
}
AppCommand::Messages { value } => {
let raw = match value {
None => "messages".to_string(),
Some(MessagesValue::Short) => "messages short".to_string(),
Some(MessagesValue::Verbose) => "messages verbose".to_string(),
};
self.handle_messages_command(&raw);
Vec::new()
}
}
}
fn dispatch_dsl(&mut self, input: &str, submission_mode: Mode) -> Vec<Action> {
match parse_command(input) {
Ok(Command::Replay { path }) => {
@@ -1268,6 +1297,13 @@ impl App {
(Operation::Query, Some(name.as_str()), None)
}
C::Replay { .. } => (Operation::Replay, None, None),
// App-lifecycle commands never reach this path —
// `dispatch_input` routes them through
// `dispatch_app_command` before the DSL execution
// pipeline that this context builder feeds.
C::App(_) => unreachable!(
"App commands are dispatched before reaching dsl execution"
),
};
TranslateContext {
@@ -1298,42 +1334,6 @@ impl App {
/// "as" is fine — the separator only matches when
/// surrounded by spaces. `split_once` is used (first
/// occurrence wins), which is the natural reading.
fn handle_import_command(&mut self, rest: &str) -> Vec<Action> {
let rest = rest.trim();
if rest.is_empty() {
self.note_error(crate::t!("project.import_usage"));
return Vec::new();
}
// `submit()` trims trailing whitespace from the raw
// line, so an input like `import foo.zip as ` arrives
// here as `foo.zip as`. Detect that explicitly rather
// than silently treating "as" as part of the zip
// path.
if rest == "as" || rest.ends_with(" as") {
self.note_error(crate::t!("project.import_empty_target"));
return Vec::new();
}
let (zip_path, as_target) = match rest.split_once(" as ") {
Some((zip, target)) => (zip.trim(), Some(target.trim().to_string())),
None => (rest, None),
};
if zip_path.is_empty() {
self.note_error(crate::t!("project.import_usage"));
return Vec::new();
}
if let Some(t) = as_target.as_deref()
&& t.is_empty()
{
self.note_error(crate::t!("project.import_empty_target"));
return Vec::new();
}
vec![Action::Import {
zip_path: zip_path.to_string(),
as_target,
source: format!("import {rest}"),
}]
}
/// Dispatch for the `save` and `save as` commands.
///
/// `save` on a temp project is identical to `save as`
@@ -1752,18 +1752,20 @@ fn render_usage_block(input: &str, position: usize) -> String {
fn render_cascade_effect(effect: &CascadeEffect) -> String {
use crate::dsl::ReferentialAction;
let what = match effect.action {
ReferentialAction::Cascade => "deleted",
ReferentialAction::SetNull => "had FK set to null",
ReferentialAction::Restrict | ReferentialAction::NoAction => "blocked",
let action_key = match effect.action {
ReferentialAction::Cascade => "db.cascade.action_deleted",
ReferentialAction::SetNull => "db.cascade.action_set_null",
ReferentialAction::Restrict | ReferentialAction::NoAction => {
"db.cascade.action_blocked"
}
};
format!(
" related: {} row(s) {} in `{}` for relationship `{}` (on delete {})",
effect.rows_changed,
what,
effect.child_table,
effect.relationship_name,
effect.action,
crate::t!(
"db.cascade.summary",
count = effect.rows_changed,
action = crate::friendly::translate(action_key, &[]),
child_table = effect.child_table,
rel = effect.relationship_name,
on_delete = effect.action,
)
}
@@ -1966,13 +1968,17 @@ mod tests {
// Stage-8 follow-up #2 (testing-round-2): the
// single-candidate-no-memo design lets the user chain
// Tabs through unique completions without getting
// stuck. From "a", Tab → "add ", Tab → "add column ".
// stuck. From "cr", Tab → "create ", Tab → "create
// table ". (Round 5 added the app-lifecycle commands —
// single-letter prefixes like `i` are now ambiguous
// (`insert` vs. `import`), so the test starts from a
// disambiguated two-letter prefix.)
let mut app = App::new();
type_str(&mut app, "a");
type_str(&mut app, "cr");
app.update(key(KeyCode::Tab));
assert_eq!(app.input, "add ");
assert_eq!(app.input, "create ");
app.update(key(KeyCode::Tab));
assert_eq!(app.input, "add column ");
assert_eq!(app.input, "create table ");
assert!(app.last_completion.is_none());
}
@@ -2102,9 +2108,24 @@ mod tests {
type_str(&mut app, "mode sideways");
submit(&mut app);
assert_eq!(app.mode, Mode::Simple);
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
assert!(last.text.contains("unknown mode"));
// The error surfaces somewhere in the output buffer
// (could be the caret line, the parse-error detail
// line, or the usage line). Scan for the friendly
// "unknown mode" anchor phrase.
let anywhere = app
.output
.iter()
.any(|l| l.text.contains("unknown mode"));
assert!(
anywhere,
"expected 'unknown mode' somewhere in output: {:?}",
app.output.iter().map(|l| &l.text).collect::<Vec<_>>(),
);
let any_error = app
.output
.iter()
.any(|l| l.kind == OutputKind::Error);
assert!(any_error, "expected at least one Error line");
}
#[test]
+185 -9
View File
@@ -26,6 +26,20 @@ use crate::dsl::{ParseError, parse_command};
/// completion engine and the parser agree on the magic string.
const TYPE_SLOT_LABEL: &str = "type";
/// Composite literal candidates whose lexed shape is more than
/// one token but which the user types as a single fluent piece.
/// Pairs of (parser-expected-opener, full-composite-text).
///
/// The opener is the first token's backticked label as it
/// appears in `ParseError::Invalid::expected` — when present,
/// the engine surfaces the full composite text as a Tab
/// candidate.
///
/// Currently the only entry is `1:n` (start of
/// `add 1:n relationship`). New entries register here; no
/// parser change required.
const COMPOSITE_CANDIDATES: &[(&str, &str)] = &[("`1`", "1:n")];
/// Per-project schema lookup cache (ADR-0022 §9).
///
/// Held by `App::schema_cache` and consulted by the completion
@@ -68,6 +82,9 @@ pub enum CandidateKind {
Keyword,
/// A schema entity (table, column, relationship).
Identifier,
/// A `--name`-style flag. Coloured with `tok_flag` so the
/// hint matches the way it'll render in the input pane.
Flag,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -164,6 +181,51 @@ pub fn candidates_at_cursor(
Vec::new()
};
// Source 1.55: flag candidates (`--name`). Like type
// names, flags live outside the Keyword enum — the parser
// labels them as backticked literals like `` `--all-rows` ``.
// Surface them as a distinct CandidateKind so the hint
// panel can colour them with `tok_flag` (matching how
// they'll appear in the input pane after insertion).
//
// The user can either Tab from a bare cursor position
// (partial empty) or after typing `--` (partial = "--").
// The standard prefix matcher walks back over alphanumeric +
// underscore, which does NOT cross `-`, so when the user
// types `--all` the partial is `all` — match the flag's
// body against that. Otherwise match the full `--name`
// against the partial (which may be empty or start with `--`).
let flags: Vec<String> = expected
.iter()
.filter_map(|item| strip_backticks(item))
.filter(|name| name.starts_with("--"))
.filter(|name| {
if partial_prefix.starts_with("--") || partial_prefix.is_empty() {
matches_prefix(name)
} else {
// partial is the alphanumeric tail past `--`
let body = &name[2..];
body.to_lowercase().starts_with(&lowered_prefix)
}
})
.map(|name| name.to_string())
.collect();
// Source 1.6: composite-literal candidates. Some commands
// start with a multi-token literal sequence that the lexer
// splits into Number/Punct/Identifier (e.g. `1:n` for
// `add 1:n relationship`). The parser's expected-set
// surfaces just the first token (`` `1` ``), which would
// otherwise be filtered out (not a Keyword variant). We
// surface the full composite so the user can Tab through
// without knowing the surface syntax.
let composites: Vec<String> = COMPOSITE_CANDIDATES
.iter()
.filter(|(opener, _)| expected.iter().any(|s| s == *opener))
.map(|(_, text)| (*text).to_string())
.filter(|s| matches_prefix(s))
.collect();
// Source 2: schema identifiers — accumulated across every
// matching known-set slot. `NewName` slots return `&[]`.
let mut identifiers: Vec<String> = expected
@@ -183,9 +245,15 @@ pub fn candidates_at_cursor(
// Keywords first (grammar parts read before content),
// then type names (closed-set grammar — coloured as
// keywords), then schema identifiers.
let mut candidates: Vec<Candidate> =
Vec::with_capacity(keywords.len() + type_names.len() + identifiers.len());
// keywords), then composite literals (`1:n`, …), then
// flags (own colour), then schema identifiers.
let mut candidates: Vec<Candidate> = Vec::with_capacity(
keywords.len()
+ type_names.len()
+ composites.len()
+ flags.len()
+ identifiers.len(),
);
candidates.extend(keywords.into_iter().map(|text| Candidate {
text,
kind: CandidateKind::Keyword,
@@ -194,6 +262,14 @@ pub fn candidates_at_cursor(
text,
kind: CandidateKind::Keyword,
}));
candidates.extend(composites.into_iter().map(|text| Candidate {
text,
kind: CandidateKind::Keyword,
}));
candidates.extend(flags.into_iter().map(|text| Candidate {
text,
kind: CandidateKind::Flag,
}));
candidates.extend(identifiers.into_iter().map(|text| Candidate {
text,
kind: CandidateKind::Identifier,
@@ -495,15 +571,115 @@ mod tests {
}
#[test]
fn multi_candidate_position_offers_all_options() {
// After `add ` the parser expects `1` (for 1:n) or
// `column`. Only `column` is a Keyword variant — `1`
// is a number-literal pattern. Tab on this position
// offers `column` only.
fn multi_candidate_position_offers_column_and_one_to_n() {
// After `add ` the parser expects `column` (for
// `add column ...`) and `1` (the opener for
// `add 1:n relationship ...`). The completion engine
// surfaces both: `column` straight from the keyword
// expected-set, and `1:n` as a composite literal
// candidate so the user can Tab through to the
// relationship form without knowing the surface syntax.
let cs = cands("add ", 4);
assert_eq!(cs, vec!["column".to_string()]);
assert_eq!(cs, vec!["column".to_string(), "1:n".to_string()]);
}
#[test]
fn one_to_n_filters_to_prefix_match() {
// Typed `1` after `add ` — only `1:n` matches.
let cs = cands("add 1", 5);
assert_eq!(cs, vec!["1:n".to_string()]);
}
#[test]
fn update_filter_position_offers_where_and_all_rows() {
// After `update T set Name='hi' ` the parser expects
// a `,` (more assignments), `where` (where clause),
// or `--all-rows` (flag). Punctuation isn't surfaced;
// `where` and `--all-rows` should appear.
let cs = cands("update T set Name='hi' ", 23);
assert!(cs.contains(&"where".to_string()), "got {cs:?}");
assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}");
}
#[test]
fn delete_filter_position_offers_where_and_all_rows() {
let cs = cands("delete from T ", 14);
assert!(cs.contains(&"where".to_string()), "got {cs:?}");
assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}");
}
#[test]
fn flag_candidates_are_classified_as_flag_kind() {
// Hint-panel colouring distinguishes flags from
// keywords (amber vs purple) — flags get their own
// CandidateKind so the renderer can apply tok_flag.
let kinds = candidates_at_cursor("delete from T ", 14, &SchemaCache::default())
.expect("some completion")
.candidates
.into_iter()
.map(|c| (c.text, c.kind))
.collect::<Vec<_>>();
let flag = kinds
.iter()
.find(|(t, _)| t == "--all-rows")
.expect("--all-rows present");
assert_eq!(flag.1, CandidateKind::Flag);
}
#[test]
fn flag_candidates_filter_by_partial_prefix() {
let cs = cands("delete from T --", 16);
assert!(cs.contains(&"--all-rows".to_string()), "got {cs:?}");
}
// ---- App-lifecycle command completion (round-5 fold-in) ----
#[test]
fn empty_input_offers_app_command_entry_keywords() {
let cs = cands("", 0);
// App-lifecycle commands now appear alongside DSL
// commands in the entry-keyword set.
for expected in &[
"quit", "q", "help", "rebuild", "save", "new", "load", "export",
"import", "mode", "messages",
] {
assert!(
cs.contains(&expected.to_string()),
"missing {expected:?} in entry-keyword candidates: {cs:?}",
);
}
}
#[test]
fn load_prefix_offers_load_only() {
let cs = cands("l", 1);
assert_eq!(cs, vec!["load".to_string()]);
}
#[test]
fn save_prefix_offers_save() {
let cs = cands("sa", 2);
assert_eq!(cs, vec!["save".to_string()]);
}
#[test]
fn mode_then_space_offers_simple_and_advanced() {
// `mode ` requires a value; the parser fails at EOF and
// the expected-set contains the two known keywords.
let cs = cands("mode ", 5);
assert!(cs.contains(&"simple".to_string()), "got {cs:?}");
assert!(cs.contains(&"advanced".to_string()), "got {cs:?}");
}
// Note: `save ` and `messages ` are deliberately NOT tested
// here. Both commands accept their bare form as a valid parse
// — `save` opens the save modal, `messages` shows the current
// verbosity — so the parser returns Ok at those positions
// and the completion engine has no expected-set to mine. The
// optional-suffix candidates (`as`, `short`, `verbose`) would
// need a separate probe mechanism (deferred — same shape as
// the post-complete-parse gap for `--create-fk` etc.).
#[test]
fn show_offers_data_and_table_alphabetised() {
let cs = cands("show ", 5);
+40 -15
View File
@@ -2632,7 +2632,11 @@ fn render_lossy_diagnostic(
lossies: &[&Outcome],
) -> String {
let mut headers = pk_header_cells(pk_columns);
headers.extend(["From".to_string(), "To".to_string(), "Reason".to_string()]);
headers.extend([
crate::t!("db.diagnostic.header_from"),
crate::t!("db.diagnostic.header_to"),
crate::t!("db.diagnostic.header_reason"),
]);
let mut alignments = pk_header_alignments(pk_columns, old_schema);
alignments.extend([
@@ -2662,18 +2666,22 @@ fn render_lossy_diagnostic(
}
let mut out = format!(
"Cannot change `{table}.{column}` from {src_ty} to {target_ty}: \
{total} row(s) would discard information.\n\n"
"{}\n\n",
crate::t!(
"db.diagnostic.lossy_summary",
table = table,
column = column,
src_ty = src_ty,
target_ty = target_ty,
total = total,
),
);
for line in render_diagnostic_table(&headers, &rows, &alignments) {
out.push_str(&line);
out.push('\n');
}
out.push('\n');
out.push_str(
"if you want to execute this conversion in spite of the problems, \
re-run with `--force-conversion`.",
);
out.push_str(&crate::t!("db.diagnostic.force_conversion_hint"));
out
}
@@ -2688,7 +2696,10 @@ fn render_incompatible_diagnostic(
incompatibles: &[&Outcome],
) -> String {
let mut headers = pk_header_cells(pk_columns);
headers.extend(["Value".to_string(), "Reason".to_string()]);
headers.extend([
crate::t!("db.diagnostic.header_value"),
crate::t!("db.diagnostic.header_reason"),
]);
let mut alignments = pk_header_alignments(pk_columns, old_schema);
alignments.extend([
@@ -2714,8 +2725,15 @@ fn render_incompatible_diagnostic(
}
let mut out = format!(
"Cannot change `{table}.{column}` from {src_ty} to {target_ty}: \
{total} row(s) cannot be converted.\n\n"
"{}\n\n",
crate::t!(
"db.diagnostic.incompatible_summary",
table = table,
column = column,
src_ty = src_ty,
target_ty = target_ty,
total = total,
),
);
for line in render_diagnostic_table(&headers, &rows, &alignments) {
out.push_str(&line);
@@ -2766,9 +2784,9 @@ fn check_uniqueness_collisions(
let pk_label = pk_columns.join(", ");
let headers = vec![
"Becomes".to_string(),
format!("Source rows ({pk_label})"),
"Source values".to_string(),
crate::t!("db.diagnostic.header_becomes"),
crate::t!("db.diagnostic.header_source_rows", pk_label = pk_label),
crate::t!("db.diagnostic.header_source_values"),
];
let alignments = vec![
@@ -2814,8 +2832,15 @@ fn check_uniqueness_collisions(
let _ = old_schema;
let mut out = format!(
"Cannot change `{table}.{column}` from {src_ty} to {target_ty}: \
{total} collision(s) would violate uniqueness.\n\n"
"{}\n\n",
crate::t!(
"db.diagnostic.uniqueness_summary",
table = table,
column = column,
src_ty = src_ty,
target_ty = target_ty,
total = total,
),
);
for line in render_diagnostic_table(&headers, &rows, &alignments) {
out.push_str(&line);
+72
View File
@@ -142,6 +142,60 @@ pub enum Command {
Replay {
path: String,
},
/// App-lifecycle command (per ADR-0003). These work in both
/// simple and advanced modes; the dispatcher branches on the
/// `Command::App(...)` variant before mode-specific routing.
/// Folded into the DSL parser so they participate in Tab
/// completion + parse-error usage templates alongside the
/// data commands.
App(AppCommand),
}
/// App-level commands surfaced through the DSL parser. These do
/// not touch the database schema or data — they affect app
/// lifecycle, mode, persistence, and verbosity.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppCommand {
/// Exit cleanly. Accepts the `q` alias.
Quit,
/// Show in-app help. Body comes from `help.in_app_body`.
Help,
/// Rebuild `playground.db` from `project.yaml` + data/, with
/// confirmation modal.
Rebuild,
/// Save the current project under a name (modal-driven).
Save,
/// Save the current project as a copy under a new path
/// (modal-driven).
SaveAs,
/// Close current, create a fresh temp project.
New,
/// Open the project picker modal.
Load,
/// Write a zip of project.yaml + data/. `path` is the user-
/// typed target (may be a name under the data root or an
/// absolute path). `None` opens the path prompt modal.
Export { path: Option<String> },
/// Unpack a zip into a new project and switch to it.
/// `target` overrides the project name (default: taken from
/// the zip).
Import { path: String, target: Option<String> },
/// Switch the persistent input mode.
Mode { value: ModeValue },
/// Show or set the messages verbosity.
Messages { value: Option<MessagesValue> },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ModeValue {
Simple,
Advanced,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessagesValue {
Short,
Verbose,
}
/// Conversion mode for `change column …` (ADR-0017 §5).
@@ -218,6 +272,19 @@ impl Command {
Self::Delete { .. } => "delete from",
Self::ShowData { .. } => "show data",
Self::Replay { .. } => "replay",
Self::App(app) => match app {
AppCommand::Quit => "quit",
AppCommand::Help => "help",
AppCommand::Rebuild => "rebuild",
AppCommand::Save => "save",
AppCommand::SaveAs => "save as",
AppCommand::New => "new",
AppCommand::Load => "load",
AppCommand::Export { .. } => "export",
AppCommand::Import { .. } => "import",
AppCommand::Mode { .. } => "mode",
AppCommand::Messages { .. } => "messages",
},
}
}
@@ -254,6 +321,11 @@ impl Command {
// Replay isn't tied to a single table; the path is
// the most identifying thing for log output.
Self::Replay { path } => path,
// App commands aren't tied to schema entities — the
// verb is the most identifying thing. The
// display_subject override below provides a richer
// form when one exists.
Self::App(_) => "",
}
}
+25
View File
@@ -105,6 +105,31 @@ define_keywords! {
Restrict => "restrict",
Action => "action",
No => "no",
// App-lifecycle commands (folded into the DSL parser so they
// surface in Tab completion and the parse-error usage
// templates). The dispatch handlers in app.rs branch on the
// parsed `Command::App(...)` variant before mode-specific
// routing so these work in both simple and advanced modes
// (per ADR-0003).
Quit => "quit",
Q => "q",
Help => "help",
Rebuild => "rebuild",
Save => "save",
New => "new",
Load => "load",
Export => "export",
Import => "import",
Mode => "mode",
Messages => "messages",
// Value vocabulary for `mode <value>` and `messages <value>`.
// Free as identifier-shapes outside their slots (no command
// uses `simple` / `advanced` / `short` / `verbose` as an
// entity name today).
Simple => "simple",
Advanced => "advanced",
Short => "short",
Verbose => "verbose",
}
macro_rules! define_punct {
+2 -1
View File
@@ -22,7 +22,8 @@ pub mod value;
pub use action::ReferentialAction;
pub use command::{
ChangeColumnMode, ColumnSpec, Command, RelationshipSelector, RowFilter,
AppCommand, ChangeColumnMode, ColumnSpec, Command, MessagesValue, ModeValue,
RelationshipSelector, RowFilter,
};
pub use parser::{ParseError, parse_command};
pub use types::Type;
+153 -13
View File
@@ -15,7 +15,8 @@ use chumsky::prelude::*;
use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{
ChangeColumnMode, ColumnSpec, Command, RelationshipSelector, RowFilter,
AppCommand, ChangeColumnMode, ColumnSpec, Command, MessagesValue, ModeValue,
RelationshipSelector, RowFilter,
};
use crate::dsl::ident_slot::IdentSlot;
use crate::dsl::keyword::{Keyword, Punct};
@@ -101,6 +102,9 @@ pub fn parse_tokens(tokens: &[Token], source: &str) -> Result<Command, ParseErro
if let Some(result) = try_parse_replay_with_bare_path(tokens, source) {
return result;
}
if let Some(result) = try_parse_app_path_command(tokens, source) {
return result;
}
match command_parser().parse(tokens).into_result() {
Ok(cmd) => Ok(cmd),
Err(errs) => Err(into_parse_error(&errs, tokens, source)),
@@ -139,7 +143,7 @@ fn try_parse_replay_with_bare_path(
// of error chumsky would (positioned where the path
// should have started).
return Some(Err(ParseError::Invalid {
message: "expected a path after `replay`".to_string(),
message: crate::t!("parse.custom.replay_path_expected"),
position: after_replay,
at_eof: true,
expected: vec!["path".to_string()],
@@ -150,6 +154,65 @@ fn try_parse_replay_with_bare_path(
}))
}
/// `export <path>` / `import <path> [as <target>]` source-slice
/// special case. Same rationale as `try_parse_replay_with_bare_path`
/// — bare paths contain `/`, `.`, `~` which the lexer would either
/// split into separate tokens or refuse outright.
///
/// Returns `None` for the bare-keyword forms (`export`, `import`
/// alone), letting the regular chumsky path handle them and
/// surface the no-arg `Command::App(...)` variant.
fn try_parse_app_path_command(
tokens: &[Token],
source: &str,
) -> Option<Result<Command, ParseError>> {
use crate::dsl::command::AppCommand;
let first = tokens.first()?;
let kw = match &first.kind {
TokenKind::Keyword(Keyword::Export) => Keyword::Export,
TokenKind::Keyword(Keyword::Import) => Keyword::Import,
_ => return None,
};
let after = first.span.1;
let rest = source[after..].trim();
if rest.is_empty() {
return None;
}
match kw {
Keyword::Export => Some(Ok(Command::App(AppCommand::Export {
path: Some(rest.to_string()),
}))),
Keyword::Import => {
// Trailing `as` with no target is a recognised user
// mistake — surface the usage hint as a parse error
// (catalog wording stays in sync with the existing
// dispatch-time error).
if rest == "as" || rest.ends_with(" as") {
return Some(Err(ParseError::Invalid {
message: crate::t!("project.import_empty_target"),
position: after + rest.len(),
at_eof: true,
expected: Vec::new(),
}));
}
let (path, target) = match rest.split_once(" as ") {
Some((p, t)) => (p.trim().to_string(), Some(t.trim().to_string())),
None => (rest.to_string(), None),
};
if path.is_empty() {
return Some(Err(ParseError::Invalid {
message: crate::t!("project.import_usage"),
position: after,
at_eof: true,
expected: vec!["path".to_string()],
}));
}
Some(Ok(Command::App(AppCommand::Import { path, target })))
}
_ => None,
}
}
// =========================================================
// Token-aware combinator helpers (ADR-0020 §5)
// =========================================================
@@ -287,10 +350,7 @@ fn command_parser<'a>()
if pk_specs.is_empty() {
return Err(Rich::custom(
span,
"tables need at least one column. Add `with pk` for a default \
`id INTEGER PRIMARY KEY`, or `with pk <name>:<type>` to choose. \
Use a comma-separated list for compound primary keys."
.to_string(),
crate::t!("parse.custom.create_table_needs_pk"),
));
}
let columns: Vec<ColumnSpec> = pk_specs
@@ -390,6 +450,66 @@ fn command_parser<'a>()
.ignore_then(string_payload())
.map(|path| Command::Replay { path });
// ---- App-lifecycle commands -----------------------------
// No-arg variants and the keyword-value variants. Path-
// bearing variants (`export <path>`, `import <zip> [as
// <target>]`) are handled by `try_parse_app_path_command`
// BEFORE chumsky runs; the bare-keyword forms below
// surface the `Path: None` / no-source variants for
// empty-prompt completion + usage rendering.
let quit_cmd = choice((kw(Keyword::Quit), kw(Keyword::Q)))
.map(|()| Command::App(AppCommand::Quit));
let help_cmd = kw(Keyword::Help).map(|()| Command::App(AppCommand::Help));
let rebuild_cmd =
kw(Keyword::Rebuild).map(|()| Command::App(AppCommand::Rebuild));
// `save as` must be tried before bare `save` (more specific).
let save_as_cmd = kw(Keyword::Save)
.then_ignore(kw(Keyword::As))
.map(|()| Command::App(AppCommand::SaveAs));
let save_cmd = kw(Keyword::Save).map(|()| Command::App(AppCommand::Save));
let new_cmd = kw(Keyword::New).map(|()| Command::App(AppCommand::New));
let load_cmd = kw(Keyword::Load).map(|()| Command::App(AppCommand::Load));
let export_no_arg =
kw(Keyword::Export).map(|()| Command::App(AppCommand::Export { path: None }));
let import_no_arg = kw(Keyword::Import).map(|()| {
Command::App(AppCommand::Import {
path: String::new(),
target: None,
})
});
// `mode <value>` and `messages [<value>]` accept either the
// known keyword forms or any identifier — the identifier
// branch funnels through `try_map` into a friendly
// `mode.unknown` / `messages.unknown` error rather than the
// generic structural-error wording. Mirrors the type-name
// pattern in `type_keyword` (ADR-0020 §4).
let known_mode = choice((
kw(Keyword::Simple).to(ModeValue::Simple),
kw(Keyword::Advanced).to(ModeValue::Advanced),
));
let unknown_mode = ident_inner().try_map(|s, span| {
Err::<ModeValue, _>(Rich::custom(
span,
crate::t!("mode.unknown", value = s),
))
});
let mode_cmd = kw(Keyword::Mode)
.ignore_then(choice((known_mode, unknown_mode)))
.map(|value| Command::App(AppCommand::Mode { value }));
let known_messages = choice((
kw(Keyword::Short).to(MessagesValue::Short),
kw(Keyword::Verbose).to(MessagesValue::Verbose),
));
let unknown_messages = ident_inner().try_map(|s, span| {
Err::<MessagesValue, _>(Rich::custom(
span,
crate::t!("messages.unknown", value = s),
))
});
let messages_cmd = kw(Keyword::Messages)
.ignore_then(choice((known_messages, unknown_messages)).or_not())
.map(|value| Command::App(AppCommand::Messages { value }));
choice((
create_table,
// `drop column` and `drop relationship` come before
@@ -408,6 +528,19 @@ fn command_parser<'a>()
update_cmd,
delete_cmd,
replay,
// App commands. `save as` before bare `save`; everything
// else order-agnostic.
quit_cmd,
help_cmd,
rebuild_cmd,
save_as_cmd,
save_cmd,
new_cmd,
load_cmd,
export_no_arg,
import_no_arg,
mode_cmd,
messages_cmd,
))
.then_ignore(end())
}
@@ -505,9 +638,15 @@ fn filter_clause<'a>()
let all_rows = flag("all-rows").to(RowFilter::AllRows);
where_clause
.or(all_rows)
.labelled("where clause or --all-rows")
// No `.labelled()` wrap here: chumsky's expected-set then
// surfaces the constituent options (`` `where` ``,
// `` `--all-rows` ``) individually instead of collapsing
// them to a single descriptive label. The completion
// engine needs the constituents to offer Tab candidates
// (ADR-0022 §8); the resulting error prose ("expected `,`,
// `where`, or `--all-rows`") reads cleanly enough without
// hand-wrapping.
where_clause.or(all_rows)
}
fn value_literal<'a>()
@@ -625,7 +764,10 @@ fn referential_clauses<'a>() -> impl Parser<
if slot.is_some() {
return Err(Rich::custom(
span,
format!("`on {target}` specified twice"),
crate::t!(
"parse.custom.on_action_specified_twice",
target = target,
),
));
}
*slot = Some(action);
@@ -683,9 +825,7 @@ fn change_column_flags<'a>()
[single] => Ok(*single),
_ => Err(Rich::custom(
span,
"`--force-conversion` and `--dont-convert` are mutually \
exclusive pick one."
.to_string(),
crate::t!("parse.custom.change_column_flags_exclusive"),
)),
})
}
+18 -4
View File
@@ -130,15 +130,29 @@ impl fmt::Display for Type {
}
}
/// Error returned when parsing a type keyword that isn't
/// recognised.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("unknown type '{found}' (expected one of: {expected})")]
/// Error returned when parsing a type keyword that isn't recognised.
///
/// Display formatting flows through the i18n catalog
/// (`parse.custom.unknown_type`); call sites that do
/// `err.to_string()` get the localised wording for free.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnknownType {
pub found: String,
pub expected: String,
}
impl fmt::Display for UnknownType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&crate::t!(
"parse.custom.unknown_type",
found = self.found,
expected = self.expected,
))
}
}
impl std::error::Error for UnknownType {}
impl FromStr for Type {
type Err = UnknownType;
+72 -8
View File
@@ -92,6 +92,53 @@ pub const REGISTRY: &[UsageEntry] = &[
entry: Keyword::Replay,
catalog_key: "parse.usage.replay",
},
// App-lifecycle commands. Registered alongside DSL commands
// so parse-error rendering surfaces a relevant usage block
// when (e.g.) the user types `mode foo` or `import` alone.
UsageEntry {
entry: Keyword::Quit,
catalog_key: "parse.usage.quit",
},
UsageEntry {
entry: Keyword::Q,
catalog_key: "parse.usage.quit",
},
UsageEntry {
entry: Keyword::Help,
catalog_key: "parse.usage.help",
},
UsageEntry {
entry: Keyword::Rebuild,
catalog_key: "parse.usage.rebuild",
},
UsageEntry {
entry: Keyword::Save,
catalog_key: "parse.usage.save",
},
UsageEntry {
entry: Keyword::New,
catalog_key: "parse.usage.new",
},
UsageEntry {
entry: Keyword::Load,
catalog_key: "parse.usage.load",
},
UsageEntry {
entry: Keyword::Export,
catalog_key: "parse.usage.export",
},
UsageEntry {
entry: Keyword::Import,
catalog_key: "parse.usage.import",
},
UsageEntry {
entry: Keyword::Mode,
catalog_key: "parse.usage.mode",
},
UsageEntry {
entry: Keyword::Messages,
catalog_key: "parse.usage.messages",
},
];
/// Find the entry-keyword whose grammar to illustrate.
@@ -155,11 +202,12 @@ mod tests {
#[test]
fn every_command_has_a_registry_entry() {
// The parser recognises ten command-entry keywords
// (ADR-0009 + ADR-0006 + ADR-0014). Each MUST be
// represented in the registry — otherwise a parse error
// for that command renders no usage block and the H1a
// pedagogy gap reopens for that family.
// Every command-entry keyword recognised by the parser
// MUST be represented in the registry — otherwise a
// parse error for that command renders no usage block
// and the H1a pedagogy gap reopens for that family.
// Round 5 added the app-lifecycle entry keywords
// alongside the original ten DSL entry keywords.
for entry in [
Keyword::Create,
Keyword::Drop,
@@ -171,6 +219,17 @@ mod tests {
Keyword::Update,
Keyword::Delete,
Keyword::Replay,
Keyword::Quit,
Keyword::Q,
Keyword::Help,
Keyword::Rebuild,
Keyword::Save,
Keyword::New,
Keyword::Load,
Keyword::Export,
Keyword::Import,
Keyword::Mode,
Keyword::Messages,
] {
assert!(
REGISTRY.iter().any(|e| e.entry == entry),
@@ -246,14 +305,19 @@ mod tests {
}
#[test]
fn entry_keywords_alphabetised_returns_ten_unique_sorted_commands() {
fn entry_keywords_alphabetised_returns_unique_sorted_commands() {
let keys = entry_keywords_alphabetised();
let names: Vec<&str> = keys.iter().map(|k| k.as_str()).collect();
// Ten DSL entries plus the eleven app-lifecycle entries
// registered in REGISTRY (quit/q are two keywords with
// the same usage template; both surface here).
assert_eq!(
names,
vec![
"add", "change", "create", "delete", "drop", "insert",
"rename", "replay", "show", "update",
"add", "change", "create", "delete", "drop", "export",
"help", "import", "insert", "load", "messages", "mode",
"new", "q", "quit", "rebuild", "rename", "replay",
"save", "show", "update",
],
);
}
+68
View File
@@ -140,6 +140,13 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
// ---- Parse error rendering ----
("parse.available_commands", &["commands"]),
("parse.caret", &["padding"]),
// Custom (try_map / source-slice) error messages raised
// by the DSL parser. See `parse.custom.*` in the catalog.
("parse.custom.change_column_flags_exclusive", &[]),
("parse.custom.create_table_needs_pk", &[]),
("parse.custom.on_action_specified_twice", &["target"]),
("parse.custom.replay_path_expected", &[]),
("parse.custom.unknown_type", &["found", "expected"]),
("parse.empty", &[]),
("parse.error", &["detail"]),
// Per-command usage templates (ADR-0021 §1). One key per
@@ -158,7 +165,17 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.usage.drop_table", &[]),
("parse.usage.insert", &[]),
("parse.usage.rename_column", &[]),
("parse.usage.export", &[]),
("parse.usage.help", &[]),
("parse.usage.import", &[]),
("parse.usage.load", &[]),
("parse.usage.messages", &[]),
("parse.usage.mode", &[]),
("parse.usage.new", &[]),
("parse.usage.quit", &[]),
("parse.usage.rebuild", &[]),
("parse.usage.replay", &[]),
("parse.usage.save", &[]),
("parse.usage.show_data", &[]),
("parse.usage.show_table", &[]),
("parse.usage.update", &[]),
@@ -176,6 +193,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.token.identifier", &[]),
("parse.token.keyword.action", &[]),
("parse.token.keyword.add", &[]),
("parse.token.keyword.advanced", &[]),
("parse.token.keyword.as", &[]),
("parse.token.keyword.cascade", &[]),
("parse.token.keyword.change", &[]),
@@ -184,26 +202,40 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.token.keyword.data", &[]),
("parse.token.keyword.delete", &[]),
("parse.token.keyword.drop", &[]),
("parse.token.keyword.export", &[]),
("parse.token.keyword.false", &[]),
("parse.token.keyword.from", &[]),
("parse.token.keyword.help", &[]),
("parse.token.keyword.import", &[]),
("parse.token.keyword.in", &[]),
("parse.token.keyword.insert", &[]),
("parse.token.keyword.into", &[]),
("parse.token.keyword.load", &[]),
("parse.token.keyword.messages", &[]),
("parse.token.keyword.mode", &[]),
("parse.token.keyword.new", &[]),
("parse.token.keyword.no", &[]),
("parse.token.keyword.null", &[]),
("parse.token.keyword.on", &[]),
("parse.token.keyword.pk", &[]),
("parse.token.keyword.q", &[]),
("parse.token.keyword.quit", &[]),
("parse.token.keyword.rebuild", &[]),
("parse.token.keyword.relationship", &[]),
("parse.token.keyword.rename", &[]),
("parse.token.keyword.replay", &[]),
("parse.token.keyword.restrict", &[]),
("parse.token.keyword.save", &[]),
("parse.token.keyword.set", &[]),
("parse.token.keyword.short", &[]),
("parse.token.keyword.show", &[]),
("parse.token.keyword.simple", &[]),
("parse.token.keyword.table", &[]),
("parse.token.keyword.to", &[]),
("parse.token.keyword.true", &[]),
("parse.token.keyword.update", &[]),
("parse.token.keyword.values", &[]),
("parse.token.keyword.verbose", &[]),
("parse.token.keyword.where", &[]),
("parse.token.keyword.with", &[]),
("parse.token.number", &[]),
@@ -222,6 +254,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("project.import_usage", &[]),
("project.import_zip_missing", &["path"]),
("project.load_path_missing", &["path"]),
("project.resume_no_previous", &["data_root"]),
("project.resume_recorded_missing", &["path"]),
("project.saveas_target_exists", &["path"]),
("project.rebuild_failed", &["error"]),
("project.rebuild_ok", &["summary"]),
@@ -248,6 +282,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("modal.rebuild_confirm_title", &[]),
// ---- Status bar + panels ----
("panel.hint_empty", &[]),
("panel.hint_title", &[]),
("panel.output_title", &[]),
("panel.tables_empty", &[]),
("panel.tables_title", &[]),
("status.no_project", &[]),
@@ -276,12 +312,44 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("messages.set_verbose", &[]),
("messages.show", &["current"]),
("messages.unknown", &["value"]),
("mode.label_advanced", &[]),
("mode.label_advanced_one_shot", &[]),
("mode.label_simple", &[]),
("mode.set_advanced", &[]),
("mode.set_simple", &[]),
("mode.show_advanced", &[]),
("mode.show_simple", &[]),
("mode.unknown", &["value"]),
("mode.usage", &[]),
// ---- Cascade-effect summaries (per ADR-0014) ----
("db.cascade.action_blocked", &[]),
("db.cascade.action_deleted", &[]),
("db.cascade.action_set_null", &[]),
(
"db.cascade.summary",
&["count", "action", "child_table", "rel", "on_delete"],
),
// ---- change-column dry-run diagnostics (per ADR-0017) ----
("db.diagnostic.force_conversion_hint", &[]),
("db.diagnostic.header_becomes", &[]),
("db.diagnostic.header_from", &[]),
("db.diagnostic.header_reason", &[]),
("db.diagnostic.header_source_rows", &["pk_label"]),
("db.diagnostic.header_source_values", &[]),
("db.diagnostic.header_to", &[]),
("db.diagnostic.header_value", &[]),
(
"db.diagnostic.incompatible_summary",
&["table", "column", "src_ty", "target_ty", "total"],
),
(
"db.diagnostic.lossy_summary",
&["table", "column", "src_ty", "target_ty", "total"],
),
(
"db.diagnostic.uniqueness_summary",
&["table", "column", "src_ty", "target_ty", "total"],
),
// ---- DSL command success summaries (ADR-0019 §9 sweep) ----
("ok.rows_deleted", &["count"]),
("ok.rows_inserted", &["count"]),
+99 -1
View File
@@ -279,6 +279,19 @@ parse:
# caret pointer (visualising the failure column) is printed
# on its own preceding line via `parse.caret`.
error: "parse error: {detail}"
# Custom (try_map / source-slice) error messages raised by
# the DSL parser. These were hand-written strings in
# `src/dsl/parser.rs` until the catalog migration brought
# them under the same roof as the rest of the user-facing
# vocabulary. Wording is unchanged from the inline source
# form so existing anchor-phrase tests still match.
custom:
replay_path_expected: "expected a path after `replay`"
create_table_needs_pk: |-
tables need at least one column. Add `with pk` for a default `id INTEGER PRIMARY KEY`, or `with pk <name>:<type>` to choose. Use a comma-separated list for compound primary keys.
on_action_specified_twice: "`on {target}` specified twice"
change_column_flags_exclusive: "`--force-conversion` and `--dont-convert` are mutually exclusive — pick one."
unknown_type: "unknown type '{found}' (expected one of: {expected})"
# Caret pointer showing where in the input the parser
# failed. `{padding}` is the leading whitespace; the
# template appends `^` so the rendered line places the
@@ -322,6 +335,22 @@ parse:
update: "update <Table> set <col>=<value>[, ...] (where <col>=<value> | --all-rows)"
delete: "delete from <Table> (where <col>=<value> | --all-rows)"
replay: "replay <path> | replay '<path with spaces>'"
# App-lifecycle commands (per ADR-0003, surfaced through
# the parser so they participate in usage templates +
# completion). Templates here describe the surface
# grammar that the parser accepts; the in-app `help`
# listing in `help.in_app_body` carries the user-facing
# description.
quit: "quit | q"
help: "help"
rebuild: "rebuild"
save: "save | save as"
new: "new"
load: "load"
export: "export [<path>]"
import: "import <zip-path> [as <target>]"
mode: "mode simple | mode advanced"
messages: "messages | messages short | messages verbose"
# Single-token vocabulary the renderer uses to translate
# chumsky's expected-set patterns. One key per Keyword variant
# (validated against `Keyword::ALL`), one per Punct variant,
@@ -360,6 +389,23 @@ parse:
restrict: "`restrict`"
action: "`action`"
"no": "`no`"
# App-lifecycle commands (per ADR-0003, surfaced through
# the parser to drive completion + usage templates).
quit: "`quit`"
q: "`q`"
help: "`help`"
rebuild: "`rebuild`"
save: "`save`"
new: "`new`"
load: "`load`"
export: "`export`"
import: "`import`"
mode: "`mode`"
messages: "`messages`"
simple: "`simple`"
advanced: "`advanced`"
short: "`short`"
verbose: "`verbose`"
punct:
colon: "`:`"
open_paren: "`(`"
@@ -395,6 +441,12 @@ project:
load_path_missing: "path `{path}` does not exist"
saveas_target_exists: "`{path}` already exists; pick a different name or remove it first"
import_zip_missing: "zip `{path}` does not exist"
# --resume CLI failures printed to stderr before the TUI
# starts (ADR-0015 §7). Wording stays one line for clean
# piping; the runtime prepends `rdbms-playground: ` from
# `cli.binary_prefix` itself.
resume_recorded_missing: "--resume: recorded project `{path}` no longer exists"
resume_no_previous: "--resume: no previous project recorded under `{data_root}`"
# ---- DSL failure wrapper + advanced-mode placeholder + fatal --------
dsl:
@@ -447,7 +499,11 @@ status:
panel:
tables_title: "Tables"
tables_empty: "(none yet)"
hint_empty: "(no active hint)"
hint_empty: "Type a command — press Tab for options, `help` for a list"
# Panel titles for the output and hint panels (rendered inside
# the rounded border, hence the leading/trailing space).
output_title: "Output"
hint_title: "Hint"
# ---- Shortcut hints (paired with key names in the bottom bar) -------
shortcut:
@@ -473,6 +529,12 @@ mode:
show_advanced: "mode: advanced"
usage: "usage: mode simple | mode advanced"
unknown: "unknown mode '{value}' (expected 'simple' or 'advanced')"
# Labels rendered inside the input panel's border to mark the
# current input mode. `label_advanced_one_shot` is shown
# while a `:` one-shot is in flight from simple mode.
label_simple: "SIMPLE"
label_advanced: "ADVANCED"
label_advanced_one_shot: "Advanced:"
messages:
show: "messages: {current}"
@@ -480,6 +542,42 @@ messages:
set_verbose: "messages: verbose"
unknown: "unknown messages mode '{value}' (expected 'short' or 'verbose')"
# ---- Cascade-effect summaries (per ADR-0014 delete reporting) -------
db:
cascade:
# Per-relationship cascade summary appended to a delete
# success note. The same template handles cascade,
# set-null, and restrict/no-action cases — `{action}` is
# one of the three action phrases below.
summary: " related: {count} row(s) {action} in `{child_table}` for relationship `{rel}` (on delete {on_delete})"
action_deleted: "deleted"
action_set_null: "had FK set to null"
action_blocked: "blocked"
# `change column ... (newtype)` dry-run diagnostics (ADR-0017).
# Surface when the migration would lose information (lossy),
# produce values the target type can't represent
# (incompatible), or violate a uniqueness contract (collision).
diagnostic:
# Column headers for the diagnostic tables.
header_from: "From"
header_to: "To"
header_reason: "Reason"
header_value: "Value"
header_becomes: "Becomes"
header_source_rows: "Source rows ({pk_label})"
header_source_values: "Source values"
# Summary lines printed above each diagnostic table.
lossy_summary: |-
Cannot change `{table}.{column}` from {src_ty} to {target_ty}: {total} row(s) would discard information.
incompatible_summary: |-
Cannot change `{table}.{column}` from {src_ty} to {target_ty}: {total} row(s) cannot be converted.
uniqueness_summary: |-
Cannot change `{table}.{column}` from {src_ty} to {target_ty}: {total} collision(s) would violate uniqueness.
# Follow-up suggestion appended to the lossy diagnostic
# (only — incompatibles can't be force-overridden).
force_conversion_hint: "if you want to execute this conversion in spite of the problems, re-run with `--force-conversion`."
# ---- DSL command success summaries (ADR-0019 §9 sweep) --------------
ok:
# Generic `[ok] <verb> <subject>` header used for every
+17 -4
View File
@@ -70,15 +70,21 @@ pub async fn run(args: Args) -> Result<()> {
Some(p) if p.exists() => Some(p),
Some(p) => {
eprintln!(
"rdbms-playground: --resume: recorded project `{}` no longer exists",
p.display(),
"rdbms-playground: {}",
crate::t!(
"project.resume_recorded_missing",
path = p.display(),
),
);
return Ok(());
}
None => {
eprintln!(
"rdbms-playground: --resume: no previous project recorded under `{}`",
data_root.display(),
"rdbms-playground: {}",
crate::t!(
"project.resume_no_previous",
data_root = data_root.display(),
),
);
return Ok(());
}
@@ -1609,6 +1615,13 @@ async fn execute_command_typed(
"Command::Replay is dispatched as Action::Replay; \
reaching execute_command_typed indicates a routing bug"
),
// App-lifecycle commands are dispatched in App, not by
// the database worker. Hitting this arm would mean the
// dispatch routing was bypassed.
Command::App(_) => unreachable!(
"Command::App is dispatched via App::dispatch_app_command; \
reaching execute_command_typed indicates a routing bug"
),
}
}
@@ -1,6 +1,5 @@
---
source: src/ui.rs
assertion_line: 421
expression: snapshot
---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
@@ -23,7 +22,7 @@ expression: snapshot
│ ││ │
│ │╰──────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮
│ ││(no active hint)
│ ││Type a command — press Tab for options, `help` for
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · mode simple switch · Ctrl-C quit
@@ -1,6 +1,5 @@
---
source: src/ui.rs
assertion_line: 404
expression: snapshot
---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
@@ -23,7 +22,7 @@ expression: snapshot
│ ││ │
│ │╰──────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮
│ ││(no active hint)
│ ││Type a command — press Tab for options, `help` for
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -1,6 +1,5 @@
---
source: src/ui.rs
assertion_line: 412
expression: snapshot
---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
@@ -23,7 +22,7 @@ expression: snapshot
│ ││ │
│ │╰──────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮
│ ││(no active hint)
│ ││Type a command — press Tab for options, `help` for
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -1,6 +1,5 @@
---
source: src/ui.rs
assertion_line: 433
expression: snapshot
---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
@@ -23,7 +22,7 @@ expression: snapshot
│ ││: sel │
│ │╰──────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮
│ ││(no active hint)
│ ││Type a command — press Tab for options, `help` for
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · Backspace cancel one-shot · Ctrl-C quit
@@ -1,6 +1,5 @@
---
source: src/ui.rs
assertion_line: 492
expression: snapshot
---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
@@ -23,7 +22,7 @@ expression: snapshot
│ ││ │
│ │╰──────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮
│ ││(no active hint)
│ ││Type a command — press Tab for options, `help` for
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
@@ -1,6 +1,5 @@
---
source: src/ui.rs
assertion_line: 561
expression: snapshot
---
╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮
@@ -23,7 +22,7 @@ expression: snapshot
│ ││ │
│ │╰──────────────────────────────────────────────────╯
│ │╭ Hint ────────────────────────────────────────────╮
│ ││(no active hint)
│ ││Type a command — press Tab for options, `help` for
╰──────────────────────────╯╰──────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
+18 -9
View File
@@ -444,7 +444,7 @@ fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border))
.title(Span::styled(
" Output ",
format!(" {} ", crate::t!("panel.output_title")),
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
@@ -576,15 +576,23 @@ fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> {
fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let effective = app.effective_mode();
let (border_color, mode_color, label) = match effective {
EffectiveMode::Simple => (theme.border, theme.mode_simple, "SIMPLE"),
EffectiveMode::AdvancedPersistent => {
(theme.border_advanced, theme.mode_advanced, "ADVANCED")
}
EffectiveMode::Simple => (
theme.border,
theme.mode_simple,
crate::t!("mode.label_simple"),
),
EffectiveMode::AdvancedPersistent => (
theme.border_advanced,
theme.mode_advanced,
crate::t!("mode.label_advanced"),
),
// Mixed-case label distinguishes the one-shot (`:`-triggered)
// state from a persistent advanced mode at a glance.
EffectiveMode::AdvancedOneShot => {
(theme.border_advanced, theme.mode_advanced, "Advanced:")
}
EffectiveMode::AdvancedOneShot => (
theme.border_advanced,
theme.mode_advanced,
crate::t!("mode.label_advanced_one_shot"),
),
};
let title = Line::from(vec![
@@ -681,7 +689,7 @@ fn render_hint_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border))
.title(Span::styled(
" Hint ",
format!(" {} ", crate::t!("panel.hint_title")),
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD),
@@ -757,6 +765,7 @@ fn render_candidate_line(
let base_fg = match items[i].kind {
crate::completion::CandidateKind::Keyword => theme.tok_keyword,
crate::completion::CandidateKind::Identifier => theme.tok_identifier,
crate::completion::CandidateKind::Flag => theme.tok_flag,
};
let mut s = Style::default().fg(base_fg);
if Some(i) == selected {