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:
+485
-67
@@ -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,
|
||||
})]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user