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
+485 -67
View File
@@ -1,18 +1,19 @@
//! Application state and the single `update` entry point.
//!
//! The walking skeleton recognises a small subset of the
//! canonical app-level commands from ADR-0003 — `quit` and
//! `mode` — plus the `:` one-shot escape from simple to advanced
//! per ADR-0003. Everything else is echoed to the output panel
//! tagged with the mode it was submitted under, so that mode
//! handling is visible end-to-end.
//! `update` is pure with respect to the runtime: it mutates
//! state in place and returns a list of `Action`s. Side effects
//! (DB execution, quit, etc.) live in the runtime. This keeps
//! every behaviour drivable from synthetic events in tests,
//! which is what makes ADR-0008's Tier 1/3 testing tractable.
use std::collections::VecDeque;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use tracing::trace;
use tracing::{trace, warn};
use crate::action::Action;
use crate::db::TableDescription;
use crate::dsl::{Command, ParseError, parse_command};
use crate::event::AppEvent;
use crate::mode::Mode;
@@ -26,13 +27,20 @@ pub enum OutputKind {
Error,
}
#[derive(Debug, Clone)]
pub struct OutputLine {
pub text: String,
pub kind: OutputKind,
pub mode_at_submission: Mode,
}
/// What mode the next submission would be evaluated in.
///
/// Derived from the persistent mode and the current input buffer.
/// The UI uses this to give immediate visual feedback for the `:`
/// one-shot escape: the moment a leading `:` is typed in simple
/// mode, the prompt flips to advanced styling, and reverts as
/// soon as the `:` is deleted.
/// Derived from the persistent mode and the current input
/// buffer. The UI uses this to give immediate visual feedback
/// for the `:` one-shot escape: the moment a leading `:` is
/// typed in simple mode, the prompt flips to advanced styling,
/// and reverts as soon as the `:` is deleted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EffectiveMode {
Simple,
@@ -47,21 +55,32 @@ impl EffectiveMode {
}
}
#[derive(Debug, Clone)]
pub struct OutputLine {
pub text: String,
pub kind: OutputKind,
pub mode_at_submission: Mode,
}
#[derive(Debug)]
pub struct App {
pub mode: Mode,
pub input: String,
pub output: VecDeque<OutputLine>,
pub hint: Option<String>,
pub tables: Vec<String>,
/// Last successfully described table, shown in the output
/// pane until the next DDL operation.
pub current_table: Option<TableDescription>,
/// In-memory history of submitted lines, oldest first.
/// Persistent history across sessions (I2 second half) lands
/// when track 2's project storage is in place.
pub history: Vec<String>,
/// Position within `history` while navigating with Up/Down.
/// `None` means "not navigating; `input` is the user's
/// in-progress draft."
history_cursor: Option<usize>,
/// Snapshot of the user's in-progress draft taken when they
/// start navigating history, restored if they navigate back
/// past the most recent entry.
history_draft: Option<String>,
}
const HISTORY_CAPACITY: usize = 1000;
impl Default for App {
fn default() -> Self {
Self::new()
@@ -76,6 +95,11 @@ impl App {
input: String::new(),
output: VecDeque::with_capacity(OUTPUT_CAPACITY),
hint: None,
tables: Vec::new(),
current_table: None,
history: Vec::new(),
history_cursor: None,
history_draft: None,
}
}
@@ -99,13 +123,29 @@ impl App {
match event {
AppEvent::Key(key) => self.handle_key(key),
AppEvent::Resize { .. } | AppEvent::Tick => Vec::new(),
AppEvent::DslSucceeded {
command,
description,
} => {
self.handle_dsl_success(&command, description);
Vec::new()
}
AppEvent::DslFailed { command, error } => {
self.handle_dsl_failure(&command, &error);
Vec::new()
}
AppEvent::TablesRefreshed(tables) => {
trace!(count = tables.len(), "tables refreshed");
self.tables = tables;
Vec::new()
}
}
}
fn handle_key(&mut self, key: KeyEvent) -> Vec<Action> {
// On Windows, key events fire for both Press and Release; only
// honour Press to avoid double-handling. Other platforms only
// emit Press, so this is a no-op there.
// On Windows, key events fire for both Press and Release;
// honour only Press to avoid double-handling. Other
// platforms emit Press only, so this is a no-op there.
if key.kind != KeyEventKind::Press {
return Vec::new();
}
@@ -113,11 +153,21 @@ impl App {
match (key.code, key.modifiers) {
(KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit],
(KeyCode::Enter, _) => self.submit(),
(KeyCode::Up, _) => {
self.history_back();
Vec::new()
}
(KeyCode::Down, _) => {
self.history_forward();
Vec::new()
}
(KeyCode::Backspace, _) => {
self.cancel_history_navigation();
self.input.pop();
Vec::new()
}
(KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
self.cancel_history_navigation();
let was_empty = self.input.is_empty();
self.input.push(c);
// Convenience: when `:` becomes the leading character in
@@ -133,12 +183,76 @@ impl App {
}
}
/// Move backwards in history (towards older entries).
fn history_back(&mut self) {
if self.history.is_empty() {
return;
}
let next_index = match self.history_cursor {
None => {
// Starting navigation: save the current draft so the
// user can return to it.
self.history_draft = Some(self.input.clone());
self.history.len() - 1
}
Some(0) => 0,
Some(i) => i - 1,
};
self.history_cursor = Some(next_index);
self.input = self.history[next_index].clone();
}
/// Move forwards in history (towards newer entries; eventually
/// returning to the user's saved draft).
fn history_forward(&mut self) {
let Some(i) = self.history_cursor else {
return;
};
if i + 1 < self.history.len() {
self.history_cursor = Some(i + 1);
self.input = self.history[i + 1].clone();
} else {
// Past the most recent entry — restore the draft and
// exit navigation mode.
self.history_cursor = None;
self.input = self.history_draft.take().unwrap_or_default();
}
}
fn cancel_history_navigation(&mut self) {
self.history_cursor = None;
// Drop the saved draft: the user has begun editing again,
// so what's in `input` *is* the new draft.
self.history_draft = None;
}
fn push_history(&mut self, line: &str) {
// Skip empties and consecutive duplicates — the same
// trick most shells use to keep navigation pleasant.
if line.is_empty() {
return;
}
if self.history.last().map(String::as_str) == Some(line) {
return;
}
self.history.push(line.to_string());
while self.history.len() > HISTORY_CAPACITY {
self.history.remove(0);
}
self.history_cursor = None;
self.history_draft = None;
}
fn submit(&mut self) -> Vec<Action> {
let raw = std::mem::take(&mut self.input);
let trimmed = raw.trim();
if trimmed.is_empty() {
return Vec::new();
}
// Record the original (trimmed) line in history regardless
// of whether it parses, so users can recall and edit
// typo'd commands.
self.push_history(trimmed);
// `:` one-shot escape: in simple mode, a leading `:` means
// treat *this single submission* as advanced. The persistent
@@ -155,7 +269,7 @@ impl App {
}
// Canonical app-level commands recognised in both modes.
// The walking skeleton implements only `quit` and `mode`;
// The current iteration implements `quit` and `mode`;
// the rest of the canonical list lands in later iterations.
match effective_input.as_str() {
"quit" | "q" => return vec![Action::Quit],
@@ -166,13 +280,76 @@ impl App {
_ => {}
}
// Default: echo the line tagged with its effective mode.
self.push_output(OutputLine {
text: effective_input,
kind: OutputKind::Echo,
mode_at_submission: effective_mode,
});
Vec::new()
// For everything else: dispatch by effective mode.
match effective_mode {
Mode::Simple => self.dispatch_dsl(&effective_input, effective_mode),
Mode::Advanced => {
// SQL handling is not implemented yet; show a placeholder
// until the advanced-mode SQL path lands. Once it does,
// this branch parses with sqlparser-rs and dispatches
// analogously to the DSL path below.
self.note_system(format!(
"advanced mode SQL not implemented yet — echo: {effective_input}"
));
self.push_output(OutputLine {
text: effective_input,
kind: OutputKind::Echo,
mode_at_submission: effective_mode,
});
Vec::new()
}
}
}
fn dispatch_dsl(&mut self, input: &str, submission_mode: Mode) -> Vec<Action> {
match parse_command(input) {
Ok(cmd) => {
self.push_output(OutputLine {
text: format!("running: {input}"),
kind: OutputKind::Echo,
mode_at_submission: submission_mode,
});
vec![Action::ExecuteDsl(cmd)]
}
Err(ParseError::Empty) => Vec::new(),
Err(err) => {
self.note_error(format!("parse error: {}", parse_error_message(&err)));
Vec::new()
}
}
}
fn handle_dsl_success(&mut self, command: &Command, description: Option<TableDescription>) {
let summary = format!("[ok] {} {}", command.verb(), command.target_table());
self.note_system(summary);
if let Some(desc) = description.as_ref() {
self.note_system(format!(" {}", desc.name));
for col in &desc.columns {
let pk = if col.primary_key { " [PK]" } else { "" };
let nn = if col.notnull { " NOT NULL" } else { "" };
// Prefer the user-facing type recovered from our
// metadata table; fall back to the SQLite type only
// if metadata is missing (only happens for tables we
// didn't create — not in the current flow).
let type_display = col
.user_type
.map_or_else(|| col.sqlite_type.to_lowercase(), |t| t.keyword().to_string());
self.note_system(format!(
" {} {}{}{}",
col.name, type_display, pk, nn
));
}
}
self.current_table = description;
}
fn handle_dsl_failure(&mut self, command: &Command, error: &str) {
warn!(verb = command.verb(), error, "dsl command failed");
self.note_error(format!(
"{} {} failed: {error}",
command.verb(),
command.target_table()
));
}
fn handle_mode_command(&mut self, raw: &str) {
@@ -217,9 +394,18 @@ impl App {
}
}
fn parse_error_message(err: &ParseError) -> String {
match err {
ParseError::Invalid { message, .. } => message.clone(),
ParseError::Empty => "empty input".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::ColumnDescription;
use crate::dsl::Type;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use pretty_assertions::assert_eq;
@@ -241,6 +427,19 @@ mod tests {
app.update(key(KeyCode::Enter))
}
fn sample_description(name: &str) -> TableDescription {
TableDescription {
name: name.to_string(),
columns: vec![ColumnDescription {
name: "id".to_string(),
user_type: Some(Type::Serial),
sqlite_type: "INTEGER".to_string(),
notnull: false,
primary_key: true,
}],
}
}
#[test]
fn typing_accumulates_in_input_buffer() {
let mut app = App::new();
@@ -258,17 +457,50 @@ mod tests {
}
#[test]
fn enter_in_simple_mode_echoes_with_simple_tag() {
fn valid_dsl_in_simple_mode_emits_execute_action() {
let mut app = App::new();
type_str(&mut app, "create table foo");
type_str(&mut app, "create table Customers with pk");
let actions = submit(&mut app);
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::CreateTable {
name: "Customers".to_string(),
columns: vec![crate::dsl::ColumnSpec {
name: "id".to_string(),
ty: Type::Serial,
}],
primary_key: vec!["id".to_string()],
})]
);
// The input is echoed back as a "running:" notice so the
// user sees something happened while the DB worker runs.
assert!(!app.output.is_empty());
}
#[test]
fn bare_create_table_emits_friendly_parse_error() {
let mut app = App::new();
type_str(&mut app, "create table Customers");
let actions = submit(&mut app);
assert!(actions.is_empty());
assert_eq!(app.input, "");
assert_eq!(app.output.len(), 1);
let line = &app.output[0];
assert_eq!(line.text, "create table foo");
assert_eq!(line.kind, OutputKind::Echo);
assert_eq!(line.mode_at_submission, Mode::Simple);
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
assert!(
last.text.contains("with pk"),
"error should mention `with pk`: {}",
last.text
);
}
#[test]
fn invalid_dsl_in_simple_mode_produces_parse_error_in_output() {
let mut app = App::new();
type_str(&mut app, "frobulate widgets");
let actions = submit(&mut app);
assert!(actions.is_empty());
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
assert!(last.text.starts_with("parse error"));
}
#[test]
@@ -277,8 +509,14 @@ mod tests {
app.mode = Mode::Advanced;
type_str(&mut app, "select 1");
submit(&mut app);
let line = &app.output[0];
assert_eq!(line.mode_at_submission, Mode::Advanced);
// We expect a placeholder system line plus the echoed line.
let echoed = app
.output
.iter()
.rfind(|l| l.kind == OutputKind::Echo)
.unwrap();
assert_eq!(echoed.mode_at_submission, Mode::Advanced);
assert_eq!(echoed.text, "select 1");
}
#[test]
@@ -304,21 +542,20 @@ mod tests {
}
#[test]
fn colon_prefix_in_simple_mode_is_one_shot_advanced() {
fn colon_prefix_in_simple_mode_evaluates_as_advanced_one_shot() {
let mut app = App::new();
type_str(&mut app, ":select 1");
submit(&mut app);
// The persistent mode is unchanged.
assert_eq!(app.mode, Mode::Simple);
let line = &app.output[0];
// The submitted line was tagged advanced for this submission.
assert_eq!(line.mode_at_submission, Mode::Advanced);
// The leading `:` is stripped before echoing.
assert_eq!(line.text, "select 1");
// Subsequent submissions revert to simple.
type_str(&mut app, "list tables");
submit(&mut app);
assert_eq!(app.output[1].mode_at_submission, Mode::Simple);
// The advanced echo line is present.
let echoed = app
.output
.iter()
.rfind(|l| l.kind == OutputKind::Echo)
.unwrap();
assert_eq!(echoed.mode_at_submission, Mode::Advanced);
assert_eq!(echoed.text, "select 1");
}
#[test]
@@ -344,6 +581,17 @@ mod tests {
assert!(app.output.is_empty());
}
#[test]
fn output_buffer_is_capped() {
let mut app = App::new();
for i in 0..(OUTPUT_CAPACITY + 50) {
app.note_system(format!("line{i}"));
}
assert_eq!(app.output.len(), OUTPUT_CAPACITY);
// Oldest entries were dropped.
assert!(app.output.front().unwrap().text.starts_with("line50"));
}
#[test]
fn effective_mode_reflects_persistent_mode_when_no_input() {
let mut app = App::new();
@@ -357,8 +605,6 @@ mod tests {
let mut app = App::new();
type_str(&mut app, ":sel");
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
// Backspace through the colon reverts. The auto-inserted space
// after the colon counts as one extra character to clear.
while !app.input.is_empty() {
app.update(key(KeyCode::Backspace));
}
@@ -395,35 +641,207 @@ mod tests {
assert_eq!(app.input, ": ");
app.update(key(KeyCode::Backspace));
assert_eq!(app.input, ":");
// The colon alone still triggers the one-shot.
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
}
#[test]
fn effective_mode_in_advanced_mode_ignores_leading_colon() {
fn dsl_success_event_records_table_view_and_appends_summary() {
let mut app = App::new();
app.mode = Mode::Advanced;
type_str(&mut app, ":hello");
// Leading `:` carries no special meaning in advanced mode.
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedPersistent);
let cmd = Command::CreateTable {
name: "Customers".to_string(),
columns: vec![crate::dsl::ColumnSpec {
name: "id".to_string(),
ty: Type::Serial,
}],
primary_key: vec!["id".to_string()],
};
let desc = sample_description("Customers");
app.update(AppEvent::DslSucceeded {
command: cmd,
description: Some(desc.clone()),
});
assert_eq!(app.current_table, Some(desc));
let last = app.output.back().unwrap();
// Last line is the column row of the structure summary.
assert!(last.text.contains("id"));
// Earlier line is the [ok] header.
assert!(app.output.iter().any(|l| l.text.starts_with("[ok]")));
}
#[test]
fn effective_mode_tolerates_leading_whitespace_before_colon() {
fn dsl_failure_event_writes_error_with_friendly_message() {
let mut app = App::new();
type_str(&mut app, " :select 1");
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
let cmd = Command::DropTable {
name: "Ghost".to_string(),
};
app.update(AppEvent::DslFailed {
command: cmd,
error: "no such table: Ghost".to_string(),
});
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
assert!(last.text.contains("Ghost"));
assert!(last.text.contains("no such table"));
}
#[test]
fn output_buffer_is_capped() {
fn tables_refreshed_event_replaces_cached_list() {
let mut app = App::new();
for i in 0..(OUTPUT_CAPACITY + 50) {
type_str(&mut app, &format!("line{i}"));
app.update(AppEvent::TablesRefreshed(vec![
"A".to_string(),
"B".to_string(),
]));
assert_eq!(app.tables, vec!["A".to_string(), "B".to_string()]);
app.update(AppEvent::TablesRefreshed(vec!["C".to_string()]));
assert_eq!(app.tables, vec!["C".to_string()]);
}
#[test]
fn add_column_command_with_unknown_type_reports_parse_error() {
let mut app = App::new();
type_str(&mut app, "add column to table T: c (varchar)");
let actions = submit(&mut app);
assert!(actions.is_empty());
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
assert!(last.text.contains("varchar"));
}
#[test]
fn drop_table_command_emits_execute_action() {
let mut app = App::new();
type_str(&mut app, "drop table T");
let actions = submit(&mut app);
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::DropTable {
name: "T".to_string()
})]
);
}
#[test]
fn history_records_submitted_lines() {
let mut app = App::new();
type_str(&mut app, "drop table A");
submit(&mut app);
type_str(&mut app, "drop table B");
submit(&mut app);
assert_eq!(
app.history,
vec!["drop table A".to_string(), "drop table B".to_string()]
);
}
#[test]
fn history_skips_consecutive_duplicates() {
let mut app = App::new();
type_str(&mut app, "drop table A");
submit(&mut app);
type_str(&mut app, "drop table A");
submit(&mut app);
type_str(&mut app, "drop table B");
submit(&mut app);
type_str(&mut app, "drop table A");
submit(&mut app);
assert_eq!(
app.history,
vec![
"drop table A".to_string(),
"drop table B".to_string(),
"drop table A".to_string(),
]
);
}
#[test]
fn up_arrow_recalls_most_recent_history_entry() {
let mut app = App::new();
type_str(&mut app, "drop table A");
submit(&mut app);
type_str(&mut app, "drop table B");
submit(&mut app);
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table B");
}
#[test]
fn up_arrow_walks_backwards_through_history() {
let mut app = App::new();
for line in ["drop table A", "drop table B", "drop table C"] {
type_str(&mut app, line);
submit(&mut app);
}
assert_eq!(app.output.len(), OUTPUT_CAPACITY);
// Oldest entries were dropped.
assert!(app.output.front().unwrap().text.starts_with("line50"));
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table C");
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table B");
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table A");
// Going past the oldest holds at the oldest.
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table A");
}
#[test]
fn down_arrow_returns_through_history_to_the_draft() {
let mut app = App::new();
for line in ["drop table A", "drop table B"] {
type_str(&mut app, line);
submit(&mut app);
}
// Type a draft, then start navigating.
type_str(&mut app, "in progress");
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table B");
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table A");
app.update(key(KeyCode::Down));
assert_eq!(app.input, "drop table B");
app.update(key(KeyCode::Down));
// Past the newest, restore the draft.
assert_eq!(app.input, "in progress");
}
#[test]
fn down_arrow_with_no_history_navigation_is_a_noop() {
let mut app = App::new();
type_str(&mut app, "draft");
app.update(key(KeyCode::Down));
assert_eq!(app.input, "draft");
}
#[test]
fn editing_during_history_navigation_cancels_it() {
let mut app = App::new();
type_str(&mut app, "drop table A");
submit(&mut app);
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table A");
// Editing the recalled line cancels navigation: another
// Up press should re-enter navigation from the new draft.
type_str(&mut app, "X");
assert_eq!(app.input, "drop table AX");
app.update(key(KeyCode::Up));
// Up brings the most recent history back, saving the
// edited draft.
assert_eq!(app.input, "drop table A");
app.update(key(KeyCode::Down));
assert_eq!(app.input, "drop table AX");
}
#[test]
fn add_column_with_text_type_emits_execute_action() {
let mut app = App::new();
type_str(&mut app, "add column to table T: Name (text)");
let actions = submit(&mut app);
assert_eq!(
actions,
vec![Action::ExecuteDsl(Command::AddColumn {
table: "T".to_string(),
column: "Name".to_string(),
ty: Type::Text,
})]
);
}
}