e52e90c45b
Add advanced-mode SQL `DROP TABLE [IF EXISTS] <name>` -> SqlDropTable, executing through the existing do_drop_table (cascade / inbound- relationship refusal / metadata cleanup) — full parity with the simple `drop table`. The only new behaviour is `IF EXISTS` as a no-op-with-note: a new DropOutcome::Skipped mirroring CreateOutcome::Skipped (journalled, no snapshot), rendered via a new ddl.drop_skipped_absent note + DslDropSkipped event. - Grammar: SQL_DROP_TABLE node (entry `drop`, shape `table [if exists] <name> [;]`), registered Advanced. SQL-first dispatch: `drop table T` -> SqlDropTable in advanced; `drop column`/`relationship`/`index`/ `constraint` fall back to the simple `drop` node (and still execute). - Worker: Request::SqlDropTable + db.sql_drop_table; the if-exists-and- absent arm journals + replies Skipped without a snapshot, else snapshot_then(do_drop_table) -> Dropped. - Completion: advanced `drop ` now surfaces the SQL `table` (the shared-entry-word behaviour from `create`); test split into simple (full DSL list) + advanced (SQL surface). Known shared-entry-word completion unevenness (advanced `drop ` offers only `table`; partial `drop rel` returns an empty list) deferred to 4i (merge candidate sets for shared entry words) along with a flagged user request to visually distinguish simple- vs advanced-mode completions in the hint UI — tracked in ADR §13 4i (d)/(e), the 4c plan, and the completion test. The DSL drops still parse + execute via fallback. 10 new tests (parse/builder + Tier-3: drop existing + one-undo-step + restore, IF EXISTS skip + journal, plain-absent error, inbound refusal). Docs: ADR-0035 Status/§13, README, requirements.md Q1. Tests: 1805 passing, 0 failing, 1 ignored. Clippy clean.
3931 lines
147 KiB
Rust
3931 lines
147 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::{
|
|
AddColumnResult, CascadeEffect, ChangeColumnTypeResult, DataResult, DeleteResult,
|
|
DropColumnResult, 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,
|
|
}
|
|
|
|
/// The semantic style class of an [`OutputSpan`] (ADR-0028 §5).
|
|
///
|
|
/// A general output-styling vocabulary, resolved to a concrete
|
|
/// theme colour at render time — never a baked-in colour. The
|
|
/// query-plan renderer (ADR-0028) is its first consumer.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum OutputStyleClass {
|
|
/// Default foreground — connectors, names, structural text.
|
|
Neutral,
|
|
/// An efficient query-plan step — an index search, a
|
|
/// covering index, a primary-key lookup.
|
|
Efficient,
|
|
/// An expensive query-plan step — a full table scan or a
|
|
/// temp B-tree.
|
|
Expensive,
|
|
/// An automatic-index step — the engine built a temporary
|
|
/// index because none existed; the strongest "add an index
|
|
/// here" signal.
|
|
AutomaticIndex,
|
|
}
|
|
|
|
/// A styled span of an output line: a byte range over the
|
|
/// line's text and the semantic class it carries (ADR-0028 §5).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct OutputSpan {
|
|
/// Half-open byte range `[start, end)` into the line text.
|
|
pub byte_range: (usize, usize),
|
|
pub class: OutputStyleClass,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct OutputLine {
|
|
pub text: String,
|
|
pub kind: OutputKind,
|
|
pub mode_at_submission: Mode,
|
|
/// Optional per-span styling (ADR-0028 §5). When `Some`,
|
|
/// `render_output_line` colours the text span-by-span from
|
|
/// these runs; when `None` it falls back to whole-line
|
|
/// styling by `kind`.
|
|
pub styled_runs: Option<Vec<OutputSpan>>,
|
|
}
|
|
|
|
impl OutputLine {
|
|
/// An output line carrying per-span styled runs (ADR-0028
|
|
/// §5) — the text is coloured per `runs`, not by `kind`.
|
|
#[must_use]
|
|
pub const fn styled(
|
|
text: String,
|
|
kind: OutputKind,
|
|
mode_at_submission: Mode,
|
|
runs: Vec<OutputSpan>,
|
|
) -> Self {
|
|
Self {
|
|
text,
|
|
kind,
|
|
mode_at_submission,
|
|
styled_runs: Some(runs),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
|
|
/// Collapse the persistent/one-shot distinction the UI cares
|
|
/// about into the plain [`Mode`] the walker reads from
|
|
/// `WalkContext::mode` (ADR-0030 §2). Both advanced variants
|
|
/// map to `Mode::Advanced`.
|
|
#[must_use]
|
|
pub const fn as_mode(self) -> Mode {
|
|
match self {
|
|
Self::Simple => Mode::Simple,
|
|
Self::AdvancedPersistent | Self::AdvancedOneShot => Mode::Advanced,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct App {
|
|
pub mode: Mode,
|
|
/// User's preferred verbosity for friendly-error rendering.
|
|
/// In-session state per ADR-0019 §5; persistence will land
|
|
/// alongside the broader settings-persistence ADR.
|
|
pub messages_verbosity: crate::friendly::Verbosity,
|
|
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>,
|
|
/// The validity indicator's currently-visible verdict
|
|
/// (ADR-0027). `None` means the indicator shows nothing —
|
|
/// the input is clean, or it is hidden mid-typing while the
|
|
/// debounce settles. The runtime owns the timing: it clears
|
|
/// this on a keystroke and sets it from
|
|
/// [`App::input_validity_verdict`] once typing pauses.
|
|
pub input_indicator: Option<crate::dsl::walker::Severity>,
|
|
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>,
|
|
/// Whether the open project is auto-named temporary or
|
|
/// user-named permanent. Drives the `[TEMP]` prefix in the
|
|
/// status bar and the `save` command's behaviour.
|
|
pub project_is_temp: bool,
|
|
/// Set when a fatal persistence failure has occurred
|
|
/// (ADR-0015 §8). The runtime reads this after the event
|
|
/// loop exits and prints it to stderr post-teardown so the
|
|
/// banner remains above the shell prompt.
|
|
pub fatal_message: Option<String>,
|
|
/// Active modal dialog (rebuild confirmation, save-as path
|
|
/// prompt, load picker, …). While `Some`, `update`
|
|
/// dispatches keys to the modal instead of the input
|
|
/// field.
|
|
pub modal: Option<Modal>,
|
|
/// Memo of the most recent Tab-completion (ADR-0022
|
|
/// stage 8). Carries enough state to cycle to the next /
|
|
/// previous candidate on subsequent Tab / Shift-Tab
|
|
/// presses, and to undo the whole insertion in one
|
|
/// keystroke via Esc / Backspace. Cleared by *any* other
|
|
/// keystroke — no completion mode, just a transient
|
|
/// memory of "the last thing Tab did."
|
|
pub last_completion: Option<crate::completion::LastCompletion>,
|
|
/// Per-project schema lookup cache feeding Tab completion
|
|
/// for identifier slots (ADR-0022 §9 + stage 8c). Empty
|
|
/// by default; refreshed by the runtime on project load
|
|
/// and after successful DDL.
|
|
pub schema_cache: crate::completion::SchemaCache,
|
|
/// Whether the undo/snapshot machinery is active this session
|
|
/// (ADR-0006 Amendment 1). `false` under the `--no-undo` CLI
|
|
/// flag; the `undo` / `redo` commands then report undo is off
|
|
/// rather than emitting a prepare action.
|
|
pub undo_enabled: bool,
|
|
}
|
|
|
|
/// Dialogs that take over keyboard input when active.
|
|
///
|
|
/// Track-2 lifecycle commands (`rebuild`, `save as`, `load`,
|
|
/// `new`) need confirmation prompts or path entry that the
|
|
/// single-line input field can't naturally express. Each
|
|
/// modal owns a small state machine; the renderer draws an
|
|
/// overlay and `App::update` routes keys through it.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Modal {
|
|
/// `rebuild` confirmation. Shows a summary of what would
|
|
/// be reconstructed; `Y` confirms, `N` / `Esc` dismisses.
|
|
RebuildConfirm(RebuildConfirmModal),
|
|
/// One-line text prompt used by `save` / `save as` for
|
|
/// the target name/path.
|
|
PathEntry(PathEntryModal),
|
|
/// Load picker. Shows a list of projects in the active
|
|
/// data root; `b` switches to a path-entry sub-mode for
|
|
/// projects outside the data root (ADR-0015 §7).
|
|
LoadPicker(LoadPickerModal),
|
|
/// `undo` / `redo` confirmation (ADR-0006 Amendment 1). Names
|
|
/// the command that will be undone / re-applied; `Y` confirms,
|
|
/// `N` / `Esc` dismisses.
|
|
UndoConfirm(UndoConfirmModal),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct UndoConfirmModal {
|
|
/// The command text of the snapshot being restored — the thing
|
|
/// that will be undone (or re-applied, for redo).
|
|
pub command: String,
|
|
/// When that snapshot was taken (ISO-8601 `Z`).
|
|
pub timestamp: String,
|
|
/// `false` for undo, `true` for redo — selects the wording.
|
|
pub is_redo: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct RebuildConfirmModal {
|
|
/// One-line summary derived from `project.yaml` + `data/`
|
|
/// (e.g. `"3 tables, 47 rows will be reconstructed"`).
|
|
pub summary: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct PathEntryModal {
|
|
pub title: String,
|
|
pub prompt: String,
|
|
pub input: String,
|
|
/// Byte offset of the insertion point inside `input`.
|
|
pub cursor: usize,
|
|
pub purpose: PathEntryPurpose,
|
|
}
|
|
|
|
/// What the runtime should do with the path the user typed.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum PathEntryPurpose {
|
|
/// Save the current project to the typed name/path.
|
|
/// Relative names resolve against `<data-root>/projects/`.
|
|
SaveAs,
|
|
/// Load the project at the typed path. Used by the load
|
|
/// picker's `b` (browse) sub-mode.
|
|
LoadByPath,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct LoadPickerModal {
|
|
pub entries: Vec<LoadPickerEntry>,
|
|
pub selected: usize,
|
|
/// Sub-mode: list-of-recents (default) or path-entry
|
|
/// (after `b`).
|
|
pub sub_mode: LoadPickerSubMode,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct LoadPickerEntry {
|
|
pub display_name: String,
|
|
pub modified: String,
|
|
pub path: std::path::PathBuf,
|
|
pub is_temp: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum LoadPickerSubMode {
|
|
List,
|
|
/// Switched to via `b`. Same input/cursor surface as
|
|
/// `PathEntryModal`; kept inline so the picker can flip
|
|
/// back to List with `Esc`.
|
|
PathEntry { input: String, cursor: usize },
|
|
}
|
|
|
|
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,
|
|
messages_verbosity: crate::friendly::Verbosity::default(),
|
|
input: String::new(),
|
|
input_cursor: 0,
|
|
output: VecDeque::with_capacity(OUTPUT_CAPACITY),
|
|
hint: None,
|
|
input_indicator: 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,
|
|
project_is_temp: false,
|
|
fatal_message: None,
|
|
modal: None,
|
|
last_completion: None,
|
|
schema_cache: crate::completion::SchemaCache::default(),
|
|
// Undo is on by default; the runtime flips this off for
|
|
// a `--no-undo` session (ADR-0006 Amendment 1).
|
|
undo_enabled: true,
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
}
|
|
|
|
/// Replace the in-memory navigable history with `entries`,
|
|
/// truncating to the in-memory cap.
|
|
///
|
|
/// Used by the runtime to hydrate from the project's
|
|
/// `history.log` on open (I2-persist, ADR-0015 §12).
|
|
/// Entries should arrive in chronological order (oldest
|
|
/// first); the most recent stays at the back, which is
|
|
/// where Up/Down navigation expects it.
|
|
///
|
|
/// Cancels any in-flight history navigation so a hydrate
|
|
/// during a session (e.g. after `load`) doesn't leave a
|
|
/// dangling cursor pointing at a now-removed entry.
|
|
pub fn seed_history(&mut self, entries: Vec<String>) {
|
|
self.history = entries;
|
|
while self.history.len() > HISTORY_CAPACITY {
|
|
self.history.remove(0);
|
|
}
|
|
self.history_cursor = None;
|
|
self.history_draft = None;
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
}
|
|
|
|
/// The validity-indicator verdict for the current input
|
|
/// (ADR-0027 §3). `None` when the input would run clean.
|
|
///
|
|
/// Computed only in simple mode — advanced mode is raw SQL,
|
|
/// which the DSL walker does not parse (ADR-0027 §7). A
|
|
/// pure query the runtime calls once the typing debounce
|
|
/// settles; the result is stored in `input_indicator`.
|
|
///
|
|
/// ADR-0032 §10.6 — the verdict reads the walker view of
|
|
/// the *active* effective mode so a SQL form in Advanced
|
|
/// mode lights up the same `[ERR]` / `[WRN]` indicator the
|
|
/// DSL surface uses. Without this the SQL predicate
|
|
/// warnings (ADR-0032 §11.6) would emit but never reach
|
|
/// the validity indicator the user sees.
|
|
#[must_use]
|
|
pub fn input_validity_verdict(&self) -> Option<crate::dsl::walker::Severity> {
|
|
let mode = match self.effective_mode() {
|
|
EffectiveMode::Simple => Mode::Simple,
|
|
EffectiveMode::AdvancedPersistent
|
|
| EffectiveMode::AdvancedOneShot => Mode::Advanced,
|
|
};
|
|
crate::dsl::walker::input_verdict_in_mode(
|
|
&self.input,
|
|
Some(&self.schema_cache),
|
|
mode,
|
|
)
|
|
}
|
|
|
|
/// 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::DslCreateSkipped {
|
|
command,
|
|
description,
|
|
} => {
|
|
// No-op (CREATE TABLE IF NOT EXISTS on an existing
|
|
// table, ADR-0035 §4): the skip note, then the existing
|
|
// structure — no misleading "[ok] create table" line.
|
|
self.note_system(crate::t!(
|
|
"ddl.create_skipped_exists",
|
|
name = command.target_table()
|
|
));
|
|
for line in crate::output_render::render_structure(&description) {
|
|
self.note_system(line);
|
|
}
|
|
self.current_table = Some(description);
|
|
Vec::new()
|
|
}
|
|
AppEvent::DslDropSkipped { command } => {
|
|
// No-op (DROP TABLE IF EXISTS on an absent table,
|
|
// ADR-0035 §4, 4c): just the skip note — no structure,
|
|
// no misleading "[ok] drop table" line.
|
|
self.note_system(crate::t!(
|
|
"ddl.drop_skipped_absent",
|
|
name = command.target_table()
|
|
));
|
|
Vec::new()
|
|
}
|
|
AppEvent::DslDataSucceeded { command, data } => {
|
|
self.handle_dsl_query_success(&command, &data);
|
|
Vec::new()
|
|
}
|
|
AppEvent::DslExplainSucceeded { command, plan } => {
|
|
self.handle_dsl_explain_success(&command, &plan);
|
|
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::DslChangeColumnSucceeded { command, result } => {
|
|
self.handle_dsl_change_column_success(&command, result);
|
|
Vec::new()
|
|
}
|
|
AppEvent::DslAddColumnSucceeded { command, result } => {
|
|
self.handle_dsl_add_column_success(&command, result);
|
|
Vec::new()
|
|
}
|
|
AppEvent::DslDropColumnSucceeded { command, result } => {
|
|
self.handle_dsl_drop_column_success(&command, result);
|
|
Vec::new()
|
|
}
|
|
AppEvent::DslFailed {
|
|
command,
|
|
error,
|
|
facts,
|
|
source,
|
|
} => {
|
|
self.handle_dsl_failure(&command, error, facts);
|
|
// ADR-0034 §1/§2: an execution failure is journalled
|
|
// `err` so it is recallable across sessions (the
|
|
// worker only journals successful commands). The App
|
|
// emits the intent; the runtime does the append.
|
|
vec![Action::JournalFailure { source }]
|
|
}
|
|
AppEvent::TablesRefreshed(tables) => {
|
|
trace!(count = tables.len(), "tables refreshed");
|
|
self.tables = tables;
|
|
Vec::new()
|
|
}
|
|
AppEvent::SchemaCacheRefreshed(cache) => {
|
|
trace!(
|
|
tables = cache.tables.len(),
|
|
columns = cache.columns.len(),
|
|
relationships = cache.relationships.len(),
|
|
"schema cache refreshed",
|
|
);
|
|
self.schema_cache = cache;
|
|
Vec::new()
|
|
}
|
|
AppEvent::PersistenceFatal {
|
|
operation,
|
|
path,
|
|
message,
|
|
} => {
|
|
let banner = crate::t!(
|
|
"fatal.persistence",
|
|
operation = operation,
|
|
path = path.display(),
|
|
message = message
|
|
);
|
|
self.note_error(banner.clone());
|
|
self.fatal_message = Some(banner);
|
|
vec![Action::Quit]
|
|
}
|
|
AppEvent::RebuildPrepared { summary } => {
|
|
self.modal = Some(Modal::RebuildConfirm(RebuildConfirmModal { summary }));
|
|
Vec::new()
|
|
}
|
|
AppEvent::RebuildSucceeded { summary } => {
|
|
self.modal = None;
|
|
self.note_system(crate::t!("project.rebuild_ok", summary = summary));
|
|
Vec::new()
|
|
}
|
|
AppEvent::RebuildFailed { error } => {
|
|
self.modal = None;
|
|
self.note_error(crate::t!("project.rebuild_failed", error = error));
|
|
Vec::new()
|
|
}
|
|
AppEvent::UndoPrepared {
|
|
command,
|
|
timestamp,
|
|
is_redo,
|
|
} => {
|
|
self.modal = Some(Modal::UndoConfirm(UndoConfirmModal {
|
|
command,
|
|
timestamp,
|
|
is_redo,
|
|
}));
|
|
Vec::new()
|
|
}
|
|
AppEvent::UndoUnavailable { is_redo } => {
|
|
if is_redo {
|
|
self.note_system(crate::t!("undo.nothing_to_redo"));
|
|
} else {
|
|
self.note_system(crate::t!("undo.nothing_to_undo"));
|
|
}
|
|
Vec::new()
|
|
}
|
|
AppEvent::UndoSucceeded { command, is_redo } => {
|
|
self.modal = None;
|
|
if is_redo {
|
|
self.note_system(crate::t!("undo.redone_ok", command = command));
|
|
} else {
|
|
self.note_system(crate::t!("undo.undone_ok", command = command));
|
|
}
|
|
Vec::new()
|
|
}
|
|
AppEvent::UndoFailed { error, is_redo } => {
|
|
self.modal = None;
|
|
if is_redo {
|
|
self.note_error(crate::t!("undo.redo_failed", error = error));
|
|
} else {
|
|
self.note_error(crate::t!("undo.undo_failed", error = error));
|
|
}
|
|
Vec::new()
|
|
}
|
|
AppEvent::LoadPickerReady { entries } => {
|
|
if entries.is_empty() {
|
|
// Empty data root: jump straight to path-entry
|
|
// mode so the user can still browse to a
|
|
// project elsewhere.
|
|
self.modal = Some(Modal::LoadPicker(LoadPickerModal {
|
|
entries,
|
|
selected: 0,
|
|
sub_mode: LoadPickerSubMode::PathEntry {
|
|
input: String::new(),
|
|
cursor: 0,
|
|
},
|
|
}));
|
|
} else {
|
|
self.modal = Some(Modal::LoadPicker(LoadPickerModal {
|
|
entries,
|
|
selected: 0,
|
|
sub_mode: LoadPickerSubMode::List,
|
|
}));
|
|
}
|
|
Vec::new()
|
|
}
|
|
AppEvent::ProjectSwitched {
|
|
display_name,
|
|
is_temp,
|
|
history_entries,
|
|
} => {
|
|
self.note_system(crate::t!(
|
|
"project.switched_ok",
|
|
display_name = display_name
|
|
));
|
|
self.project_name = Some(display_name);
|
|
self.project_is_temp = is_temp;
|
|
self.tables.clear();
|
|
self.current_table = None;
|
|
self.seed_history(history_entries);
|
|
Vec::new()
|
|
}
|
|
AppEvent::ProjectSwitchFailed { error } => {
|
|
self.note_error(crate::t!("project.switch_failed", error = error));
|
|
Vec::new()
|
|
}
|
|
AppEvent::ExportSucceeded { path } => {
|
|
self.note_system(crate::t!(
|
|
"project.export_ok",
|
|
path = path.display()
|
|
));
|
|
Vec::new()
|
|
}
|
|
AppEvent::ExportFailed { error } => {
|
|
self.note_error(crate::t!("project.export_failed", error = error));
|
|
Vec::new()
|
|
}
|
|
AppEvent::ReplayCompleted {
|
|
path,
|
|
count,
|
|
warnings,
|
|
} => {
|
|
self.note_system(crate::t!(
|
|
"replay.completed",
|
|
path = path,
|
|
count = count
|
|
));
|
|
// ADR-0034: surface `[skip]` warnings for app-lifecycle
|
|
// commands whose omission can leave the replayed state
|
|
// incomplete (`import`, nested `replay`).
|
|
for warning in warnings {
|
|
self.note_system(warning);
|
|
}
|
|
Vec::new()
|
|
}
|
|
AppEvent::ReplayFailed {
|
|
path,
|
|
line_number,
|
|
command,
|
|
error,
|
|
} => {
|
|
// line_number == 0 is the runtime's signal that
|
|
// file-open itself failed (no per-line context to
|
|
// surface). Otherwise we lead with the line-number
|
|
// header and echo the offending command beneath
|
|
// it, mirroring how the interactive `running: …`
|
|
// path renders source-line context above an error.
|
|
if line_number == 0 {
|
|
self.note_error(crate::t!(
|
|
"replay.failed_open",
|
|
path = path,
|
|
error = error
|
|
));
|
|
} else {
|
|
self.note_error(crate::t!(
|
|
"replay.failed_at_line",
|
|
path = path,
|
|
line_number = line_number,
|
|
error = error
|
|
));
|
|
if !command.is_empty() {
|
|
self.note_error(crate::t!(
|
|
"replay.command_echo",
|
|
command = command
|
|
));
|
|
}
|
|
}
|
|
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");
|
|
|
|
// While a modal is open it owns the keyboard. Normal
|
|
// input editing, history navigation, and command
|
|
// submission are all gated behind closing the modal.
|
|
if self.modal.is_some() {
|
|
return self.handle_modal_key(key);
|
|
}
|
|
|
|
// ADR-0022 stage 8 — non-modal completion. Tab /
|
|
// Shift-Tab cycle; Esc / Backspace undo the whole
|
|
// last-Tab insertion in one keystroke while the memo
|
|
// is alive (per the user's symmetry preference: one
|
|
// keystroke to insert, one to remove). Any other key
|
|
// clears the memo before being processed normally.
|
|
match (key.code, key.modifiers) {
|
|
(KeyCode::Tab, _) => return self.completion_tab_forward(),
|
|
(KeyCode::BackTab, _) => return self.completion_tab_backward(),
|
|
(KeyCode::Esc, _) if self.last_completion.is_some() => {
|
|
self.undo_last_completion();
|
|
return Vec::new();
|
|
}
|
|
(KeyCode::Backspace, _) if self.last_completion.is_some() => {
|
|
self.undo_last_completion();
|
|
return Vec::new();
|
|
}
|
|
_ => self.last_completion = None,
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/// Tab key handler — insert / cycle forward through the
|
|
/// candidates at the cursor (ADR-0022 stage 8). Behaviour
|
|
/// is split between single and multi-candidate cases per
|
|
/// the user's stage-8 feedback round 2:
|
|
///
|
|
/// - **Single candidate**: insert with trailing space,
|
|
/// no memo. The space is the natural commit; subsequent
|
|
/// Tab fresh-computes at the new cursor. Esc/Backspace
|
|
/// are normal.
|
|
/// - **Multi candidate**: insert WITHOUT trailing space,
|
|
/// create memo for cycling. Tab again cycles; any other
|
|
/// key clears the memo. Pressing space (the natural
|
|
/// "I'm done picking" gesture) clears the memo and adds
|
|
/// the space, completing the chosen candidate.
|
|
/// - **Memo present**: cycle to next candidate, replacing
|
|
/// the inserted text in place (still no trailing space).
|
|
/// - **No candidates**: no-op.
|
|
fn completion_tab_forward(&mut self) -> Vec<Action> {
|
|
self.cancel_history_navigation();
|
|
if let Some(memo) = self.last_completion.take() {
|
|
let next = memo.next_idx();
|
|
self.last_completion = Some(self.replace_inserted(memo, next));
|
|
return Vec::new();
|
|
}
|
|
self.start_or_complete_at(0);
|
|
Vec::new()
|
|
}
|
|
|
|
/// Shift-Tab key handler — symmetric to forward; on a
|
|
/// fresh multi-candidate position starts from the last
|
|
/// candidate (per the user's #2 wrap-from-both-ends).
|
|
/// Single candidate behaves identically to Tab.
|
|
fn completion_tab_backward(&mut self) -> Vec<Action> {
|
|
self.cancel_history_navigation();
|
|
if let Some(memo) = self.last_completion.take() {
|
|
let prev = memo.prev_idx();
|
|
self.last_completion = Some(self.replace_inserted(memo, prev));
|
|
return Vec::new();
|
|
}
|
|
self.start_or_complete_last();
|
|
Vec::new()
|
|
}
|
|
|
|
/// Esc / Backspace handler while a completion memo is
|
|
/// alive — restore the original text in `inserted_range`
|
|
/// and place the cursor where the user was when they hit
|
|
/// Tab. The memo is cleared. Only fires on multi-candidate
|
|
/// completions (single-candidate paths don't create a
|
|
/// memo); the user accepts that single-candidate Tab
|
|
/// requires regular backspace to undo.
|
|
fn undo_last_completion(&mut self) {
|
|
let Some(memo) = self.last_completion.take() else {
|
|
return;
|
|
};
|
|
let (start, end) = memo.inserted_range;
|
|
self.input.replace_range(start..end, &memo.original_text);
|
|
self.input_cursor = start + memo.original_text.len();
|
|
}
|
|
|
|
fn start_or_complete_at(&mut self, multi_start_idx: usize) {
|
|
let cursor = self.input_cursor.min(self.input.len());
|
|
let Some(comp) = crate::completion::candidates_at_cursor_in_mode(
|
|
&self.input,
|
|
cursor,
|
|
&self.schema_cache,
|
|
self.effective_mode().as_mode(),
|
|
) else {
|
|
return;
|
|
};
|
|
if comp.candidates.len() == 1 {
|
|
self.commit_unique(&comp);
|
|
} else {
|
|
let idx = multi_start_idx % comp.candidates.len();
|
|
self.last_completion = Some(self.commit_multi(comp, idx));
|
|
}
|
|
}
|
|
|
|
fn start_or_complete_last(&mut self) {
|
|
let cursor = self.input_cursor.min(self.input.len());
|
|
let Some(comp) = crate::completion::candidates_at_cursor_in_mode(
|
|
&self.input,
|
|
cursor,
|
|
&self.schema_cache,
|
|
self.effective_mode().as_mode(),
|
|
) else {
|
|
return;
|
|
};
|
|
if comp.candidates.len() == 1 {
|
|
self.commit_unique(&comp);
|
|
} else {
|
|
let idx = comp.candidates.len() - 1;
|
|
self.last_completion = Some(self.commit_multi(comp, idx));
|
|
}
|
|
}
|
|
|
|
/// Single-candidate commit: insert "<text> " (with trailing
|
|
/// space) and DO NOT create a memo. The user can keep
|
|
/// typing or press Tab again to fresh-complete at the new
|
|
/// cursor.
|
|
fn commit_unique(&mut self, comp: &crate::completion::Completion) {
|
|
let inserted = format!("{} ", comp.candidates[0].text);
|
|
self.input
|
|
.replace_range(comp.replaced_range.0..comp.replaced_range.1, &inserted);
|
|
self.input_cursor = comp.replaced_range.0 + inserted.len();
|
|
}
|
|
|
|
/// Multi-candidate commit: insert just the candidate text
|
|
/// (no trailing space) and return the memo carrying the
|
|
/// full candidate list for cycling. The user presses space
|
|
/// (or any other non-Tab key) to commit the choice — that
|
|
/// clears the memo and inserts whatever they typed
|
|
/// normally, naturally producing "<chosen> " as the
|
|
/// completed text.
|
|
fn commit_multi(
|
|
&mut self,
|
|
comp: crate::completion::Completion,
|
|
idx: usize,
|
|
) -> crate::completion::LastCompletion {
|
|
let inserted = comp.candidates[idx].text.clone();
|
|
let original_text =
|
|
self.input[comp.replaced_range.0..comp.replaced_range.1].to_string();
|
|
self.input
|
|
.replace_range(comp.replaced_range.0..comp.replaced_range.1, &inserted);
|
|
let new_end = comp.replaced_range.0 + inserted.len();
|
|
self.input_cursor = new_end;
|
|
crate::completion::LastCompletion {
|
|
inserted_range: (comp.replaced_range.0, new_end),
|
|
original_text,
|
|
candidates: comp.candidates,
|
|
selection_idx: idx,
|
|
}
|
|
}
|
|
|
|
/// Replace the inserted text with `candidates[idx]` (no
|
|
/// trailing space — same multi-candidate convention) and
|
|
/// return an updated memo. Used by Tab/Shift-Tab cycling.
|
|
fn replace_inserted(
|
|
&mut self,
|
|
memo: crate::completion::LastCompletion,
|
|
idx: usize,
|
|
) -> crate::completion::LastCompletion {
|
|
let new_inserted = memo.candidates[idx].text.clone();
|
|
let (start, end) = memo.inserted_range;
|
|
self.input.replace_range(start..end, &new_inserted);
|
|
let new_end = start + new_inserted.len();
|
|
self.input_cursor = new_end;
|
|
crate::completion::LastCompletion {
|
|
inserted_range: (start, new_end),
|
|
selection_idx: idx,
|
|
..memo
|
|
}
|
|
}
|
|
|
|
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) {
|
|
// Submitting a command always ends history navigation —
|
|
// the next Up restarts from the newest entry. Reset here,
|
|
// before the early-return guards below, so a recalled
|
|
// command re-submitted unchanged (a consecutive duplicate)
|
|
// doesn't strand the cursor at its old position.
|
|
self.history_cursor = None;
|
|
self.history_draft = None;
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
// Parse-first: app-level commands and DSL commands now
|
|
// share the chumsky parser (per the round-5 refactor).
|
|
// App commands work in both modes — they're not gated by
|
|
// `effective_mode`. Anything that parses to a non-App
|
|
// variant falls through to the existing mode-specific
|
|
// path: simple → DSL execution; advanced → SQL placeholder.
|
|
// Anything that fails to parse falls through too — the
|
|
// simple-mode path renders the friendly parse error, the
|
|
// advanced-mode path renders the SQL placeholder.
|
|
if let Ok(Command::App(app_cmd)) = parse_command(&effective_input) {
|
|
return self.dispatch_app_command(app_cmd, &effective_input);
|
|
}
|
|
|
|
// For everything else: unified dispatch. `dispatch_dsl`
|
|
// parses with `effective_mode` (ADR-0030 §2), so a SQL
|
|
// form in advanced mode runs and a SQL form in simple
|
|
// mode yields the precise "this is SQL" hint through the
|
|
// walker's mode gate — no separate placeholder branch.
|
|
self.dispatch_dsl(&effective_input, effective_mode)
|
|
}
|
|
|
|
/// Dispatch a parsed app-lifecycle command. Works in both
|
|
/// simple and advanced modes; the parse-first refactor
|
|
/// (round-5) routes app commands here before the
|
|
/// mode-specific DSL/SQL paths.
|
|
fn dispatch_app_command(
|
|
&mut self,
|
|
cmd: crate::dsl::AppCommand,
|
|
source: &str,
|
|
) -> Vec<Action> {
|
|
use crate::dsl::{AppCommand, MessagesValue, ModeValue};
|
|
match cmd {
|
|
AppCommand::Quit => vec![Action::Quit],
|
|
AppCommand::Help => {
|
|
self.note_help();
|
|
Vec::new()
|
|
}
|
|
AppCommand::Rebuild => vec![Action::PrepareRebuild],
|
|
AppCommand::Save => self.handle_save_command(false),
|
|
AppCommand::SaveAs => self.handle_save_command(true),
|
|
AppCommand::New => vec![Action::NewProject {
|
|
source: "new".to_string(),
|
|
}],
|
|
AppCommand::Load => vec![Action::OpenLoadPicker],
|
|
AppCommand::Export { path } => path.map_or_else(
|
|
|| {
|
|
vec![Action::Export {
|
|
target: None,
|
|
source: "export".to_string(),
|
|
}]
|
|
},
|
|
|target| {
|
|
vec![Action::Export {
|
|
source: format!("export {target}"),
|
|
target: Some(target),
|
|
}]
|
|
},
|
|
),
|
|
AppCommand::Import { path, target } => {
|
|
// The path-bearing import goes through the
|
|
// pre-chumsky source-slice (parser.rs), which
|
|
// already validated non-empty path. Bare
|
|
// `import` returns from chumsky with an empty
|
|
// path string — surface the usage error.
|
|
if path.is_empty() {
|
|
self.note_error(crate::t!("project.import_usage"));
|
|
return Vec::new();
|
|
}
|
|
vec![Action::Import {
|
|
zip_path: path,
|
|
as_target: target,
|
|
source: source.to_string(),
|
|
}]
|
|
}
|
|
AppCommand::Mode { value } => {
|
|
let arg = match value {
|
|
ModeValue::Simple => "simple",
|
|
ModeValue::Advanced => "advanced",
|
|
};
|
|
self.handle_mode_command(&format!("mode {arg}"));
|
|
Vec::new()
|
|
}
|
|
AppCommand::Messages { value } => {
|
|
let raw = match value {
|
|
None => "messages".to_string(),
|
|
Some(MessagesValue::Short) => "messages short".to_string(),
|
|
Some(MessagesValue::Verbose) => "messages verbose".to_string(),
|
|
};
|
|
self.handle_messages_command(&raw);
|
|
Vec::new()
|
|
}
|
|
AppCommand::Undo => self.handle_undo_command(false),
|
|
AppCommand::Redo => self.handle_undo_command(true),
|
|
}
|
|
}
|
|
|
|
/// `undo` / `redo` dispatch. When undo is disabled (`--no-undo`)
|
|
/// the command reports that and does nothing; otherwise it asks
|
|
/// the runtime to peek the snapshot and open the confirmation
|
|
/// modal (ADR-0006 Amendment 1).
|
|
fn handle_undo_command(&mut self, is_redo: bool) -> Vec<Action> {
|
|
if !self.undo_enabled {
|
|
self.note_system(crate::t!("undo.disabled"));
|
|
return Vec::new();
|
|
}
|
|
if is_redo {
|
|
vec![Action::PrepareRedo]
|
|
} else {
|
|
vec![Action::PrepareUndo]
|
|
}
|
|
}
|
|
|
|
fn dispatch_dsl(&mut self, input: &str, submission_mode: Mode) -> Vec<Action> {
|
|
// ADR-0024 §Phase D: parse with the live schema so typed
|
|
// value slots (insert-into-T-values-…) dispatch on the
|
|
// column's actual user-facing type instead of accepting
|
|
// any literal at bind time.
|
|
//
|
|
// ADR-0030 §2: parse with the submission's effective
|
|
// mode so the walker gates SQL-only forms — simple-mode
|
|
// `select` returns the "this is SQL" hint as a normal
|
|
// parse error and is rendered through the Err arm below.
|
|
match crate::dsl::parser::parse_command_with_schema_in_mode(
|
|
input,
|
|
&self.schema_cache,
|
|
submission_mode,
|
|
) {
|
|
Ok(Command::Replay { path }) => {
|
|
// `replay` is parsed as a DSL command for the
|
|
// sake of grammar uniformity, but its execution
|
|
// model is fundamentally different from every
|
|
// other command — it loops over file content and
|
|
// re-enters the dispatch pipeline once per line.
|
|
// Sending it down the ExecuteDsl path would push
|
|
// the recursion through the database worker
|
|
// thread, which is wrong: the worker has no
|
|
// filesystem context, and replay would also land
|
|
// in `history.log` (where it would re-trigger
|
|
// itself on the next replay-of-history). So we
|
|
// hand it off as a dedicated `Action::Replay`,
|
|
// keeping the worker out of the loop and the
|
|
// history.log clean.
|
|
self.push_output(OutputLine {
|
|
text: crate::t!("dsl.running", input = input),
|
|
kind: OutputKind::Echo,
|
|
mode_at_submission: submission_mode,
|
|
styled_runs: None,
|
|
});
|
|
vec![Action::Replay { path }]
|
|
}
|
|
Ok(cmd) => {
|
|
self.push_output(OutputLine {
|
|
text: crate::t!("dsl.running", input = input),
|
|
kind: OutputKind::Echo,
|
|
mode_at_submission: submission_mode,
|
|
styled_runs: None,
|
|
});
|
|
vec![Action::ExecuteDsl {
|
|
command: cmd,
|
|
source: input.to_string(),
|
|
}]
|
|
}
|
|
Err(ParseError::Empty) => Vec::new(),
|
|
Err(err) => {
|
|
// Echo the source line so the user can see what
|
|
// got submitted (and copy-paste it back to fix).
|
|
self.push_output(OutputLine {
|
|
text: crate::t!("dsl.running", input = input),
|
|
kind: OutputKind::Echo,
|
|
mode_at_submission: submission_mode,
|
|
styled_runs: None,
|
|
});
|
|
// Caret pointer at the failure position, when we
|
|
// have one. Aligned to the "running: " prefix so
|
|
// the caret sits under the offending character.
|
|
//
|
|
// Note: the prefix length is hardcoded against
|
|
// the en-US `dsl.running` template ("running:
|
|
// {input}"). A translator changing that prefix
|
|
// must update this width too — the constraint is
|
|
// captured in the catalog comment block.
|
|
//
|
|
// ADR-0020: positions returned by `parse_command`
|
|
// are byte offsets into the *original* input
|
|
// (the lexer doesn't trim before lexing). We
|
|
// convert to a character count for caret padding.
|
|
if let ParseError::Invalid { position, .. } = &err {
|
|
let prefix = "running: ";
|
|
let chars_before = input
|
|
.get(..*position)
|
|
.map_or(*position, |s| s.chars().count());
|
|
let pad = prefix.chars().count() + chars_before;
|
|
self.note_error(crate::t!(
|
|
"parse.caret",
|
|
padding = " ".repeat(pad)
|
|
));
|
|
}
|
|
self.note_error(crate::t!(
|
|
"parse.error",
|
|
detail = parse_error_message(&err)
|
|
));
|
|
// ADR-0033 Amendment 3: combine the DSL error with a
|
|
// pointer to advanced mode when the same line would
|
|
// run as SQL there. Only in simple mode (a one-shot
|
|
// `:` or persistent advanced submission uses the SQL
|
|
// surface already). This mirrors the live hint and
|
|
// covers SQL constructs that surface only on submit
|
|
// (e.g. `delete … returning`, where the live hint
|
|
// shows WHERE-completion rather than an error).
|
|
if submission_mode == Mode::Simple
|
|
&& let Some(note) =
|
|
crate::input_render::advanced_alternative_note(input, &self.schema_cache)
|
|
{
|
|
self.note_error(note);
|
|
}
|
|
// ADR-0021 §2: append the usage block (if a
|
|
// known command-entry keyword was consumed) or
|
|
// the available-commands fallback (§5).
|
|
if let ParseError::Invalid { .. } = &err {
|
|
self.note_error(render_usage_block(input));
|
|
}
|
|
// ADR-0034 §1/§2: a submitted line that failed to
|
|
// parse is journalled `err` so it is recallable
|
|
// across sessions (the same `source` an `ok`
|
|
// command would record). The runtime does the
|
|
// append; the App only emits the intent.
|
|
vec![Action::JournalFailure {
|
|
source: input.to_string(),
|
|
}]
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Emit the standard `[ok] <verb> <subject>` header used by
|
|
/// every successful DSL command. Routes through the i18n
|
|
/// catalog (ADR-0019 §9 sweep).
|
|
fn note_ok_summary(&mut self, command: &Command) {
|
|
self.note_system(crate::t!(
|
|
"ok.summary",
|
|
verb = command.verb(),
|
|
subject = command.display_subject()
|
|
));
|
|
}
|
|
|
|
fn handle_dsl_success(&mut self, command: &Command, description: Option<TableDescription>) {
|
|
self.note_ok_summary(command);
|
|
if let Some(desc) = description.as_ref() {
|
|
for line in crate::output_render::render_structure(desc) {
|
|
self.note_system(line);
|
|
}
|
|
}
|
|
self.current_table = description;
|
|
}
|
|
|
|
fn handle_dsl_query_success(&mut self, command: &Command, data: &DataResult) {
|
|
self.note_ok_summary(command);
|
|
for line in crate::output_render::render_data_table(data) {
|
|
self.note_system(line);
|
|
}
|
|
}
|
|
|
|
fn handle_dsl_explain_success(
|
|
&mut self,
|
|
command: &Command,
|
|
plan: &crate::db::QueryPlan,
|
|
) {
|
|
self.note_ok_summary(command);
|
|
// ADR-0028 §3: the display SQL, then the plan tree.
|
|
// `render_explain_plan` returns ready-built `OutputLine`s
|
|
// so it can carry the per-span styling (ADR-0028 §5).
|
|
for line in crate::output_render::render_explain_plan(plan, self.mode) {
|
|
self.push_output(line);
|
|
}
|
|
}
|
|
|
|
fn handle_dsl_insert_success(&mut self, command: &Command, result: &InsertResult) {
|
|
self.note_ok_summary(command);
|
|
self.note_system(crate::t!("ok.rows_inserted", count = result.rows_affected));
|
|
for line in crate::output_render::render_data_table(&result.data) {
|
|
self.note_system(line);
|
|
}
|
|
}
|
|
|
|
fn handle_dsl_update_success(&mut self, command: &Command, result: &UpdateResult) {
|
|
self.note_ok_summary(command);
|
|
self.note_system(crate::t!("ok.rows_updated", count = result.rows_affected));
|
|
// A column-less result carries no rows to tabulate (the SQL
|
|
// UPDATE path before `RETURNING`, ADR-0033 sub-phase 3e):
|
|
// surface just the count rather than a misleading
|
|
// "(no rows)" band. The DSL UPDATE always has columns.
|
|
if !result.data.columns.is_empty() {
|
|
for line in crate::output_render::render_data_table(&result.data) {
|
|
self.note_system(line);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_dsl_add_column_success(
|
|
&mut self,
|
|
command: &Command,
|
|
result: AddColumnResult,
|
|
) {
|
|
self.note_ok_summary(command);
|
|
// ADR-0018 §9: emit auto-fill note(s) before the
|
|
// structure render, so the pedagogical "the tool did
|
|
// this for you" line is in the user's eye-line next to
|
|
// the success summary.
|
|
for note in result.client_side_notes {
|
|
self.note_system(note);
|
|
}
|
|
for line in crate::output_render::render_structure(&result.description) {
|
|
self.note_system(line);
|
|
}
|
|
self.current_table = Some(result.description);
|
|
}
|
|
|
|
fn handle_dsl_drop_column_success(
|
|
&mut self,
|
|
command: &Command,
|
|
result: DropColumnResult,
|
|
) {
|
|
self.note_ok_summary(command);
|
|
// ADR-0025: when `--cascade` removed covering indexes,
|
|
// name each one so the learner sees the side effect.
|
|
for index in &result.dropped_indexes {
|
|
self.note_system(crate::t!(
|
|
"ok.index_dropped_with_column",
|
|
index = index,
|
|
));
|
|
}
|
|
for line in crate::output_render::render_structure(&result.description) {
|
|
self.note_system(line);
|
|
}
|
|
self.current_table = Some(result.description);
|
|
}
|
|
|
|
fn handle_dsl_change_column_success(
|
|
&mut self,
|
|
command: &Command,
|
|
result: ChangeColumnTypeResult,
|
|
) {
|
|
self.note_ok_summary(command);
|
|
if let Some(note) = result.client_side {
|
|
// ADR-0017 §6 + ADR-0018 §9: pedagogical hook
|
|
// telling the learner "the tool did this for you;
|
|
// raw SQL would need explicit CAST / UPDATE / etc."
|
|
//
|
|
// When both transformations and auto-fills happen
|
|
// in the same operation, both note lines are
|
|
// emitted in order (ADR-0018 §9 explicit rule).
|
|
if note.transformed > 0 {
|
|
let line = if note.lossy > 0 {
|
|
crate::t!(
|
|
"client_side.transformed_lossy",
|
|
count = note.transformed,
|
|
lossy = note.lossy
|
|
)
|
|
} else {
|
|
crate::t!(
|
|
"client_side.transformed",
|
|
count = note.transformed
|
|
)
|
|
};
|
|
self.note_system(line);
|
|
}
|
|
if note.auto_filled > 0 {
|
|
let kind = match note.auto_fill_kind {
|
|
Some(crate::db::AutoFillKind::Serial) => "serial",
|
|
Some(crate::db::AutoFillKind::ShortId) => "shortid",
|
|
None => "auto-generated",
|
|
};
|
|
self.note_system(crate::t!(
|
|
"client_side.auto_fill_transition",
|
|
count = note.auto_filled,
|
|
kind = kind
|
|
));
|
|
}
|
|
}
|
|
for line in crate::output_render::render_structure(&result.description) {
|
|
self.note_system(line);
|
|
}
|
|
self.current_table = Some(result.description);
|
|
}
|
|
|
|
fn handle_dsl_delete_success(&mut self, command: &Command, result: &DeleteResult) {
|
|
self.note_ok_summary(command);
|
|
self.note_system(crate::t!("ok.rows_deleted", count = result.rows_affected));
|
|
for effect in &result.cascade {
|
|
self.note_system(render_cascade_effect(effect));
|
|
}
|
|
// A `RETURNING` clause (ADR-0033 §5, 3g) carries the deleted
|
|
// rows; the cascade summary above surfaces alongside them. A
|
|
// column-less result (the DSL `delete` and SQL `DELETE`
|
|
// without RETURNING) is skipped, exactly as for UPDATE.
|
|
if !result.data.columns.is_empty() {
|
|
for line in crate::output_render::render_data_table(&result.data) {
|
|
self.note_system(line);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_dsl_failure(
|
|
&mut self,
|
|
command: &Command,
|
|
error: crate::db::DbError,
|
|
facts: crate::friendly::FailureContext,
|
|
) {
|
|
// Render through the friendly-error layer (ADR-0019).
|
|
// The translator picks operation-tailored wording from
|
|
// the catalog and applies the user's current verbosity.
|
|
// `facts` carries schema-resolved enrichment (parent
|
|
// tables, attempted values, pinpointed rows) the
|
|
// runtime built before posting the event.
|
|
let ctx = self.build_translate_context(command, facts);
|
|
let rendered = crate::friendly::translate_error(&error, &ctx).render();
|
|
warn!(
|
|
verb = command.verb(),
|
|
error = %rendered,
|
|
"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.
|
|
// `note_error` splits on newlines internally — refusal
|
|
// diagnostics from `change column …` (ADR-0017 §7) flow
|
|
// through as a multi-line bordered table.
|
|
self.note_error(crate::t!(
|
|
"dsl.failed",
|
|
verb = command.verb(),
|
|
subject = command.display_subject(),
|
|
rendered = rendered
|
|
));
|
|
}
|
|
|
|
/// Construct a [`TranslateContext`] by combining the
|
|
/// runtime-supplied [`FailureContext`] (schema-resolved
|
|
/// facts) with the operation derived from the originating
|
|
/// [`Command`] and the App's current verbosity.
|
|
///
|
|
/// Schema-resolved facts win over Command-derived
|
|
/// fallbacks where the runtime supplied them — typically
|
|
/// the runtime knows more (the FK-relationship lookup
|
|
/// produces `parent_table` that the Command alone can't
|
|
/// reveal).
|
|
fn build_translate_context(
|
|
&self,
|
|
command: &Command,
|
|
facts: crate::friendly::FailureContext,
|
|
) -> crate::friendly::TranslateContext {
|
|
use crate::dsl::{Command as C, IndexSelector, RelationshipSelector};
|
|
use crate::friendly::{Operation, TranslateContext};
|
|
let (operation, fallback_table, fallback_column) = match command {
|
|
C::CreateTable { name, .. } => (Operation::CreateTable, Some(name.as_str()), None),
|
|
C::SqlCreateTable { name, .. } => {
|
|
(Operation::CreateTable, Some(name.as_str()), None)
|
|
}
|
|
C::DropTable { name } => (Operation::DropTable, Some(name.as_str()), None),
|
|
C::SqlDropTable { name, .. } => (Operation::DropTable, Some(name.as_str()), None),
|
|
C::AddColumn { table, column, .. } => (
|
|
Operation::AddColumn,
|
|
Some(table.as_str()),
|
|
Some(column.as_str()),
|
|
),
|
|
C::DropColumn { table, column, .. } => (
|
|
Operation::DropColumn,
|
|
Some(table.as_str()),
|
|
Some(column.as_str()),
|
|
),
|
|
C::RenameColumn { table, .. } => (Operation::RenameColumn, Some(table.as_str()), None),
|
|
C::ChangeColumnType { table, column, .. } => (
|
|
Operation::ChangeColumnType,
|
|
Some(table.as_str()),
|
|
Some(column.as_str()),
|
|
),
|
|
C::AddRelationship {
|
|
parent_table,
|
|
parent_column,
|
|
..
|
|
} => (
|
|
Operation::AddRelationship,
|
|
Some(parent_table.as_str()),
|
|
Some(parent_column.as_str()),
|
|
),
|
|
C::DropRelationship { selector } => match selector {
|
|
RelationshipSelector::Endpoints {
|
|
parent_table,
|
|
parent_column,
|
|
..
|
|
} => (
|
|
Operation::DropRelationship,
|
|
Some(parent_table.as_str()),
|
|
Some(parent_column.as_str()),
|
|
),
|
|
RelationshipSelector::Named { .. } => (Operation::DropRelationship, None, None),
|
|
},
|
|
C::AddIndex { table, .. } => (Operation::AddIndex, Some(table.as_str()), None),
|
|
C::AddConstraint { table, column, .. } => (
|
|
Operation::AddConstraint,
|
|
Some(table.as_str()),
|
|
Some(column.as_str()),
|
|
),
|
|
C::DropConstraint { table, column, .. } => (
|
|
Operation::DropConstraint,
|
|
Some(table.as_str()),
|
|
Some(column.as_str()),
|
|
),
|
|
C::DropIndex { selector } => match selector {
|
|
IndexSelector::Columns { table, .. } => {
|
|
(Operation::DropIndex, Some(table.as_str()), None)
|
|
}
|
|
IndexSelector::Named { .. } => (Operation::DropIndex, None, None),
|
|
},
|
|
C::Insert { table, .. } => (Operation::Insert, Some(table.as_str()), None),
|
|
C::Update { table, .. } => (Operation::Update, Some(table.as_str()), None),
|
|
C::Delete { table, .. } => (Operation::Delete, Some(table.as_str()), None),
|
|
C::ShowData { name, .. } | C::ShowTable { name } => {
|
|
(Operation::Query, Some(name.as_str()), None)
|
|
}
|
|
// A SQL `SELECT` carries only its statement text —
|
|
// no single table name to fall back on. A query
|
|
// failure routes through `Operation::Query`.
|
|
C::Select { .. } => (Operation::Query, None, None),
|
|
// A SQL `INSERT` (ADR-0033) — route engine errors
|
|
// (FK / UNIQUE / NOT NULL) through the insert operation
|
|
// with the parsed target table.
|
|
C::SqlInsert { target_table, .. } => {
|
|
(Operation::Insert, Some(target_table.as_str()), None)
|
|
}
|
|
// A SQL `UPDATE` (ADR-0033 §2) — route engine errors
|
|
// through the update operation with the parsed target.
|
|
C::SqlUpdate { target_table, .. } => {
|
|
(Operation::Update, Some(target_table.as_str()), None)
|
|
}
|
|
// A SQL `DELETE` (ADR-0033 §1/§7) — route engine errors
|
|
// (e.g. an FK violation with no cascade) through the
|
|
// delete operation with the parsed target.
|
|
C::SqlDelete { target_table, .. } => {
|
|
(Operation::Delete, Some(target_table.as_str()), None)
|
|
}
|
|
C::Replay { .. } => (Operation::Replay, None, None),
|
|
// An `explain` failure (e.g. unknown table) is best
|
|
// described by the wrapped query it failed to plan.
|
|
C::Explain { query } => {
|
|
return self.build_translate_context(query, facts);
|
|
}
|
|
// App-lifecycle commands never reach this path —
|
|
// `dispatch_input` routes them through
|
|
// `dispatch_app_command` before the DSL execution
|
|
// pipeline that this context builder feeds.
|
|
C::App(_) => unreachable!(
|
|
"App commands are dispatched before reaching dsl execution"
|
|
),
|
|
};
|
|
|
|
TranslateContext {
|
|
operation: Some(operation),
|
|
table: facts
|
|
.table
|
|
.or_else(|| fallback_table.map(str::to_string)),
|
|
column: facts
|
|
.column
|
|
.or_else(|| fallback_column.map(str::to_string)),
|
|
child_table: facts.child_table,
|
|
parent_table: facts.parent_table,
|
|
parent_column: facts.parent_column,
|
|
src_type: None,
|
|
target_type: None,
|
|
value: facts.value,
|
|
diagnostic_table: facts.diagnostic_table,
|
|
check_rule: facts.check_rule,
|
|
verbosity: self.messages_verbosity,
|
|
}
|
|
}
|
|
|
|
/// Parse the argument tail of an `import` command and
|
|
/// return the corresponding `Action::Import`.
|
|
///
|
|
/// Grammar: `import <zip-path> [as <target>]`. The
|
|
/// separator is the literal ` as ` (whitespace + "as" +
|
|
/// whitespace) so a zip path containing the substring
|
|
/// "as" is fine — the separator only matches when
|
|
/// surrounded by spaces. `split_once` is used (first
|
|
/// occurrence wins), which is the natural reading.
|
|
/// Dispatch for the `save` and `save as` commands.
|
|
///
|
|
/// `save` on a temp project is identical to `save as`
|
|
/// (prompts for a target). `save` on a named project is a
|
|
/// no-op with a friendly hint, since auto-save guarantees
|
|
/// the named project is already persistent (ADR-0015 §11).
|
|
fn handle_save_command(&mut self, force_save_as: bool) -> Vec<Action> {
|
|
if !force_save_as && !self.project_is_temp {
|
|
self.note_system(crate::t!("save.already_saved"));
|
|
return Vec::new();
|
|
}
|
|
let title = if force_save_as {
|
|
crate::t!("save.title_as")
|
|
} else {
|
|
crate::t!("save.title_save")
|
|
};
|
|
self.modal = Some(Modal::PathEntry(PathEntryModal {
|
|
title,
|
|
prompt: crate::t!("save.path_prompt"),
|
|
input: String::new(),
|
|
cursor: 0,
|
|
purpose: PathEntryPurpose::SaveAs,
|
|
}));
|
|
Vec::new()
|
|
}
|
|
|
|
/// Route a keypress through whichever modal is active.
|
|
///
|
|
/// Each modal owns its own tiny state machine. On
|
|
/// confirmation, the modal yields one or more `Action`s
|
|
/// for the runtime to enact. On dismissal it simply
|
|
/// closes itself.
|
|
fn handle_modal_key(&mut self, key: KeyEvent) -> Vec<Action> {
|
|
let Some(modal) = self.modal.clone() else {
|
|
return Vec::new();
|
|
};
|
|
match modal {
|
|
Modal::RebuildConfirm(_) => self.handle_rebuild_confirm_key(key),
|
|
Modal::PathEntry(state) => self.handle_path_entry_key(key, state),
|
|
Modal::LoadPicker(state) => self.handle_load_picker_key(key, state),
|
|
Modal::UndoConfirm(state) => self.handle_undo_confirm_key(key, &state),
|
|
}
|
|
}
|
|
|
|
fn handle_undo_confirm_key(&mut self, key: KeyEvent, state: &UndoConfirmModal) -> Vec<Action> {
|
|
match key.code {
|
|
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
|
self.modal = None;
|
|
if state.is_redo {
|
|
vec![Action::Redo]
|
|
} else {
|
|
vec![Action::Undo]
|
|
}
|
|
}
|
|
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
|
|
self.modal = None;
|
|
if state.is_redo {
|
|
self.note_system(crate::t!("modal.redo_cancelled"));
|
|
} else {
|
|
self.note_system(crate::t!("modal.undo_cancelled"));
|
|
}
|
|
Vec::new()
|
|
}
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn handle_rebuild_confirm_key(&mut self, key: KeyEvent) -> Vec<Action> {
|
|
match key.code {
|
|
KeyCode::Char('y') | KeyCode::Char('Y') => {
|
|
self.modal = None;
|
|
vec![Action::Rebuild {
|
|
source: "rebuild".to_string(),
|
|
}]
|
|
}
|
|
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
|
|
self.modal = None;
|
|
self.note_system(crate::t!("modal.rebuild_cancelled"));
|
|
Vec::new()
|
|
}
|
|
_ => Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn handle_path_entry_key(
|
|
&mut self,
|
|
key: KeyEvent,
|
|
mut state: PathEntryModal,
|
|
) -> Vec<Action> {
|
|
match key.code {
|
|
KeyCode::Esc => {
|
|
self.modal = None;
|
|
self.note_system(crate::t!(
|
|
"modal.generic_cancelled",
|
|
title = state.title.to_lowercase()
|
|
));
|
|
Vec::new()
|
|
}
|
|
KeyCode::Enter => {
|
|
let target = state.input.trim().to_string();
|
|
if target.is_empty() {
|
|
self.note_error(crate::t!("modal.path_entry_empty_name"));
|
|
self.modal = Some(Modal::PathEntry(state));
|
|
return Vec::new();
|
|
}
|
|
self.modal = None;
|
|
match state.purpose {
|
|
PathEntryPurpose::SaveAs => vec![Action::SaveAs {
|
|
target,
|
|
source: "save as".to_string(),
|
|
}],
|
|
PathEntryPurpose::LoadByPath => vec![Action::LoadProject {
|
|
path: std::path::PathBuf::from(target),
|
|
source: "load".to_string(),
|
|
}],
|
|
}
|
|
}
|
|
KeyCode::Char(c) => {
|
|
state.input.insert(state.cursor, c);
|
|
state.cursor += c.len_utf8();
|
|
self.modal = Some(Modal::PathEntry(state));
|
|
Vec::new()
|
|
}
|
|
KeyCode::Backspace => {
|
|
if state.cursor > 0 {
|
|
let before = state.input[..state.cursor].chars().next_back();
|
|
if let Some(c) = before {
|
|
let new_cursor = state.cursor - c.len_utf8();
|
|
state.input.drain(new_cursor..state.cursor);
|
|
state.cursor = new_cursor;
|
|
}
|
|
}
|
|
self.modal = Some(Modal::PathEntry(state));
|
|
Vec::new()
|
|
}
|
|
KeyCode::Left => {
|
|
if state.cursor > 0
|
|
&& let Some(c) = state.input[..state.cursor].chars().next_back()
|
|
{
|
|
state.cursor -= c.len_utf8();
|
|
}
|
|
self.modal = Some(Modal::PathEntry(state));
|
|
Vec::new()
|
|
}
|
|
KeyCode::Right => {
|
|
if state.cursor < state.input.len()
|
|
&& let Some(c) = state.input[state.cursor..].chars().next()
|
|
{
|
|
state.cursor += c.len_utf8();
|
|
}
|
|
self.modal = Some(Modal::PathEntry(state));
|
|
Vec::new()
|
|
}
|
|
KeyCode::Home => {
|
|
state.cursor = 0;
|
|
self.modal = Some(Modal::PathEntry(state));
|
|
Vec::new()
|
|
}
|
|
KeyCode::End => {
|
|
state.cursor = state.input.len();
|
|
self.modal = Some(Modal::PathEntry(state));
|
|
Vec::new()
|
|
}
|
|
_ => {
|
|
self.modal = Some(Modal::PathEntry(state));
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_load_picker_key(
|
|
&mut self,
|
|
key: KeyEvent,
|
|
mut state: LoadPickerModal,
|
|
) -> Vec<Action> {
|
|
match &mut state.sub_mode {
|
|
LoadPickerSubMode::List => match key.code {
|
|
KeyCode::Esc => {
|
|
self.modal = None;
|
|
self.note_system(crate::t!("modal.load_cancelled"));
|
|
Vec::new()
|
|
}
|
|
KeyCode::Up => {
|
|
if state.selected > 0 {
|
|
state.selected -= 1;
|
|
}
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
KeyCode::Down => {
|
|
if state.selected + 1 < state.entries.len() {
|
|
state.selected += 1;
|
|
}
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
KeyCode::Enter => {
|
|
if let Some(entry) = state.entries.get(state.selected).cloned() {
|
|
self.modal = None;
|
|
return vec![Action::LoadProject {
|
|
path: entry.path,
|
|
source: "load".to_string(),
|
|
}];
|
|
}
|
|
self.note_error(crate::t!("modal.load_picker_nothing"));
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
KeyCode::Char('b') | KeyCode::Char('B') => {
|
|
state.sub_mode = LoadPickerSubMode::PathEntry {
|
|
input: String::new(),
|
|
cursor: 0,
|
|
};
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
_ => {
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
},
|
|
LoadPickerSubMode::PathEntry { input, cursor } => match key.code {
|
|
KeyCode::Esc => {
|
|
state.sub_mode = LoadPickerSubMode::List;
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
KeyCode::Enter => {
|
|
let target = input.trim().to_string();
|
|
if target.is_empty() {
|
|
self.note_error(crate::t!("modal.path_entry_empty_path"));
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
return Vec::new();
|
|
}
|
|
self.modal = None;
|
|
vec![Action::LoadProject {
|
|
path: std::path::PathBuf::from(target),
|
|
source: "load".to_string(),
|
|
}]
|
|
}
|
|
KeyCode::Char(c) => {
|
|
input.insert(*cursor, c);
|
|
*cursor += c.len_utf8();
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
KeyCode::Backspace => {
|
|
if *cursor > 0
|
|
&& let Some(c) = input[..*cursor].chars().next_back()
|
|
{
|
|
let new_cursor = *cursor - c.len_utf8();
|
|
input.drain(new_cursor..*cursor);
|
|
*cursor = new_cursor;
|
|
}
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
KeyCode::Left => {
|
|
if *cursor > 0
|
|
&& let Some(c) = input[..*cursor].chars().next_back()
|
|
{
|
|
*cursor -= c.len_utf8();
|
|
}
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
KeyCode::Right => {
|
|
if *cursor < input.len()
|
|
&& let Some(c) = input[*cursor..].chars().next()
|
|
{
|
|
*cursor += c.len_utf8();
|
|
}
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
_ => {
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Note the list of currently-supported commands to the
|
|
/// output panel.
|
|
///
|
|
/// Assembled from the command REGISTRY (ADR-0024 §help_id):
|
|
/// the framing (`help.intro`, `help.dsl_section`,
|
|
/// `help.types_reference`) comes from the catalog, and each
|
|
/// command's body is the catalog entry named by its
|
|
/// `help_id`. A newly-registered command appears here
|
|
/// automatically — no edit to this function or a hand-kept
|
|
/// list. Each catalog line becomes its own `OutputLine` so
|
|
/// the scroll-position math (one logical line = one display
|
|
/// row) stays accurate per the renderer's invariant.
|
|
fn note_help(&mut self) {
|
|
use crate::dsl::grammar::REGISTRY;
|
|
|
|
let mut lines: Vec<String> = Vec::new();
|
|
lines.push(crate::t!("help.intro"));
|
|
// REGISTRY is ordered app-commands first; emit the
|
|
// "DSL data commands" sub-header at the first command
|
|
// whose help_id leaves the `app.` namespace.
|
|
let mut dsl_header_done = false;
|
|
for (command, _category) in REGISTRY {
|
|
let Some(help_id) = command.help_id else {
|
|
continue;
|
|
};
|
|
if !dsl_header_done && !help_id.starts_with("app.") {
|
|
lines.push(crate::t!("help.dsl_section"));
|
|
dsl_header_done = true;
|
|
}
|
|
let key = format!("help.{help_id}");
|
|
let body = crate::friendly::translate(&key, &[]);
|
|
lines.extend(body.lines().map(str::to_string));
|
|
}
|
|
lines.extend(
|
|
crate::t!("help.types_reference")
|
|
.lines()
|
|
.map(str::to_string),
|
|
);
|
|
for line in lines {
|
|
self.note_system(line);
|
|
}
|
|
}
|
|
|
|
fn handle_messages_command(&mut self, raw: &str) {
|
|
let arg = raw.strip_prefix("messages").unwrap_or(raw).trim();
|
|
match arg {
|
|
"" => {
|
|
let current = match self.messages_verbosity {
|
|
crate::friendly::Verbosity::Short => "short",
|
|
crate::friendly::Verbosity::Verbose => "verbose",
|
|
};
|
|
self.note_system(crate::t!("messages.show", current = current));
|
|
}
|
|
"short" => {
|
|
self.messages_verbosity = crate::friendly::Verbosity::Short;
|
|
self.note_system(crate::t!("messages.set_short"));
|
|
}
|
|
"verbose" => {
|
|
self.messages_verbosity = crate::friendly::Verbosity::Verbose;
|
|
self.note_system(crate::t!("messages.set_verbose"));
|
|
}
|
|
other => self.note_error(crate::t!("messages.unknown", value = other)),
|
|
}
|
|
}
|
|
|
|
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(crate::t!("mode.set_simple"));
|
|
}
|
|
"advanced" => {
|
|
self.mode = Mode::Advanced;
|
|
self.note_system(crate::t!("mode.set_advanced"));
|
|
}
|
|
"" => self.note_error(crate::t!("mode.usage")),
|
|
other => self.note_error(crate::t!("mode.unknown", value = other)),
|
|
}
|
|
}
|
|
|
|
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,
|
|
styled_runs: None,
|
|
});
|
|
return;
|
|
}
|
|
for line in text.split('\n') {
|
|
self.push_output(OutputLine {
|
|
text: line.to_string(),
|
|
kind,
|
|
mode_at_submission: self.mode,
|
|
styled_runs: None,
|
|
});
|
|
}
|
|
}
|
|
|
|
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 => crate::t!("parse.empty"),
|
|
}
|
|
}
|
|
|
|
/// Compose the third block of a parse-error rendering
|
|
/// (ADR-0021 §2): "usage: …" when at least one
|
|
/// command-entry keyword was consumed, otherwise an
|
|
/// "available commands:" fallback (§5).
|
|
///
|
|
/// Driven by the walker registry (ADR-0024 §architecture).
|
|
/// If the input's first identifier-shape token is a registered
|
|
/// `CommandNode` entry word, the node's `usage_ids` slice
|
|
/// renders every catalog template — multi-form families like
|
|
/// `drop` show every variant. Otherwise the fallback lists every
|
|
/// entry keyword alphabetically.
|
|
fn render_usage_block(input: &str) -> String {
|
|
// A multi-form command that has committed to a form
|
|
// (`add index …`) shows only that form's usage; a bare
|
|
// multi-form entry word (`add`) shows the whole family.
|
|
let catalog_keys: Vec<&'static str> =
|
|
crate::dsl::grammar::usage_key_for_input(input)
|
|
.map(|key| vec![key])
|
|
.or_else(|| {
|
|
crate::dsl::grammar::usage_keys_for_input(input)
|
|
.map(|(_word, all)| all.to_vec())
|
|
})
|
|
.unwrap_or_default();
|
|
if !catalog_keys.is_empty() {
|
|
let mut out = String::from("usage:");
|
|
for key in catalog_keys {
|
|
let template = crate::friendly::translate(key, &[]);
|
|
for line in template.lines() {
|
|
out.push('\n');
|
|
out.push_str(" ");
|
|
out.push_str(line);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
// No-prefix fallback. Each entry word renders backticked
|
|
// verbatim (replaces the old `parse.token.keyword.*` catalog
|
|
// lookup; ADR-0024 §cleanup-pass §F prescribes the same
|
|
// wrapping helper).
|
|
let names: Vec<String> = crate::dsl::grammar::entry_words_alphabetised()
|
|
.into_iter()
|
|
.map(|w| format!("`{w}`"))
|
|
.collect();
|
|
crate::t!(
|
|
"parse.available_commands",
|
|
commands = names.join(", ")
|
|
)
|
|
}
|
|
|
|
fn render_cascade_effect(effect: &CascadeEffect) -> String {
|
|
use crate::dsl::ReferentialAction;
|
|
let action_key = match effect.action {
|
|
ReferentialAction::Cascade => "db.cascade.action_deleted",
|
|
ReferentialAction::SetNull => "db.cascade.action_set_null",
|
|
ReferentialAction::Restrict | ReferentialAction::NoAction => {
|
|
"db.cascade.action_blocked"
|
|
}
|
|
};
|
|
crate::t!(
|
|
"db.cascade.summary",
|
|
count = effect.rows_changed,
|
|
action = crate::friendly::translate(action_key, &[]),
|
|
child_table = effect.child_table,
|
|
rel = effect.relationship_name,
|
|
on_delete = effect.action,
|
|
)
|
|
}
|
|
|
|
#[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))
|
|
}
|
|
|
|
/// Render every error-kind output line, one per line, for
|
|
/// failed-assertion error messages.
|
|
fn error_lines(app: &App) -> String {
|
|
app.output
|
|
.iter()
|
|
.filter(|l| l.kind == OutputKind::Error)
|
|
.map(|l| l.text.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
}
|
|
|
|
fn output_contains(app: &App, needle: &str) -> bool {
|
|
app.output.iter().any(|l| l.text.contains(needle))
|
|
}
|
|
|
|
// ---- undo / redo dispatch + modal (ADR-0006 Amendment 1) ----
|
|
|
|
#[test]
|
|
fn undo_command_emits_prepare_undo() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "undo");
|
|
assert_eq!(submit(&mut app), vec![Action::PrepareUndo]);
|
|
}
|
|
|
|
#[test]
|
|
fn redo_command_emits_prepare_redo() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "redo");
|
|
assert_eq!(submit(&mut app), vec![Action::PrepareRedo]);
|
|
}
|
|
|
|
#[test]
|
|
fn undo_when_disabled_notes_and_emits_no_action() {
|
|
let mut app = App::new();
|
|
app.undo_enabled = false;
|
|
type_str(&mut app, "undo");
|
|
let actions = submit(&mut app);
|
|
assert!(actions.is_empty(), "no action when disabled: {actions:?}");
|
|
assert!(
|
|
output_contains(&app, "turned off"),
|
|
"expected a 'turned off' note, output: {:?}",
|
|
app.output
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn undo_prepared_opens_modal_naming_the_command() {
|
|
let mut app = App::new();
|
|
let actions = app.update(AppEvent::UndoPrepared {
|
|
command: "delete from Customers where id = 2".to_string(),
|
|
timestamp: "2026-05-24T10:00:00Z".to_string(),
|
|
is_redo: false,
|
|
});
|
|
assert!(actions.is_empty());
|
|
match &app.modal {
|
|
Some(Modal::UndoConfirm(m)) => {
|
|
assert_eq!(m.command, "delete from Customers where id = 2");
|
|
assert!(!m.is_redo);
|
|
}
|
|
other => panic!("expected UndoConfirm modal, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn undo_modal_y_confirms_and_emits_undo() {
|
|
let mut app = App::new();
|
|
app.update(AppEvent::UndoPrepared {
|
|
command: "x".to_string(),
|
|
timestamp: "t".to_string(),
|
|
is_redo: false,
|
|
});
|
|
let actions = app.update(key(KeyCode::Char('y')));
|
|
assert_eq!(actions, vec![Action::Undo]);
|
|
assert!(app.modal.is_none(), "modal closes on confirm");
|
|
}
|
|
|
|
#[test]
|
|
fn redo_modal_y_emits_redo() {
|
|
let mut app = App::new();
|
|
app.update(AppEvent::UndoPrepared {
|
|
command: "x".to_string(),
|
|
timestamp: "t".to_string(),
|
|
is_redo: true,
|
|
});
|
|
assert_eq!(app.update(key(KeyCode::Char('y'))), vec![Action::Redo]);
|
|
}
|
|
|
|
#[test]
|
|
fn undo_modal_esc_cancels_without_action() {
|
|
let mut app = App::new();
|
|
app.update(AppEvent::UndoPrepared {
|
|
command: "x".to_string(),
|
|
timestamp: "t".to_string(),
|
|
is_redo: false,
|
|
});
|
|
let actions = app.update(key(KeyCode::Esc));
|
|
assert!(actions.is_empty());
|
|
assert!(app.modal.is_none());
|
|
assert!(output_contains(&app, "cancelled"));
|
|
}
|
|
|
|
#[test]
|
|
fn undo_unavailable_notes_nothing_to_undo() {
|
|
let mut app = App::new();
|
|
let actions = app.update(AppEvent::UndoUnavailable { is_redo: false });
|
|
assert!(actions.is_empty());
|
|
assert!(output_contains(&app, "nothing to undo"));
|
|
}
|
|
|
|
#[test]
|
|
fn undo_succeeded_closes_modal_and_notes_command() {
|
|
let mut app = App::new();
|
|
app.modal = Some(Modal::UndoConfirm(UndoConfirmModal {
|
|
command: "x".to_string(),
|
|
timestamp: "t".to_string(),
|
|
is_redo: false,
|
|
}));
|
|
app.update(AppEvent::UndoSucceeded {
|
|
command: "delete from T --all-rows".to_string(),
|
|
is_redo: false,
|
|
});
|
|
assert!(app.modal.is_none());
|
|
assert!(output_contains(&app, "delete from T --all-rows"));
|
|
}
|
|
|
|
// ---- ADR-0022 stage 8: Tab completion + Esc/Backspace undo ----
|
|
|
|
#[test]
|
|
fn tab_with_unique_candidate_inserts_with_space_and_no_memo() {
|
|
// Single-candidate path: insert "<text> ", no memo.
|
|
// Stage-8 follow-up #2 (testing-round-2): no memo
|
|
// for unique completions so subsequent Tab fresh-
|
|
// computes at the new cursor.
|
|
let mut app = App::new();
|
|
type_str(&mut app, "cre");
|
|
let actions = app.update(key(KeyCode::Tab));
|
|
assert!(actions.is_empty());
|
|
assert_eq!(app.input, "create ");
|
|
assert_eq!(app.input_cursor, 7);
|
|
assert!(app.last_completion.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn tab_at_word_boundary_inserts_next_expected_keyword() {
|
|
// `create ` → expects only `table`. Single candidate;
|
|
// insert "table " with space, no memo.
|
|
let mut app = App::new();
|
|
type_str(&mut app, "create ");
|
|
let actions = app.update(key(KeyCode::Tab));
|
|
assert!(actions.is_empty());
|
|
assert_eq!(app.input, "create table ");
|
|
assert!(app.last_completion.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn tab_with_no_candidates_is_a_noop() {
|
|
// After `create table T with pk` the parser succeeds —
|
|
// no candidates, Tab does nothing.
|
|
let mut app = App::new();
|
|
type_str(&mut app, "create table T with pk");
|
|
let len = app.input.len();
|
|
app.update(key(KeyCode::Tab));
|
|
assert_eq!(app.input.len(), len);
|
|
assert!(app.last_completion.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn tab_with_multi_candidates_inserts_without_space_and_creates_memo() {
|
|
// Multi-candidate path: insert WITHOUT trailing space
|
|
// so the user can press space to commit. Memo carries
|
|
// the candidate list for cycling.
|
|
let mut app = App::new();
|
|
type_str(&mut app, "show ");
|
|
app.update(key(KeyCode::Tab));
|
|
assert_eq!(app.input, "show data");
|
|
assert!(app.last_completion.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn tab_cycles_forward_through_multi_candidate_set() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "show ");
|
|
app.update(key(KeyCode::Tab));
|
|
assert_eq!(app.input, "show data");
|
|
app.update(key(KeyCode::Tab));
|
|
assert_eq!(app.input, "show table");
|
|
// Wrap-around.
|
|
app.update(key(KeyCode::Tab));
|
|
assert_eq!(app.input, "show data");
|
|
}
|
|
|
|
#[test]
|
|
fn shift_tab_cycles_backward_starting_from_last() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "show ");
|
|
app.update(key(KeyCode::BackTab));
|
|
assert_eq!(app.input, "show table");
|
|
app.update(key(KeyCode::BackTab));
|
|
assert_eq!(app.input, "show data");
|
|
app.update(key(KeyCode::BackTab));
|
|
assert_eq!(app.input, "show table");
|
|
}
|
|
|
|
#[test]
|
|
fn space_after_multi_candidate_tab_commits_the_choice() {
|
|
// The natural commit gesture for multi-candidate Tab:
|
|
// press space. Memo clears (any non-Tab key clears),
|
|
// space is inserted normally → "show data " ready
|
|
// for the next position.
|
|
let mut app = App::new();
|
|
type_str(&mut app, "show ");
|
|
app.update(key(KeyCode::Tab)); // → "show data" (no space)
|
|
type_str(&mut app, " ");
|
|
assert_eq!(app.input, "show data ");
|
|
assert!(app.last_completion.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn esc_after_multi_tab_restores_original_in_one_keystroke() {
|
|
// Multi-candidate Tab leaves a memo; Esc undoes the
|
|
// whole insertion regardless of cycle depth.
|
|
let mut app = App::new();
|
|
type_str(&mut app, "show ");
|
|
app.update(key(KeyCode::Tab)); // → "show data"
|
|
app.update(key(KeyCode::Esc));
|
|
assert_eq!(app.input, "show ");
|
|
assert!(app.last_completion.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn backspace_after_multi_tab_restores_original_in_one_keystroke() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "show ");
|
|
app.update(key(KeyCode::Tab));
|
|
app.update(key(KeyCode::Backspace));
|
|
assert_eq!(app.input, "show ");
|
|
assert!(app.last_completion.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn esc_after_multiple_tabs_restores_original_state_not_previous_cycle() {
|
|
// Tab Tab Tab cycled through three candidates; Esc
|
|
// restores the pre-completion state (no insertion at
|
|
// all), not the previous cycle.
|
|
let mut app = App::new();
|
|
type_str(&mut app, "drop ");
|
|
app.update(key(KeyCode::Tab)); // column
|
|
app.update(key(KeyCode::Tab)); // relationship
|
|
app.update(key(KeyCode::Tab)); // table
|
|
assert_eq!(app.input, "drop table");
|
|
app.update(key(KeyCode::Esc));
|
|
assert_eq!(app.input, "drop ");
|
|
}
|
|
|
|
#[test]
|
|
fn typing_a_letter_clears_the_completion_memo() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "show ");
|
|
app.update(key(KeyCode::Tab));
|
|
assert!(app.last_completion.is_some());
|
|
// Typing any non-Tab key clears the memo. The
|
|
// inserted text stays in the buffer.
|
|
type_str(&mut app, "x");
|
|
assert_eq!(app.input, "show datax");
|
|
assert!(app.last_completion.is_none());
|
|
// Backspace now does its normal job — delete one char.
|
|
app.update(key(KeyCode::Backspace));
|
|
assert_eq!(app.input, "show data");
|
|
}
|
|
|
|
#[test]
|
|
fn cursor_movement_clears_the_completion_memo() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, "show ");
|
|
app.update(key(KeyCode::Tab));
|
|
// Cursor movement clears the memo. After this,
|
|
// Esc / Backspace behave normally — no whole-span
|
|
// undo.
|
|
app.update(key(KeyCode::Home));
|
|
assert!(app.last_completion.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn unique_tab_then_another_unique_tab_chains_naturally() {
|
|
// Stage-8 follow-up #2 (testing-round-2): the
|
|
// single-candidate-no-memo design lets the user chain
|
|
// Tabs through unique completions without getting
|
|
// stuck. From "cr", Tab → "create ", Tab → "create
|
|
// table ". (Round 5 added the app-lifecycle commands —
|
|
// single-letter prefixes like `i` are now ambiguous
|
|
// (`insert` vs. `import`), so the test starts from a
|
|
// disambiguated two-letter prefix.)
|
|
let mut app = App::new();
|
|
type_str(&mut app, "cr");
|
|
app.update(key(KeyCode::Tab));
|
|
assert_eq!(app.input, "create ");
|
|
app.update(key(KeyCode::Tab));
|
|
assert_eq!(app.input, "create table ");
|
|
assert!(app.last_completion.is_none());
|
|
}
|
|
|
|
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,
|
|
unique: false,
|
|
default: None,
|
|
check: None,
|
|
}],
|
|
outbound_relationships: Vec::new(),
|
|
inbound_relationships: Vec::new(),
|
|
indexes: 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.len(), 1);
|
|
let Action::ExecuteDsl { command, .. } = &actions[0] else {
|
|
panic!("expected ExecuteDsl, got {:?}", actions[0]);
|
|
};
|
|
assert_eq!(
|
|
command,
|
|
&Command::CreateTable {
|
|
name: "Customers".to_string(),
|
|
columns: vec![crate::dsl::ColumnSpec::new("id", 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);
|
|
// A definite parse error journals `err` (ADR-0034) and does
|
|
// not dispatch a command to the worker.
|
|
assert!(
|
|
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
|
|
"expected only a JournalFailure, no dispatch; got {actions:?}",
|
|
);
|
|
// Parse-error rendering is now multi-line (ADR-0021):
|
|
// caret + "parse error: …" + "usage: …" — the test
|
|
// checks that some error line mentions `with pk`.
|
|
let mentions_with_pk = app
|
|
.output
|
|
.iter()
|
|
.any(|l| l.kind == OutputKind::Error && l.text.contains("with pk"));
|
|
assert!(
|
|
mentions_with_pk,
|
|
"no error line mentions `with pk`; output:\n{}",
|
|
error_lines(&app),
|
|
);
|
|
}
|
|
|
|
#[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!(
|
|
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
|
|
"a definite parse error journals err without dispatching; got {actions:?}",
|
|
);
|
|
let has_parse_error = app
|
|
.output
|
|
.iter()
|
|
.any(|l| l.kind == OutputKind::Error && l.text.starts_with("parse error"));
|
|
assert!(
|
|
has_parse_error,
|
|
"no error line starts with `parse error`; output:\n{}",
|
|
error_lines(&app),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn simple_mode_submit_of_sql_construct_appends_advanced_pointer() {
|
|
// ADR-0033 Amendment 3: submitting a line in simple mode that
|
|
// fails as DSL but would run as SQL in advanced mode appends
|
|
// the `advanced_mode.also_valid_sql` pointer to the parse
|
|
// error — keeping the DSL detail and pointing at advanced
|
|
// mode. Multi-row VALUES is a definite DSL error and valid SQL
|
|
// (no schema needed).
|
|
let mut app = App::new();
|
|
type_str(&mut app, "insert into T values (1, 2), (3, 4)");
|
|
let actions = submit(&mut app);
|
|
assert!(
|
|
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
|
|
"the bad line journals err but must not dispatch; got {actions:?}",
|
|
);
|
|
let has_pointer = app
|
|
.output
|
|
.iter()
|
|
.any(|l| l.kind == OutputKind::Error && l.text.contains("advanced mode"));
|
|
assert!(
|
|
has_pointer,
|
|
"expected the advanced-mode pointer on submit; output:\n{}",
|
|
error_lines(&app),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn simple_mode_submit_of_pure_dsl_error_has_no_advanced_pointer() {
|
|
// A DSL error that is *not* valid SQL either (unknown command)
|
|
// must not carry the advanced-mode pointer — there is nothing
|
|
// to switch modes for.
|
|
let mut app = App::new();
|
|
type_str(&mut app, "frobulate widgets");
|
|
let _ = submit(&mut app);
|
|
let has_pointer = app
|
|
.output
|
|
.iter()
|
|
.any(|l| l.text.contains("valid as SQL in advanced mode"));
|
|
assert!(!has_pointer, "unknown command must not point at advanced mode");
|
|
}
|
|
|
|
#[test]
|
|
fn enter_in_advanced_mode_dispatches_select_with_advanced_tag() {
|
|
// The pre-ADR-0030 placeholder echoed any advanced-mode
|
|
// input back unexecuted; with the SQL surface live, a
|
|
// `select` in advanced mode runs through `dispatch_dsl`
|
|
// exactly like a DSL command, producing the standard
|
|
// `running: …` echo and an `ExecuteDsl(Command::Select)`
|
|
// action. The mode-tag invariant — that the echo carries
|
|
// the submission's effective mode — is what this test
|
|
// pins down.
|
|
let mut app = App::new();
|
|
app.mode = Mode::Advanced;
|
|
type_str(&mut app, "select 1");
|
|
let actions = submit(&mut app);
|
|
let echoed = app
|
|
.output
|
|
.iter()
|
|
.rfind(|l| l.kind == OutputKind::Echo)
|
|
.unwrap();
|
|
assert_eq!(echoed.mode_at_submission, Mode::Advanced);
|
|
assert!(
|
|
echoed.text.contains("select 1"),
|
|
"echo line carries the input: {:?}",
|
|
echoed.text,
|
|
);
|
|
assert!(
|
|
matches!(
|
|
actions.as_slice(),
|
|
[Action::ExecuteDsl {
|
|
command: Command::Select { .. },
|
|
..
|
|
}],
|
|
),
|
|
"advanced-mode `select` should produce ExecuteDsl(Select); got {actions:?}",
|
|
);
|
|
}
|
|
|
|
#[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);
|
|
// The error surfaces somewhere in the output buffer
|
|
// (could be the caret line, the parse-error detail
|
|
// line, or the usage line). Scan for the friendly
|
|
// "unknown mode" anchor phrase.
|
|
let anywhere = app
|
|
.output
|
|
.iter()
|
|
.any(|l| l.text.contains("unknown mode"));
|
|
assert!(
|
|
anywhere,
|
|
"expected 'unknown mode' somewhere in output: {:?}",
|
|
app.output.iter().map(|l| &l.text).collect::<Vec<_>>(),
|
|
);
|
|
let any_error = app
|
|
.output
|
|
.iter()
|
|
.any(|l| l.kind == OutputKind::Error);
|
|
assert!(any_error, "expected at least one Error line");
|
|
}
|
|
|
|
#[test]
|
|
fn colon_prefix_in_simple_mode_evaluates_as_advanced_one_shot() {
|
|
let mut app = App::new();
|
|
type_str(&mut app, ":select 1");
|
|
let actions = submit(&mut app);
|
|
// The persistent mode is unchanged.
|
|
assert_eq!(app.mode, Mode::Simple);
|
|
let echoed = app
|
|
.output
|
|
.iter()
|
|
.rfind(|l| l.kind == OutputKind::Echo)
|
|
.unwrap();
|
|
// The line ran under the one-shot effective mode, so
|
|
// the echo carries the Advanced tag…
|
|
assert_eq!(echoed.mode_at_submission, Mode::Advanced);
|
|
// …and the `:` is stripped before dispatch (the SQL
|
|
// executed is `select 1`, not `:select 1`).
|
|
assert!(
|
|
echoed.text.contains("select 1") && !echoed.text.contains(":select"),
|
|
"echo carries the stripped input: {:?}",
|
|
echoed.text,
|
|
);
|
|
// The one-shot dispatched the SELECT through the same
|
|
// path as a persistent-advanced submission.
|
|
assert!(
|
|
matches!(
|
|
actions.as_slice(),
|
|
[Action::ExecuteDsl {
|
|
command: Command::Select { .. },
|
|
..
|
|
}],
|
|
),
|
|
"`:select 1` should produce ExecuteDsl(Select); got {actions:?}",
|
|
);
|
|
}
|
|
|
|
#[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::new("id", 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));
|
|
// Some line in the output buffer is the structure
|
|
// table row that contains `id` (followed by border
|
|
// chars on either side).
|
|
assert!(
|
|
app.output.iter().any(|l| l.text.contains("id")),
|
|
"expected `id` somewhere in structure output",
|
|
);
|
|
// Earlier line is the [ok] header.
|
|
assert!(app.output.iter().any(|l| l.text.starts_with("[ok]")));
|
|
}
|
|
|
|
#[test]
|
|
fn explain_success_event_renders_display_sql_and_plan_tree() {
|
|
let mut app = App::new();
|
|
let cmd = Command::Explain {
|
|
query: Box::new(Command::ShowData {
|
|
name: "Customers".to_string(),
|
|
filter: None,
|
|
limit: None,
|
|
}),
|
|
};
|
|
let plan = crate::db::QueryPlan {
|
|
display_sql: "SELECT \"id\" FROM \"Customers\"".to_string(),
|
|
rows: vec![crate::db::ExplainRow {
|
|
id: 2,
|
|
parent: 0,
|
|
detail: "SCAN Customers".to_string(),
|
|
}],
|
|
};
|
|
app.update(AppEvent::DslExplainSucceeded {
|
|
command: cmd,
|
|
plan,
|
|
});
|
|
// `[ok] explain Customers` header.
|
|
assert!(
|
|
app.output.iter().any(|l| l.text.starts_with("[ok]")
|
|
&& l.text.contains("explain")),
|
|
"expected an [ok] explain header",
|
|
);
|
|
// The display SQL and the plan node both reach output.
|
|
assert!(
|
|
app.output
|
|
.iter()
|
|
.any(|l| l.text.contains("SELECT \"id\" FROM \"Customers\"")),
|
|
"expected the display SQL line",
|
|
);
|
|
assert!(
|
|
app.output.iter().any(|l| l.text.contains("SCAN Customers")),
|
|
"expected the plan-tree node",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn replay_command_dispatches_replay_action_not_execute_dsl() {
|
|
// Submitting `replay <path>` must NOT produce an
|
|
// `Action::ExecuteDsl` (otherwise the worker thread
|
|
// would try to execute Replay, which has no semantics
|
|
// there, and history.log would record the replay
|
|
// invocation itself — see ADR-related runtime comments).
|
|
let mut app = App::new();
|
|
type_str(&mut app, "replay history.log");
|
|
let actions = submit(&mut app);
|
|
assert_eq!(actions.len(), 1);
|
|
match &actions[0] {
|
|
Action::Replay { path } => assert_eq!(path, "history.log"),
|
|
other => panic!("expected Action::Replay, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn replay_completed_event_writes_ok_summary() {
|
|
let mut app = App::new();
|
|
app.update(AppEvent::ReplayCompleted {
|
|
path: "seed.commands".to_string(),
|
|
count: 4,
|
|
warnings: Vec::new(),
|
|
});
|
|
let last = app.output.back().unwrap();
|
|
assert_eq!(last.kind, OutputKind::System);
|
|
assert!(last.text.starts_with("[ok] replay"), "{}", last.text);
|
|
assert!(last.text.contains("4 command(s)"), "{}", last.text);
|
|
assert!(last.text.contains("seed.commands"), "{}", last.text);
|
|
}
|
|
|
|
#[test]
|
|
fn replay_completed_event_renders_skip_warnings() {
|
|
// ADR-0034 Amendment 1: `[skip]` warnings (import / nested
|
|
// replay) surface in the output after the summary line.
|
|
let mut app = App::new();
|
|
app.update(AppEvent::ReplayCompleted {
|
|
path: "history.log".to_string(),
|
|
count: 2,
|
|
warnings: vec![
|
|
"[skip] line 3: `import a.zip` — replay does not re-import".to_string(),
|
|
"[skip] line 7: nested `replay x` — its commands were not replayed".to_string(),
|
|
],
|
|
});
|
|
let text: String = app
|
|
.output
|
|
.iter()
|
|
.map(|l| l.text.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(text.contains("[ok] replay"), "summary present:\n{text}");
|
|
assert!(text.contains("import a.zip"), "import skip warning rendered:\n{text}");
|
|
assert!(text.contains("nested `replay x`"), "nested-replay skip warning rendered:\n{text}");
|
|
}
|
|
|
|
#[test]
|
|
fn replay_failed_event_renders_line_number_and_command_echo() {
|
|
let mut app = App::new();
|
|
app.update(AppEvent::ReplayFailed {
|
|
path: "seed.commands".to_string(),
|
|
line_number: 3,
|
|
command: "this is not a command".to_string(),
|
|
error: "parse error: …".to_string(),
|
|
});
|
|
// Two error lines emitted: header with line number,
|
|
// then ` > <command>` echo for context.
|
|
let lines: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
|
|
assert!(
|
|
lines.iter().any(|l| l.contains("at line 3")),
|
|
"missing line-number header in {lines:?}"
|
|
);
|
|
assert!(
|
|
lines.iter().any(|l| l.contains("> this is not a command")),
|
|
"missing command echo in {lines:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn replay_failed_with_line_zero_skips_command_echo() {
|
|
// Line-number 0 is the runtime's signal that file-open
|
|
// itself failed; there's no per-line command to echo.
|
|
let mut app = App::new();
|
|
app.update(AppEvent::ReplayFailed {
|
|
path: "missing.commands".to_string(),
|
|
line_number: 0,
|
|
command: String::new(),
|
|
error: "could not open `missing.commands`: not found".to_string(),
|
|
});
|
|
let lines: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
|
|
assert!(
|
|
lines.iter().any(|l| l.contains("could not open")),
|
|
"missing error in {lines:?}"
|
|
);
|
|
assert!(
|
|
!lines.iter().any(|l| l.contains("at line 0")),
|
|
"should not render `at line 0` header in {lines:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dsl_failure_event_renders_through_friendly_translator() {
|
|
// Synthetic DslFailed carries a structured DbError;
|
|
// the App applies its current verbosity and routes the
|
|
// payload through `friendly::translate_error` (ADR-0019).
|
|
let mut app = App::new();
|
|
let cmd = Command::DropTable {
|
|
name: "Ghost".to_string(),
|
|
};
|
|
app.update(AppEvent::DslFailed {
|
|
command: cmd,
|
|
error: crate::db::DbError::Sqlite {
|
|
message: "no such table: Ghost".to_string(),
|
|
kind: crate::db::SqliteErrorKind::NoSuchTable,
|
|
},
|
|
facts: crate::friendly::FailureContext::default(),
|
|
source: String::new(),
|
|
});
|
|
let last = app.output.back().unwrap();
|
|
assert_eq!(last.kind, OutputKind::Error);
|
|
// Anchor phrase + table name (ADR-0019 §10).
|
|
assert!(last.text.contains("no such table"), "{}", last.text);
|
|
assert!(last.text.contains("Ghost"), "{}", last.text);
|
|
}
|
|
|
|
#[test]
|
|
fn messages_command_toggles_verbosity_and_reports() {
|
|
let mut app = App::new();
|
|
// Default is verbose.
|
|
type_str(&mut app, "messages");
|
|
submit(&mut app);
|
|
let last = app.output.back().unwrap();
|
|
assert!(last.text.ends_with("verbose"), "{}", last.text);
|
|
|
|
// Switch to short.
|
|
type_str(&mut app, "messages short");
|
|
submit(&mut app);
|
|
assert_eq!(app.messages_verbosity, crate::friendly::Verbosity::Short);
|
|
let last = app.output.back().unwrap();
|
|
assert_eq!(last.text, "messages: short");
|
|
|
|
// And back.
|
|
type_str(&mut app, "messages verbose");
|
|
submit(&mut app);
|
|
assert_eq!(app.messages_verbosity, crate::friendly::Verbosity::Verbose);
|
|
}
|
|
|
|
#[test]
|
|
fn dsl_failure_threads_facts_value_into_unique_insert_headline() {
|
|
// The runtime resolves the user's attempted value into
|
|
// `FailureContext::value` (Phase C). The App threads
|
|
// it into `TranslateContext.value` and the catalog
|
|
// headline gets the concrete substitution. Here we
|
|
// simulate the runtime by populating `facts` directly.
|
|
let mut app = App::new();
|
|
let cmd = Command::Insert {
|
|
table: "thing".to_string(),
|
|
columns: None,
|
|
values: vec![crate::dsl::Value::Number("1".to_string())],
|
|
};
|
|
let err = crate::db::DbError::Sqlite {
|
|
message: "UNIQUE constraint failed: thing.id".to_string(),
|
|
kind: crate::db::SqliteErrorKind::UniqueViolation,
|
|
};
|
|
let facts = crate::friendly::FailureContext {
|
|
table: Some("thing".to_string()),
|
|
column: Some("id".to_string()),
|
|
value: Some("1".to_string()),
|
|
..crate::friendly::FailureContext::default()
|
|
};
|
|
app.update(AppEvent::DslFailed {
|
|
command: cmd,
|
|
error: err,
|
|
facts,
|
|
source: String::new(),
|
|
});
|
|
let body = app
|
|
.output
|
|
.iter()
|
|
.map(|l| l.text.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(
|
|
body.contains("`1`"),
|
|
"expected the attempted value `1` in headline:\n{body}"
|
|
);
|
|
assert!(
|
|
!body.contains("{value}"),
|
|
"{{value}} placeholder should have been substituted:\n{body}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dsl_failure_threads_facts_value_into_unique_update_headline() {
|
|
// UPDATE: same threading as INSERT, just that the
|
|
// runtime would have pulled `value` from the SET
|
|
// assignment matching the offending column.
|
|
let mut app = App::new();
|
|
let cmd = Command::Update {
|
|
table: "Customers".to_string(),
|
|
assignments: vec![(
|
|
"id".to_string(),
|
|
crate::dsl::Value::Number("7".to_string()),
|
|
)],
|
|
filter: crate::dsl::RowFilter::eq(
|
|
"name",
|
|
crate::dsl::Value::Text("Bob".to_string()),
|
|
),
|
|
};
|
|
let err = crate::db::DbError::Sqlite {
|
|
message: "UNIQUE constraint failed: Customers.id".to_string(),
|
|
kind: crate::db::SqliteErrorKind::UniqueViolation,
|
|
};
|
|
let facts = crate::friendly::FailureContext {
|
|
table: Some("Customers".to_string()),
|
|
column: Some("id".to_string()),
|
|
value: Some("7".to_string()),
|
|
..crate::friendly::FailureContext::default()
|
|
};
|
|
app.update(AppEvent::DslFailed {
|
|
command: cmd,
|
|
error: err,
|
|
facts,
|
|
source: String::new(),
|
|
});
|
|
let body = app
|
|
.output
|
|
.iter()
|
|
.map(|l| l.text.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(body.contains("`7`"), "expected attempted id `7`:\n{body}");
|
|
}
|
|
|
|
#[test]
|
|
fn messages_short_drops_the_hint_in_dsl_failure_render() {
|
|
// Verbose mode → headline + hint. Short mode → headline only.
|
|
// Use a UNIQUE-style violation since it has a meaty hint
|
|
// worth measuring against.
|
|
let mut app = App::new();
|
|
let cmd = Command::Insert {
|
|
table: "Customers".to_string(),
|
|
columns: Some(vec!["id".to_string()]),
|
|
values: vec![],
|
|
};
|
|
let err = || crate::db::DbError::Sqlite {
|
|
message: "UNIQUE constraint failed: Customers.id".to_string(),
|
|
kind: crate::db::SqliteErrorKind::UniqueViolation,
|
|
};
|
|
|
|
app.messages_verbosity = crate::friendly::Verbosity::Verbose;
|
|
app.update(AppEvent::DslFailed {
|
|
command: cmd.clone(),
|
|
error: err(),
|
|
facts: crate::friendly::FailureContext::default(),
|
|
source: String::new(),
|
|
});
|
|
let verbose_text = app
|
|
.output
|
|
.iter()
|
|
.map(|l| l.text.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(
|
|
verbose_text.contains("pick a different value"),
|
|
"verbose mode missing hint: {verbose_text}"
|
|
);
|
|
|
|
// Reset and try short.
|
|
let mut app = App::new();
|
|
app.messages_verbosity = crate::friendly::Verbosity::Short;
|
|
app.update(AppEvent::DslFailed {
|
|
command: cmd,
|
|
error: err(),
|
|
facts: crate::friendly::FailureContext::default(),
|
|
source: String::new(),
|
|
});
|
|
let short_text = app
|
|
.output
|
|
.iter()
|
|
.map(|l| l.text.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
assert!(
|
|
short_text.contains("already has the value"),
|
|
"short still has the headline: {short_text}"
|
|
);
|
|
assert!(
|
|
!short_text.contains("pick a different value"),
|
|
"short mode should not include the hint: {short_text}"
|
|
);
|
|
}
|
|
|
|
#[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!(
|
|
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
|
|
"expected only a JournalFailure, no dispatch; got {actions:?}",
|
|
);
|
|
let mentions_varchar = app
|
|
.output
|
|
.iter()
|
|
.any(|l| l.kind == OutputKind::Error && l.text.contains("varchar"));
|
|
assert!(
|
|
mentions_varchar,
|
|
"no error line mentions `varchar`; output:\n{}",
|
|
error_lines(&app),
|
|
);
|
|
}
|
|
|
|
#[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.len(), 1);
|
|
let Action::ExecuteDsl { command, .. } = &actions[0] else {
|
|
panic!("expected ExecuteDsl, got {:?}", actions[0]);
|
|
};
|
|
assert_eq!(
|
|
command,
|
|
&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 submitting_an_unparseable_line_emits_journal_failure() {
|
|
// ADR-0034 §1/§2: a submitted line that fails to parse is
|
|
// journalled `err` (recallable across sessions). The
|
|
// pure-sync App emits the intent; the runtime does the I/O.
|
|
let mut app = App::new();
|
|
type_str(&mut app, "florp glorp");
|
|
let actions = submit(&mut app);
|
|
assert!(
|
|
matches!(
|
|
actions.as_slice(),
|
|
[Action::JournalFailure { source }] if source == "florp glorp"
|
|
),
|
|
"expected JournalFailure for the typo'd line; got {actions:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dsl_failure_event_emits_journal_failure_carrying_the_source() {
|
|
// ADR-0034 §1/§2: an execution failure (the worker rejected
|
|
// a parsed command) is journalled `err` too. The runtime
|
|
// forwards the source on `DslFailed`; the App turns it into
|
|
// a `JournalFailure` action.
|
|
let mut app = App::new();
|
|
let actions = app.update(AppEvent::DslFailed {
|
|
command: Command::DropTable {
|
|
name: "Ghost".to_string(),
|
|
},
|
|
error: crate::db::DbError::Sqlite {
|
|
message: "no such table: Ghost".to_string(),
|
|
kind: crate::db::SqliteErrorKind::NoSuchTable,
|
|
},
|
|
facts: crate::friendly::FailureContext::default(),
|
|
source: "drop table Ghost".to_string(),
|
|
});
|
|
assert!(
|
|
matches!(
|
|
actions.as_slice(),
|
|
[Action::JournalFailure { source }] if source == "drop table Ghost"
|
|
),
|
|
"expected JournalFailure carrying the source; got {actions:?}",
|
|
);
|
|
}
|
|
|
|
#[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 resubmitting_a_recalled_command_does_not_strand_the_cursor() {
|
|
// Regression: a recalled command re-submitted unchanged
|
|
// is a consecutive duplicate, so `push_history` skips the
|
|
// append — but it must still reset the navigation cursor.
|
|
// Otherwise the next Up steps backwards from the stranded
|
|
// position instead of restarting at the newest entry.
|
|
let mut app = App::new();
|
|
type_str(&mut app, "show data Thing");
|
|
submit(&mut app);
|
|
type_str(&mut app, "insert into Thing values (1)");
|
|
submit(&mut app);
|
|
|
|
// Recall the insert and resubmit it unchanged, repeatedly.
|
|
// Every fresh Up must restart at the newest entry.
|
|
for round in 0..3 {
|
|
app.update(key(KeyCode::Up));
|
|
assert_eq!(
|
|
app.input, "insert into Thing values (1)",
|
|
"Up #{} should recall the newest entry",
|
|
round + 1,
|
|
);
|
|
submit(&mut app);
|
|
}
|
|
}
|
|
|
|
#[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.len(), 1);
|
|
let Action::ExecuteDsl { command, .. } = &actions[0] else {
|
|
panic!("expected ExecuteDsl, got {:?}", actions[0]);
|
|
};
|
|
assert_eq!(
|
|
command,
|
|
&Command::AddColumn {
|
|
table: "T".to_string(),
|
|
column: "Name".to_string(),
|
|
ty: Type::Text,
|
|
not_null: false,
|
|
unique: false,
|
|
default: None,
|
|
check: None,
|
|
},
|
|
);
|
|
}
|
|
|
|
// ---- Validity-indicator verdict (ADR-0027) ----------------
|
|
|
|
#[test]
|
|
fn input_validity_verdict_flags_a_broken_simple_command() {
|
|
let mut app = App::new();
|
|
app.input = "create table".to_string();
|
|
assert_eq!(
|
|
app.input_validity_verdict(),
|
|
Some(crate::dsl::walker::Severity::Error),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn input_validity_verdict_is_none_for_clean_input() {
|
|
let mut app = App::new();
|
|
app.input = "quit".to_string();
|
|
assert_eq!(app.input_validity_verdict(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn input_validity_verdict_fires_in_advanced_mode_for_incomplete_input() {
|
|
// Updated per ADR-0032 §10.6 / §11.6 — Phase 2 wires
|
|
// the SQL diagnostic surface (predicate warnings, etc.)
|
|
// through to the validity indicator. Pre-Phase-2 the
|
|
// verdict was silent in Advanced mode; now it reflects
|
|
// the active-mode walker's verdict, mirroring Simple
|
|
// mode's behaviour.
|
|
let mut app = App::new();
|
|
app.mode = Mode::Advanced;
|
|
app.input = "create table".to_string();
|
|
// Incomplete-at-EOF maps to Error (same as in Simple).
|
|
assert_eq!(
|
|
app.input_validity_verdict(),
|
|
Some(crate::dsl::walker::Severity::Error),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn input_validity_verdict_fires_for_colon_one_shot() {
|
|
// A `:`-prefixed line is a one-shot advanced escape;
|
|
// the verdict reads the advanced walker view, same as
|
|
// a persistent-advanced session.
|
|
let mut app = App::new();
|
|
app.input = ":create table".to_string();
|
|
assert_eq!(
|
|
app.input_validity_verdict(),
|
|
Some(crate::dsl::walker::Severity::Error),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn input_validity_verdict_fires_warning_for_sql_predicate_in_advanced() {
|
|
// ADR-0032 §11.6 — a SQL `LIKE`-on-numeric predicate
|
|
// emits a Warning diagnostic. The validity indicator
|
|
// now reflects that in Advanced mode.
|
|
use crate::completion::TableColumn;
|
|
use crate::dsl::types::Type;
|
|
let mut app = App::new();
|
|
app.mode = Mode::Advanced;
|
|
app.schema_cache.tables.push("products".to_string());
|
|
app.schema_cache.columns.push("price".to_string());
|
|
app.schema_cache.table_columns.insert(
|
|
"products".to_string(),
|
|
vec![TableColumn {
|
|
name: "price".to_string(),
|
|
user_type: Type::Real,
|
|
not_null: false,
|
|
has_default: false,
|
|
}],
|
|
);
|
|
app.input =
|
|
"select * from products where price like 5".to_string();
|
|
assert_eq!(
|
|
app.input_validity_verdict(),
|
|
Some(crate::dsl::walker::Severity::Warning),
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sql_update_success_shows_count_without_no_rows_band() {
|
|
// ADR-0033 sub-phase 3e: a SQL UPDATE returns a column-less
|
|
// result (precise rows are RETURNING, 3g). The render must
|
|
// surface the affected-row count and NOT a misleading
|
|
// "(no rows)" table band.
|
|
let mut app = App::new();
|
|
app.update(AppEvent::DslUpdateSucceeded {
|
|
command: Command::SqlUpdate {
|
|
sql: "update t set v = 1".to_string(),
|
|
target_table: "t".to_string(),
|
|
returning: false,
|
|
},
|
|
result: crate::db::UpdateResult {
|
|
rows_affected: 2,
|
|
data: crate::db::DataResult {
|
|
table_name: "t".to_string(),
|
|
columns: Vec::new(),
|
|
column_types: Vec::new(),
|
|
rows: Vec::new(),
|
|
},
|
|
},
|
|
});
|
|
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
|
|
assert!(
|
|
texts.iter().any(|t| t.contains("2 row(s) updated")),
|
|
"affected-row count surfaced: {texts:?}",
|
|
);
|
|
assert!(
|
|
!texts.iter().any(|t| t.contains("(no rows)")),
|
|
"no misleading empty-table band: {texts:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn update_success_with_columns_renders_the_table() {
|
|
// The guard only suppresses a column-less result: a result
|
|
// carrying columns (the DSL UPDATE path) still renders.
|
|
let mut app = App::new();
|
|
app.update(AppEvent::DslUpdateSucceeded {
|
|
command: Command::SqlUpdate {
|
|
sql: "update t set v = 1".to_string(),
|
|
target_table: "t".to_string(),
|
|
returning: false,
|
|
},
|
|
result: crate::db::UpdateResult {
|
|
rows_affected: 1,
|
|
data: crate::db::DataResult {
|
|
table_name: "t".to_string(),
|
|
columns: vec!["id".to_string(), "v".to_string()],
|
|
column_types: vec![Some(Type::Int), Some(Type::Int)],
|
|
rows: vec![vec![Some("1".to_string()), Some("9".to_string())]],
|
|
},
|
|
},
|
|
});
|
|
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
|
|
assert!(
|
|
texts.iter().any(|t| t.contains("id") && t.contains('v')),
|
|
"header row rendered: {texts:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sql_delete_success_renders_count_and_cascade_summary() {
|
|
// ADR-0033 sub-phase 3f: a SQL DELETE reuses the DSL delete
|
|
// renderer (CommandOutcome::Delete -> handle_dsl_delete_
|
|
// success). This pins that the SHARED renderer produces the
|
|
// right user-facing strings for the SQL path — the ok-summary
|
|
// (verb + subject, where SqlDelete's subject is its target
|
|
// table) and the per-relationship cascade line. The integration
|
|
// tests check the DeleteResult struct; this checks the render.
|
|
use crate::dsl::ReferentialAction;
|
|
let mut app = App::new();
|
|
app.update(AppEvent::DslDeleteSucceeded {
|
|
command: Command::SqlDelete {
|
|
sql: "delete from Customers where id = 1".to_string(),
|
|
target_table: "Customers".to_string(),
|
|
returning: false,
|
|
},
|
|
result: crate::db::DeleteResult {
|
|
rows_affected: 1,
|
|
cascade: vec![crate::db::CascadeEffect {
|
|
relationship_name: "places".to_string(),
|
|
child_table: "Orders".to_string(),
|
|
rows_changed: 2,
|
|
action: ReferentialAction::Cascade,
|
|
}],
|
|
data: crate::db::DataResult {
|
|
table_name: "Customers".to_string(),
|
|
columns: Vec::new(),
|
|
column_types: Vec::new(),
|
|
rows: Vec::new(),
|
|
},
|
|
},
|
|
});
|
|
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
|
|
assert!(
|
|
texts.iter().any(|t| t.contains("delete from") && t.contains("Customers")),
|
|
"ok summary names the verb + target table: {texts:?}",
|
|
);
|
|
assert!(
|
|
texts.iter().any(|t| t.contains("1 row(s) deleted")),
|
|
"directly-deleted count surfaced: {texts:?}",
|
|
);
|
|
assert!(
|
|
texts.iter().any(|t| t.contains("2 row(s) deleted in `Orders`")
|
|
&& t.contains("relationship `places`")),
|
|
"per-relationship cascade summary surfaced: {texts:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sql_delete_returning_renders_cascade_and_result_table() {
|
|
// ADR-0033 3g: a DELETE … RETURNING surfaces BOTH the cascade
|
|
// summary AND the returned-rows table. Pins the render branch
|
|
// that tabulates `result.data` when RETURNING populated it
|
|
// (the column-less non-RETURNING path is skipped — see the
|
|
// sibling test above).
|
|
use crate::dsl::ReferentialAction;
|
|
let mut app = App::new();
|
|
app.update(AppEvent::DslDeleteSucceeded {
|
|
command: Command::SqlDelete {
|
|
sql: "delete from Customers where id = 1 returning *".to_string(),
|
|
target_table: "Customers".to_string(),
|
|
returning: true,
|
|
},
|
|
result: crate::db::DeleteResult {
|
|
rows_affected: 1,
|
|
cascade: vec![crate::db::CascadeEffect {
|
|
relationship_name: "places".to_string(),
|
|
child_table: "Orders".to_string(),
|
|
rows_changed: 2,
|
|
action: ReferentialAction::Cascade,
|
|
}],
|
|
data: crate::db::DataResult {
|
|
table_name: "Customers".to_string(),
|
|
columns: vec!["id".to_string(), "Name".to_string()],
|
|
column_types: vec![Some(Type::Int), Some(Type::Text)],
|
|
rows: vec![vec![Some("1".to_string()), Some("Alice".to_string())]],
|
|
},
|
|
},
|
|
});
|
|
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
|
|
assert!(
|
|
texts.iter().any(|t| t.contains("2 row(s) deleted in `Orders`")),
|
|
"cascade summary still surfaces alongside RETURNING: {texts:?}",
|
|
);
|
|
assert!(
|
|
texts.iter().any(|t| t.contains("Name")) && texts.iter().any(|t| t.contains("Alice")),
|
|
"the returned (deleted) row is tabulated: {texts:?}",
|
|
);
|
|
}
|
|
}
|