DSL parser, async DB worker, types, history, metadata, polish

Track 1 implementation plus polish round.

Parser (chumsky):
- Grammar-based DSL producing a typed Command AST.
- create table X with pk [name:type[,name:type...]] supports
  arbitrary names, any user type, compound PKs natively. Bare
  form errors with a friendly hint pointing at `with pk`.
- add column to table X: Name (type); drop table X.
- Required clauses use keyword grammar; -- reserved for opt-in
  flags (ADR-0009). Custom Rich reasons preferred when surfacing
  chumsky errors so unknown-type messages list valid alternatives.

Database (ADR-0010, ADR-0012):
- rusqlite + STRICT tables + foreign_keys=ON.
- Dedicated worker thread; mpsc Request inbox, oneshot replies.
- Typed DbError with friendly_message() hook for H1.
- Internal __rdbms_playground_columns metadata table preserves
  user-facing types across schema reads, atomically maintained
  alongside DDL via Connection transactions. list_tables hides
  it via the new __rdbms_ internal-table convention.

Types (ADR-0005, ADR-0011):
- All ten user-facing types: text, int, real, decimal, bool,
  date, datetime, blob, serial, shortid.
- Type::fk_target_type() for FK-side column-type rule
  (Serial->Int, ShortId->Text, others identity) -- foundation
  for the FK iteration.

App / Runtime / UI:
- update() stays pure-sync; runtime dispatches DSL via spawned
  tasks, results post back as AppEvent::Dsl*.
- Items panel renders live tables list; output panel shows the
  user-facing structure of the current table after each DDL.
- In-memory command history (Up/Down, draft preservation,
  consecutive-duplicate dedup) -- I2 partial.
- Mouse capture removed; terminal native text selection
  restored (toggle approach revisited when scroll/click
  features land).

Docs:
- ADRs 0009 (DSL syntax conventions), 0010 (DB worker),
  0011 (FK type compat), 0012 (internal metadata table).
- requirements.md progress notes; new V4 entry for the
  scrollable session-log + inline rich rendering + Markdown
  export direction.

Tests: 103 passing (91 lib + 12 integration), 0 skipped.
Clippy clean with nursery enabled.
This commit is contained in:
claude@clouddev1
2026-05-07 13:32:19 +00:00
parent 25a0f1260f
commit c1e52920eb
21 changed files with 3186 additions and 120 deletions
+205 -14
View File
@@ -12,6 +12,8 @@ use ratatui::backend::TestBackend;
use rdbms_playground::action::Action;
use rdbms_playground::app::{App, OutputKind};
use rdbms_playground::db::{ColumnDescription, TableDescription};
use rdbms_playground::dsl::{ColumnSpec, Command, Type};
use rdbms_playground::event::AppEvent;
use rdbms_playground::mode::Mode;
use rdbms_playground::theme::Theme;
@@ -56,30 +58,48 @@ fn rendered_text(app: &App, theme: &Theme, width: u16, height: u16) -> String {
}
#[test]
fn typing_then_submitting_produces_an_echo_in_the_output_panel() {
fn typing_then_submitting_a_dsl_command_emits_execute_action() {
let mut app = App::new();
let theme = Theme::dark();
type_str(&mut app, "hello world");
type_str(&mut app, "create table Customers with pk");
let pre_render = rendered_text(&app, &theme, 80, 24);
assert!(
pre_render.contains("hello world"),
pre_render.contains("create table Customers"),
"input field should display the typed text:\n{pre_render}"
);
let actions = submit(&mut app);
assert!(actions.is_empty());
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::CreateTable {
name: "Customers".to_string(),
columns: vec![ColumnSpec {
name: "id".to_string(),
ty: Type::Serial,
}],
primary_key: vec!["id".to_string()],
})]
);
assert!(app.input.is_empty(), "input buffer cleared on submit");
assert_eq!(app.output.len(), 1);
let post_render = rendered_text(&app, &theme, 80, 24);
assert!(
post_render.contains("hello world"),
"output panel should display the echoed line:\n{post_render}"
post_render.contains("running:"),
"output panel should show the running notice:\n{post_render}"
);
}
#[test]
fn typing_invalid_simple_input_shows_a_parse_error_not_an_echo() {
let mut app = App::new();
let theme = Theme::dark();
type_str(&mut app, "hello world");
let actions = submit(&mut app);
assert!(actions.is_empty());
let rendered = rendered_text(&app, &theme, 80, 24);
assert!(
post_render.contains("[simple]"),
"echo should be tagged with the submission mode:\n{post_render}"
rendered.contains("parse error"),
"output panel should show the parse error:\n{rendered}"
);
}
@@ -112,12 +132,23 @@ fn colon_escape_in_simple_mode_is_one_shot() {
type_str(&mut app, ":select 1");
submit(&mut app);
assert_eq!(app.mode, Mode::Simple);
assert_eq!(app.output[0].mode_at_submission, Mode::Advanced);
assert_eq!(app.output[0].text, "select 1");
// Advanced mode currently echoes (SQL handling lands later);
// the echoed line should carry the advanced submission mode.
let echoed = app
.output
.iter()
.rfind(|l| l.kind == OutputKind::Echo)
.expect("echo output present");
assert_eq!(echoed.mode_at_submission, Mode::Advanced);
assert_eq!(echoed.text, "select 1");
type_str(&mut app, "another line");
// Subsequent submission (unrecognised in simple mode) parse-errors,
// not echoes — confirming the mode reverted.
type_str(&mut app, "list things");
submit(&mut app);
assert_eq!(app.output[1].mode_at_submission, Mode::Simple);
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
assert_eq!(last.mode_at_submission, Mode::Simple);
}
#[test]
@@ -184,3 +215,163 @@ fn status_bar_lists_quit_and_submit_in_all_modes() {
assert!(advanced.contains("Ctrl-C"));
assert!(advanced.contains("mode simple"));
}
// ---------------------------------------------------------------
// Full DSL flow tests.
//
// These tests simulate the runtime by feeding the AppEvent::Dsl*
// events that the runtime would post after dispatching a command
// to the database. That keeps these tests deterministic and runtime
// agnostic — the actual database is exercised in the db module's
// own #[tokio::test] suite.
// ---------------------------------------------------------------
fn fake_table(name: &str, columns: &[(&str, Type, bool)]) -> TableDescription {
TableDescription {
name: name.to_string(),
columns: columns
.iter()
.map(|(n, t, pk)| ColumnDescription {
name: (*n).to_string(),
user_type: Some(*t),
sqlite_type: t.sqlite_strict_type().to_string(),
notnull: false,
primary_key: *pk,
})
.collect(),
}
}
#[test]
fn create_table_flow_updates_tables_list_and_structure_view() {
let mut app = App::new();
let theme = Theme::dark();
// User types and submits.
type_str(&mut app, "create table Customers with pk");
let actions = submit(&mut app);
let expected_cmd = Command::CreateTable {
name: "Customers".to_string(),
columns: vec![ColumnSpec {
name: "id".to_string(),
ty: Type::Serial,
}],
primary_key: vec!["id".to_string()],
};
assert_eq!(actions, vec![Action::ExecuteDsl(expected_cmd.clone())]);
// Runtime would now dispatch and feed back DslSucceeded + TablesRefreshed.
let desc = fake_table("Customers", &[("id", Type::Serial, true)]);
app.update(AppEvent::DslSucceeded {
command: expected_cmd,
description: Some(desc.clone()),
});
app.update(AppEvent::TablesRefreshed(vec!["Customers".to_string()]));
assert_eq!(app.tables, vec!["Customers".to_string()]);
assert_eq!(app.current_table, Some(desc));
let rendered = rendered_text(&app, &theme, 80, 24);
assert!(
rendered.contains("Customers"),
"items panel should list Customers:\n{rendered}"
);
assert!(
rendered.contains("[ok] create table Customers"),
"output should confirm success:\n{rendered}"
);
assert!(
rendered.contains("id serial"),
"output should show the structure with the user-facing type:\n{rendered}"
);
}
#[test]
fn add_column_flow_updates_structure_view() {
let mut app = App::new();
// Simulate the prior create_table state.
app.tables = vec!["Customers".to_string()];
app.current_table = Some(fake_table(
"Customers",
&[("id", Type::Serial, true)],
));
type_str(&mut app, "add column to table Customers: Name (text)");
let actions = submit(&mut app);
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::AddColumn {
table: "Customers".to_string(),
column: "Name".to_string(),
ty: Type::Text,
})]
);
let updated = fake_table(
"Customers",
&[("id", Type::Serial, true), ("Name", Type::Text, false)],
);
app.update(AppEvent::DslSucceeded {
command: Command::AddColumn {
table: "Customers".to_string(),
column: "Name".to_string(),
ty: Type::Text,
},
description: Some(updated.clone()),
});
assert_eq!(app.current_table, Some(updated));
let rendered = rendered_text(&app, &Theme::dark(), 80, 24);
assert!(rendered.contains("Name text"));
}
#[test]
fn drop_table_flow_clears_items_list() {
let mut app = App::new();
app.tables = vec!["Customers".to_string()];
app.current_table = Some(fake_table("Customers", &[("id", Type::Serial, true)]));
type_str(&mut app, "drop table Customers");
let actions = submit(&mut app);
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::DropTable {
name: "Customers".to_string()
})]
);
app.update(AppEvent::DslSucceeded {
command: Command::DropTable {
name: "Customers".to_string(),
},
description: None,
});
app.update(AppEvent::TablesRefreshed(Vec::new()));
assert!(app.tables.is_empty());
assert!(app.current_table.is_none());
let rendered = rendered_text(&app, &Theme::dark(), 80, 24);
assert!(rendered.contains("(none yet)"));
assert!(rendered.contains("[ok] drop table Customers"));
}
#[test]
fn dsl_failure_shows_friendly_error_in_output() {
let mut app = App::new();
type_str(&mut app, "drop table Ghost");
submit(&mut app);
app.update(AppEvent::DslFailed {
command: Command::DropTable {
name: "Ghost".to_string(),
},
error: "no such table: Ghost".to_string(),
});
let rendered = rendered_text(&app, &Theme::dark(), 80, 24);
assert!(
rendered.contains("Ghost"),
"error should mention the table:\n{rendered}"
);
assert!(
rendered.contains("no such table"),
"error should include the friendly message:\n{rendered}"
);
}