Files
rdbms-playground/src/app.rs
T
claude@clouddev1 601d3b6c51 Iteration 1: file-backed projects with auto-named temps, lock file, and L1 CLI
Replaces the in-memory database with an on-disk project. Startup either
opens a project at the positional CLI path (L1) or creates an auto-named
temp project (<YYYYMMDD>-<word>-<word>-<word>) under the OS-standard
data directory or a --data-dir override. The new project::Project type
owns the directory skeleton and a PID+hostname lock file with
stale-lock takeover via sysinfo. The status bar now shows
"Project: <Display Name>", derived by a small kebab/snake/camel
prettifier. Per-command persistence to YAML/CSV/history.log is NOT
yet wired -- that's Iteration 2; for now playground.db carries the
state across quits.

Tests: 257 passing (231 lib + 9 new integration + 17 existing),
0 failing, 0 skipped. Clippy clean with nursery lints.
2026-05-07 20:21:52 +00:00

1438 lines
48 KiB
Rust

//! Application state and the single `update` entry point.
//!
//! `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, warn};
use crate::action::Action;
use crate::db::{
CascadeEffect, DataResult, DeleteResult, InsertResult, TableDescription, UpdateResult,
};
use crate::dsl::{Command, ParseError, parse_command};
use crate::event::AppEvent;
use crate::mode::Mode;
/// Maximum number of output lines kept in the rolling buffer.
const OUTPUT_CAPACITY: usize = 1000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputKind {
Echo,
System,
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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EffectiveMode {
Simple,
AdvancedPersistent,
AdvancedOneShot,
}
impl EffectiveMode {
#[must_use]
pub const fn is_advanced(self) -> bool {
matches!(self, Self::AdvancedPersistent | Self::AdvancedOneShot)
}
}
#[derive(Debug)]
pub struct App {
pub mode: Mode,
pub input: String,
/// Byte offset into `input` where the next character will be
/// inserted. Always lies on a UTF-8 character boundary.
pub input_cursor: usize,
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>,
/// Number of lines from the bottom we've scrolled up. `0`
/// means "showing the most recent lines"; positive values
/// reveal older lines. Reset to `0` whenever a new output
/// line is appended so newly-arrived results are always
/// visible after a command. The full V4 session-log spec
/// supersedes this; we ship a minimal subset now to address
/// the immediate "ran out of space" UX problem.
pub output_scroll: usize,
/// The most recent visible-row count of the output panel,
/// reported by the renderer. Used to cap `output_scroll` —
/// without this, scrolling past `len - visible` would slide
/// the visible window off the top of the buffer and shrink
/// what the user sees.
pub last_output_visible: usize,
/// The most recent total *wrapped* row count of the output
/// panel — counted in display rows after wrapping, not in
/// logical OutputLines. Required for accurate scroll capping
/// when long lines wrap to multiple display rows.
pub last_output_total_wrapped: usize,
/// Prettified display name of the currently-open project,
/// rendered in the status bar (P-NAME-3, ADR-0015 §2). `None`
/// during very-early startup before the runtime has opened a
/// project; otherwise always populated.
pub project_name: Option<String>,
}
const PAGE_SCROLL_LINES: usize = 5;
const HISTORY_CAPACITY: usize = 1000;
impl Default for App {
fn default() -> Self {
Self::new()
}
}
impl App {
#[must_use]
pub fn new() -> Self {
Self {
mode: Mode::Simple,
input: String::new(),
input_cursor: 0,
output: VecDeque::with_capacity(OUTPUT_CAPACITY),
hint: None,
tables: Vec::new(),
current_table: None,
history: Vec::new(),
history_cursor: None,
history_draft: None,
output_scroll: 0,
last_output_visible: 0,
last_output_total_wrapped: 0,
project_name: None,
}
}
/// Called by the renderer with the current output-panel
/// dimensions (row count + total wrapped-row count for the
/// current buffer) so subsequent scroll input is capped
/// correctly. Without `total_wrapped`, scroll math would
/// incorrectly assume one logical line = one display row.
pub const fn note_output_viewport(&mut self, visible_rows: usize, total_wrapped_rows: usize) {
self.last_output_visible = visible_rows;
self.last_output_total_wrapped = total_wrapped_rows;
// If a previous PageUp drifted past the maximum useful
// scroll (e.g. the user kept paging up past the top),
// bring it back so the next PageDown is responsive.
let max = total_wrapped_rows.saturating_sub(visible_rows);
if self.output_scroll > max {
self.output_scroll = max;
}
}
/// Effective mode for the *next* submission, given the
/// persistent mode and the current input buffer. See
/// [`EffectiveMode`].
#[must_use]
pub fn effective_mode(&self) -> EffectiveMode {
match self.mode {
Mode::Advanced => EffectiveMode::AdvancedPersistent,
Mode::Simple if self.input.trim_start().starts_with(':') => {
EffectiveMode::AdvancedOneShot
}
Mode::Simple => EffectiveMode::Simple,
}
}
/// Process one event from the runtime, mutating state and
/// returning any actions for the runtime to enact.
pub fn update(&mut self, event: AppEvent) -> Vec<Action> {
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::DslDataSucceeded { command, data } => {
self.handle_dsl_query_success(&command, &data);
Vec::new()
}
AppEvent::DslInsertSucceeded { command, result } => {
self.handle_dsl_insert_success(&command, &result);
Vec::new()
}
AppEvent::DslUpdateSucceeded { command, result } => {
self.handle_dsl_update_success(&command, &result);
Vec::new()
}
AppEvent::DslDeleteSucceeded { command, result } => {
self.handle_dsl_delete_success(&command, &result);
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;
// 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();
}
trace!(?key, "handle_key");
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::Left, _) => {
self.cursor_left();
Vec::new()
}
(KeyCode::Right, _) => {
self.cursor_right();
Vec::new()
}
(KeyCode::Home, _) => {
self.input_cursor = 0;
Vec::new()
}
(KeyCode::End, _) => {
self.input_cursor = self.input.len();
Vec::new()
}
(KeyCode::Backspace, _) => {
self.cancel_history_navigation();
self.delete_before_cursor();
Vec::new()
}
(KeyCode::Delete, _) => {
self.cancel_history_navigation();
self.delete_at_cursor();
Vec::new()
}
(KeyCode::PageUp, _) => {
self.scroll_output_up();
Vec::new()
}
(KeyCode::PageDown, _) => {
self.scroll_output_down();
Vec::new()
}
(KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => {
self.cancel_history_navigation();
let was_empty = self.input.is_empty();
self.insert_at_cursor(c);
// Convenience: when `:` becomes the leading character in
// simple mode, auto-insert a space after it so the input
// reads ": foo" rather than ":foo". The trailing space is
// an ordinary character — backspace removes it normally.
if c == ':' && was_empty && self.mode == Mode::Simple {
self.insert_at_cursor(' ');
}
Vec::new()
}
_ => Vec::new(),
}
}
fn cursor_left(&mut self) {
let mut idx = self.input_cursor;
while idx > 0 {
idx -= 1;
if self.input.is_char_boundary(idx) {
self.input_cursor = idx;
return;
}
}
self.input_cursor = 0;
}
fn cursor_right(&mut self) {
let mut idx = self.input_cursor;
while idx < self.input.len() {
idx += 1;
if self.input.is_char_boundary(idx) {
self.input_cursor = idx;
return;
}
}
self.input_cursor = self.input.len();
}
fn insert_at_cursor(&mut self, c: char) {
// Defensive clamp: callers (and tests) may mutate
// `input` directly; keep the cursor inside the buffer.
if self.input_cursor > self.input.len() {
self.input_cursor = self.input.len();
}
self.input.insert(self.input_cursor, c);
self.input_cursor += c.len_utf8();
}
fn delete_before_cursor(&mut self) {
if self.input_cursor == 0 {
return;
}
// Find the start of the previous character.
let mut idx = self.input_cursor - 1;
while !self.input.is_char_boundary(idx) {
idx -= 1;
}
self.input.replace_range(idx..self.input_cursor, "");
self.input_cursor = idx;
}
fn delete_at_cursor(&mut self) {
if self.input_cursor >= self.input.len() {
return;
}
// Find the end of the character at the cursor.
let mut idx = self.input_cursor + 1;
while idx < self.input.len() && !self.input.is_char_boundary(idx) {
idx += 1;
}
self.input.replace_range(self.input_cursor..idx, "");
}
/// 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();
self.input_cursor = self.input.len();
}
/// 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();
}
self.input_cursor = self.input.len();
}
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);
self.input_cursor = 0;
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
// mode is unchanged.
let (effective_mode, effective_input) =
if self.mode == Mode::Simple && trimmed.starts_with(':') {
(Mode::Advanced, trimmed[1..].trim().to_string())
} else {
(self.mode, trimmed.to_string())
};
if effective_input.is_empty() {
return Vec::new();
}
// Canonical app-level commands recognised in both modes.
// 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],
other if other.starts_with("mode") => {
self.handle_mode_command(other);
return 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.display_subject());
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
));
}
if !desc.outbound_relationships.is_empty() {
self.note_system(" References:");
for r in &desc.outbound_relationships {
self.note_system(format!(
" {}{}.{} ({}, on delete {}, on update {})",
r.local_column,
r.other_table,
r.other_column,
r.name,
r.on_delete,
r.on_update,
));
}
}
if !desc.inbound_relationships.is_empty() {
self.note_system(" Referenced by:");
for r in &desc.inbound_relationships {
self.note_system(format!(
" {}.{}{} ({}, on delete {}, on update {})",
r.other_table,
r.other_column,
r.local_column,
r.name,
r.on_delete,
r.on_update,
));
}
}
}
self.current_table = description;
}
fn handle_dsl_query_success(&mut self, command: &Command, data: &DataResult) {
let summary = format!("[ok] {} {}", command.verb(), command.display_subject());
self.note_system(summary);
for line in render_data_view(data) {
self.note_system(line);
}
}
fn handle_dsl_insert_success(&mut self, command: &Command, result: &InsertResult) {
self.note_system(format!(
"[ok] {} {}",
command.verb(),
command.display_subject()
));
self.note_system(format!(" {} row(s) inserted", result.rows_affected));
for line in render_data_view(&result.data) {
self.note_system(line);
}
}
fn handle_dsl_update_success(&mut self, command: &Command, result: &UpdateResult) {
self.note_system(format!(
"[ok] {} {}",
command.verb(),
command.display_subject()
));
self.note_system(format!(" {} row(s) updated", result.rows_affected));
for line in render_data_view(&result.data) {
self.note_system(line);
}
}
fn handle_dsl_delete_success(&mut self, command: &Command, result: &DeleteResult) {
self.note_system(format!(
"[ok] {} {}",
command.verb(),
command.display_subject()
));
self.note_system(format!(" {} row(s) deleted", result.rows_affected));
for effect in &result.cascade {
self.note_system(render_cascade_effect(effect));
}
}
fn handle_dsl_failure(&mut self, command: &Command, error: &str) {
warn!(verb = command.verb(), error, "dsl command failed");
// Wrap the command portion in quotes so the message
// reads cleanly: "...failed: <reason>" rather than the
// command running into "failed: ..." with no break.
self.note_error(format!(
"\"{} {}\" failed: {error}",
command.verb(),
command.display_subject()
));
}
fn handle_mode_command(&mut self, raw: &str) {
let arg = raw.strip_prefix("mode").unwrap_or(raw).trim();
match arg {
"simple" => {
self.mode = Mode::Simple;
self.note_system("mode: simple");
}
"advanced" => {
self.mode = Mode::Advanced;
self.note_system("mode: advanced");
}
"" => self.note_error("usage: mode simple | mode advanced"),
other => self.note_error(format!(
"unknown mode '{other}' (expected 'simple' or 'advanced')"
)),
}
}
fn note_system(&mut self, text: impl Into<String>) {
self.push_multiline(text.into(), OutputKind::System);
}
fn note_error(&mut self, text: impl Into<String>) {
self.push_multiline(text.into(), OutputKind::Error);
}
/// Push possibly-multi-line `text` as a sequence of single-line
/// `OutputLine`s. Keeping one display row per `OutputLine` is
/// what makes the scroll-position math (line count = display
/// rows) accurate; the renderer therefore truncates rather
/// than wraps long lines.
fn push_multiline(&mut self, text: String, kind: OutputKind) {
if text.is_empty() {
self.push_output(OutputLine {
text,
kind,
mode_at_submission: self.mode,
});
return;
}
for line in text.split('\n') {
self.push_output(OutputLine {
text: line.to_string(),
kind,
mode_at_submission: self.mode,
});
}
}
fn push_output(&mut self, line: OutputLine) {
self.output.push_back(line);
while self.output.len() > OUTPUT_CAPACITY {
self.output.pop_front();
}
// Any new line resets the scroll so freshly-arrived
// output is always visible. The user can PageUp again
// to inspect history.
self.output_scroll = 0;
}
fn scroll_output_up(&mut self) {
// Cap at `total_wrapped - visible` (display rows, not
// logical lines) so the topmost visible chunk is the
// first `visible` rendered rows; going past that would
// shrink the view by sliding the window off the top.
let max = self
.last_output_total_wrapped
.saturating_sub(self.last_output_visible.max(1));
self.output_scroll = (self.output_scroll + PAGE_SCROLL_LINES).min(max);
}
const fn scroll_output_down(&mut self) {
self.output_scroll = self.output_scroll.saturating_sub(PAGE_SCROLL_LINES);
}
}
fn parse_error_message(err: &ParseError) -> String {
match err {
ParseError::Invalid { message, .. } => message.clone(),
ParseError::Empty => "empty input".to_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",
};
format!(
" related: {} row(s) {} in `{}` for relationship `{}` (on delete {})",
effect.rows_changed,
what,
effect.child_table,
effect.relationship_name,
effect.action,
)
}
/// Render a data result as a sequence of aligned-column text
/// lines suitable for the output panel. Pretty box-drawing
/// rendering is V4 territory; this version uses simple
/// pipe-and-dash separators.
fn render_data_view(data: &DataResult) -> Vec<String> {
let header = data.columns.clone();
let body: Vec<Vec<String>> = data
.rows
.iter()
.map(|row| {
row.iter()
.map(|cell| {
cell.as_ref()
.map_or_else(|| "(null)".to_string(), Clone::clone)
})
.collect()
})
.collect();
// Column widths = max(header, all cells) per column.
let mut widths: Vec<usize> = header.iter().map(String::len).collect();
for row in &body {
for (i, cell) in row.iter().enumerate() {
if i < widths.len() && cell.chars().count() > widths[i] {
widths[i] = cell.chars().count();
}
}
}
let mut out: Vec<String> = Vec::with_capacity(body.len() + 3);
out.push(format!(" {}", join_padded(&header, &widths)));
out.push(format!(" {}", separator_row(&widths)));
if body.is_empty() {
out.push(" (no rows)".to_string());
} else {
for row in &body {
out.push(format!(" {}", join_padded(row, &widths)));
}
}
out
}
fn join_padded(cells: &[String], widths: &[usize]) -> String {
let mut s = String::new();
for (i, cell) in cells.iter().enumerate() {
if i > 0 {
s.push_str(" | ");
}
let w = widths.get(i).copied().unwrap_or(0);
s.push_str(cell);
let pad = w.saturating_sub(cell.chars().count());
for _ in 0..pad {
s.push(' ');
}
}
s
}
fn separator_row(widths: &[usize]) -> String {
let mut s = String::new();
for (i, w) in widths.iter().enumerate() {
if i > 0 {
s.push_str("-+-");
}
for _ in 0..*w {
s.push('-');
}
}
s
}
#[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;
fn key(code: KeyCode) -> AppEvent {
AppEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
fn key_mod(code: KeyCode, mods: KeyModifiers) -> AppEvent {
AppEvent::Key(KeyEvent::new(code, mods))
}
fn type_str(app: &mut App, s: &str) {
for c in s.chars() {
app.update(key(KeyCode::Char(c)));
}
}
fn submit(app: &mut App) -> Vec<Action> {
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,
}],
outbound_relationships: Vec::new(),
inbound_relationships: Vec::new(),
}
}
#[test]
fn typing_accumulates_in_input_buffer() {
let mut app = App::new();
type_str(&mut app, "hello");
assert_eq!(app.input, "hello");
assert!(app.output.is_empty());
}
#[test]
fn backspace_removes_last_char() {
let mut app = App::new();
type_str(&mut app, "abc");
app.update(key(KeyCode::Backspace));
assert_eq!(app.input, "ab");
}
#[test]
fn valid_dsl_in_simple_mode_emits_execute_action() {
let mut app = App::new();
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());
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]
fn enter_in_advanced_mode_echoes_with_advanced_tag() {
let mut app = App::new();
app.mode = Mode::Advanced;
type_str(&mut app, "select 1");
submit(&mut app);
// 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]
fn mode_command_switches_persistently() {
let mut app = App::new();
type_str(&mut app, "mode advanced");
submit(&mut app);
assert_eq!(app.mode, Mode::Advanced);
type_str(&mut app, "mode simple");
submit(&mut app);
assert_eq!(app.mode, Mode::Simple);
}
#[test]
fn mode_command_with_unknown_arg_errors() {
let mut app = App::new();
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"));
}
#[test]
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);
// 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]
fn quit_command_returns_quit_action() {
let mut app = App::new();
type_str(&mut app, "quit");
let actions = submit(&mut app);
assert_eq!(actions, vec![Action::Quit]);
}
#[test]
fn ctrl_c_returns_quit_action() {
let mut app = App::new();
let actions = app.update(key_mod(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert_eq!(actions, vec![Action::Quit]);
}
#[test]
fn empty_submission_is_a_noop() {
let mut app = App::new();
let actions = submit(&mut app);
assert!(actions.is_empty());
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();
assert_eq!(app.effective_mode(), EffectiveMode::Simple);
app.mode = Mode::Advanced;
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedPersistent);
}
#[test]
fn effective_mode_flips_to_one_shot_when_colon_typed_in_simple_mode() {
let mut app = App::new();
type_str(&mut app, ":sel");
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
while !app.input.is_empty() {
app.update(key(KeyCode::Backspace));
}
assert_eq!(app.effective_mode(), EffectiveMode::Simple);
}
#[test]
fn typing_colon_first_in_simple_mode_auto_inserts_a_space() {
let mut app = App::new();
type_str(&mut app, ":");
assert_eq!(app.input, ": ");
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
}
#[test]
fn typing_colon_after_other_chars_does_not_auto_insert_space() {
let mut app = App::new();
type_str(&mut app, "ab:");
assert_eq!(app.input, "ab:");
}
#[test]
fn typing_colon_in_advanced_mode_does_not_auto_insert_space() {
let mut app = App::new();
app.mode = Mode::Advanced;
type_str(&mut app, ":");
assert_eq!(app.input, ":");
}
#[test]
fn auto_inserted_space_can_be_removed_with_backspace() {
let mut app = App::new();
type_str(&mut app, ":");
assert_eq!(app.input, ": ");
app.update(key(KeyCode::Backspace));
assert_eq!(app.input, ":");
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot);
}
#[test]
fn dsl_success_event_records_table_view_and_appends_summary() {
let mut app = App::new();
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 dsl_failure_event_writes_error_with_friendly_message() {
let mut app = App::new();
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 tables_refreshed_event_replaces_cached_list() {
let mut app = App::new();
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 typing_moves_cursor_to_end_of_input() {
let mut app = App::new();
type_str(&mut app, "hello");
assert_eq!(app.input, "hello");
assert_eq!(app.input_cursor, 5);
}
#[test]
fn left_arrow_moves_cursor_back_one_char() {
let mut app = App::new();
type_str(&mut app, "hello");
app.update(key(KeyCode::Left));
assert_eq!(app.input_cursor, 4);
app.update(key(KeyCode::Left));
assert_eq!(app.input_cursor, 3);
}
#[test]
fn left_arrow_at_zero_does_not_underflow() {
let mut app = App::new();
app.update(key(KeyCode::Left));
assert_eq!(app.input_cursor, 0);
}
#[test]
fn right_arrow_moves_cursor_forward() {
let mut app = App::new();
type_str(&mut app, "hello");
app.input_cursor = 0;
app.update(key(KeyCode::Right));
assert_eq!(app.input_cursor, 1);
}
#[test]
fn home_and_end_jump_to_extremes() {
let mut app = App::new();
type_str(&mut app, "hello");
app.update(key(KeyCode::Home));
assert_eq!(app.input_cursor, 0);
app.update(key(KeyCode::End));
assert_eq!(app.input_cursor, 5);
}
#[test]
fn typing_inserts_at_cursor_position() {
let mut app = App::new();
type_str(&mut app, "hello");
// Cursor between 'h' and 'e'.
app.input_cursor = 1;
type_str(&mut app, "X");
assert_eq!(app.input, "hXello");
assert_eq!(app.input_cursor, 2);
}
#[test]
fn backspace_removes_char_before_cursor() {
let mut app = App::new();
type_str(&mut app, "hello");
// Cursor at end.
app.update(key(KeyCode::Backspace));
assert_eq!(app.input, "hell");
assert_eq!(app.input_cursor, 4);
// Cursor in the middle.
app.input_cursor = 2; // between 'e' and 'l'
app.update(key(KeyCode::Backspace));
assert_eq!(app.input, "hll");
assert_eq!(app.input_cursor, 1);
}
#[test]
fn backspace_at_start_is_a_noop() {
let mut app = App::new();
type_str(&mut app, "hello");
app.input_cursor = 0;
app.update(key(KeyCode::Backspace));
assert_eq!(app.input, "hello");
assert_eq!(app.input_cursor, 0);
}
#[test]
fn delete_removes_char_at_cursor() {
let mut app = App::new();
type_str(&mut app, "hello");
app.input_cursor = 1; // between 'h' and 'e'
app.update(key(KeyCode::Delete));
assert_eq!(app.input, "hllo");
assert_eq!(app.input_cursor, 1);
}
#[test]
fn delete_at_end_is_a_noop() {
let mut app = App::new();
type_str(&mut app, "hello");
app.update(key(KeyCode::Delete));
assert_eq!(app.input, "hello");
assert_eq!(app.input_cursor, 5);
}
#[test]
fn cursor_handles_multibyte_chars() {
let mut app = App::new();
type_str(&mut app, "héllo"); // 'é' is 2 bytes
// input length is 6 bytes, 5 chars
assert_eq!(app.input.len(), 6);
assert_eq!(app.input_cursor, 6);
// Move left across the 2-byte char.
app.update(key(KeyCode::Left));
assert_eq!(app.input_cursor, 5);
app.update(key(KeyCode::Left));
assert_eq!(app.input_cursor, 4);
app.update(key(KeyCode::Left));
assert_eq!(app.input_cursor, 3);
app.update(key(KeyCode::Left));
// Now at the byte before 'é' — must skip the multi-byte char.
assert_eq!(app.input_cursor, 1);
}
#[test]
fn submit_resets_cursor_to_zero() {
let mut app = App::new();
type_str(&mut app, "drop table T");
submit(&mut app);
assert_eq!(app.input_cursor, 0);
}
#[test]
fn page_up_scrolls_output_back() {
let mut app = App::new();
for i in 0..30 {
app.note_system(format!("line{i}"));
}
// Simulate a render establishing 10 visible / 30 wrapped.
app.note_output_viewport(10, 30);
assert_eq!(app.output_scroll, 0);
app.update(key(KeyCode::PageUp));
assert_eq!(app.output_scroll, super::PAGE_SCROLL_LINES);
}
#[test]
fn page_down_scrolls_output_back_to_bottom() {
let mut app = App::new();
for i in 0..30 {
app.note_system(format!("line{i}"));
}
app.note_output_viewport(10, 30);
for _ in 0..3 {
app.update(key(KeyCode::PageUp));
}
assert!(app.output_scroll > 0);
for _ in 0..10 {
app.update(key(KeyCode::PageDown));
}
assert_eq!(app.output_scroll, 0);
}
#[test]
fn new_output_resets_scroll_to_zero() {
let mut app = App::new();
for i in 0..30 {
app.note_system(format!("line{i}"));
}
app.note_output_viewport(10, 30);
app.update(key(KeyCode::PageUp));
assert!(app.output_scroll > 0);
// Any new output line snaps the scroll back to bottom so
// the user always sees the latest result after a command.
app.note_system("fresh");
assert_eq!(app.output_scroll, 0);
}
#[test]
fn page_up_caps_at_top_of_buffer() {
let mut app = App::new();
app.note_system("only line");
// Many PageUps in a row should not push past the buffer.
for _ in 0..50 {
app.update(key(KeyCode::PageUp));
}
// With 1 line in the buffer, the maximum scroll is 0
// (since there's nothing older to reveal).
assert_eq!(app.output_scroll, 0);
}
#[test]
fn page_up_at_top_of_buffer_does_not_shrink_visible_window() {
// Regression: extra PageUps past the top used to drift
// `output_scroll` higher than `len - visible`, which
// then made the rendered window slide off the top and
// appeared to "eat" lines from the bottom.
let mut app = App::new();
for i in 0..30 {
app.note_system(format!("line{i}"));
}
// Simulate a render reporting 10 visible rows over a
// 30-row wrapped buffer (every line fits in one row in
// this test).
app.note_output_viewport(10, 30);
// Page up many times — past the maximum useful scroll.
for _ in 0..20 {
app.update(key(KeyCode::PageUp));
}
// Cap should be at total_wrapped - visible = 30 - 10 = 20.
assert_eq!(app.output_scroll, 20);
}
#[test]
fn note_output_viewport_clamps_a_drifted_scroll_value() {
// If the scroll value was set high while the viewport
// was unknown (e.g. before the first render), the next
// render's report should bring it back into range.
let mut app = App::new();
for i in 0..30 {
app.note_system(format!("line{i}"));
}
app.output_scroll = 100;
app.note_output_viewport(10, 30);
assert_eq!(app.output_scroll, 20);
}
#[test]
fn history_recall_places_cursor_at_end() {
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");
assert_eq!(app.input_cursor, "drop table A".len());
}
#[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);
}
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,
})]
);
}
}