//! 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>, } 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, ) -> 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, pub hint: Option, /// 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, pub tables: Vec, /// Last successfully described table, shown in the output /// pane until the next DDL operation. pub current_table: Option, /// 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, /// Position within `history` while navigating with Up/Down. /// `None` means "not navigating; `input` is the user's /// in-progress draft." history_cursor: Option, /// 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, /// 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, /// 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, /// 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, /// 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, /// 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, /// The DSL → SQL teaching echo (ADR-0038) for the command currently /// being rendered: set from the success event just before its handler /// runs, consumed by `note_ok_summary` (which pushes it beneath /// `[ok]`), within the same synchronous `update()` call. `None` when /// the command has no echo. pending_echo: Option, } /// 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 `/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, 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, pending_echo: None, } } /// Called by the renderer with the current output-panel /// dimensions (row count + total wrapped-row count for the /// current buffer) so subsequent scroll input is capped /// correctly. Without `total_wrapped`, scroll math would /// incorrectly assume one logical line = one display row. pub const fn note_output_viewport(&mut self, visible_rows: usize, total_wrapped_rows: usize) { self.last_output_visible = visible_rows; self.last_output_total_wrapped = total_wrapped_rows; // If a previous PageUp drifted past the maximum useful // scroll (e.g. the user kept paging up past the top), // bring it back so the next PageDown is responsive. let max = total_wrapped_rows.saturating_sub(visible_rows); if self.output_scroll > max { self.output_scroll = max; } } /// 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) { 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 { 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 { match event { AppEvent::Key(key) => self.handle_key(key), AppEvent::Resize { .. } | AppEvent::Tick => Vec::new(), AppEvent::DslSucceeded { command, description, echo, } => { // Stash the teaching echo (ADR-0038) for `note_ok_summary` // to render beneath `[ok]` — consumed synchronously below. self.pending_echo = echo; 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::DslDropIndexSkipped { command } => { // No-op (DROP INDEX IF EXISTS on an absent index, // ADR-0035 §4d): just the skip note. `target_table()` // returns the index name for `SqlDropIndex`. self.note_system(crate::t!( "ddl.drop_index_skipped_absent", name = command.target_table() )); Vec::new() } AppEvent::DslCreateIndexSkipped { command: _, name } => { // No-op (CREATE INDEX IF NOT EXISTS on an existing index // name, ADR-0035 §4d): the skip note carries the resolved // index name (the unnamed form's auto-name isn't on the // command). No structure shown. self.note_system(crate::t!("ddl.create_index_skipped_exists", name = name)); 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 { // 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 { 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 { 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 " " (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 " " 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 { 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. The three-way `EffectiveMode` (ADR-0037) is // carried through dispatch so the runtime can gate the DSL → SQL // teaching echo (ADR-0038) on an advanced effective mode. let (submission_mode, effective_input) = if self.mode == Mode::Simple && trimmed.starts_with(':') { (EffectiveMode::AdvancedOneShot, trimmed[1..].trim().to_string()) } else if self.mode == Mode::Advanced { (EffectiveMode::AdvancedPersistent, trimmed.to_string()) } else { (EffectiveMode::Simple, 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, submission_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 { 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 { 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: EffectiveMode) -> Vec { // The two-way mode the walker + the `[mode]` render tag read; the // three-way `submission_mode` (ADR-0037) rides on `ExecuteDsl` for // the runtime's echo gate (ADR-0038). let mode = submission_mode.as_mode(); // 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, 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: 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: mode, styled_runs: None, }); vec![Action::ExecuteDsl { command: cmd, source: input.to_string(), submission_mode, }] } 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: 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 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] ` 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() )); // ADR-0038: the DSL → SQL teaching echo, beneath `[ok]`. Set on // the success event when a DSL-form command ran in an advanced // effective mode (ADR-0037); `None` otherwise. De-emphasised. if let Some(sql) = self.pending_echo.take() { self.note_system(crate::t!("echo.executing_sql", sql = sql)); } } fn handle_dsl_success(&mut self, command: &Command, description: Option) { 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: " 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`] from a [`Command`] + schema- /// resolved [`FailureContext`], using the App's current verbosity. /// Thin wrapper over [`Self::translate_context_for`], which is shared /// with the replay path (it supplies its own verbosity — ADR-0035 /// Amendment 1, F2 follow-up). fn build_translate_context( &self, command: &Command, facts: crate::friendly::FailureContext, ) -> crate::friendly::TranslateContext { Self::translate_context_for(command, facts, self.messages_verbosity) } /// Combine the runtime-supplied [`FailureContext`] (schema-resolved /// facts) with the operation derived from the originating [`Command`] /// and an explicit `verbosity`. Schema-resolved facts win over /// Command-derived fallbacks where the runtime supplied them /// (typically the FK-relationship lookup yields a `parent_table` the /// Command alone can't reveal). Shared by interactive rendering and /// the replay failure path (ADR-0035 Amendment 1, F2 follow-up), so a /// replayed failing command shows real names instead of leaking /// `{name}` placeholders. pub(crate) fn translate_context_for( command: &Command, facts: crate::friendly::FailureContext, verbosity: crate::friendly::Verbosity, ) -> crate::friendly::TranslateContext { use crate::dsl::{AlterTableAction, 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), // SQL `ALTER TABLE` routes engine/validation errors through // the operation matching its action, with the parsed table // (and column, where the action names one) — ADR-0035 §4e. C::SqlAlterTable { table, action } => match action { AlterTableAction::AddColumn(spec) => ( Operation::AddColumn, Some(table.as_str()), Some(spec.name.as_str()), ), AlterTableAction::DropColumn { column } => ( Operation::DropColumn, Some(table.as_str()), Some(column.as_str()), ), AlterTableAction::RenameColumn { old, .. } => ( Operation::RenameColumn, Some(table.as_str()), Some(old.as_str()), ), AlterTableAction::AlterColumnType { column, .. } => ( Operation::ChangeColumnType, Some(table.as_str()), Some(column.as_str()), ), // ADR-0035 Amendment 2: the column-attribute set/drop forms // decompose to add/drop constraint, and name a column (so // the friendly error can pinpoint it). AlterTableAction::SetColumnNotNull { column } | AlterTableAction::SetColumnDefault { column, .. } => ( Operation::AddConstraint, Some(table.as_str()), Some(column.as_str()), ), AlterTableAction::DropColumnNotNull { column } | AlterTableAction::DropColumnDefault { column } => ( Operation::DropConstraint, Some(table.as_str()), Some(column.as_str()), ), AlterTableAction::AddTableConstraint { .. } => { (Operation::AddConstraint, Some(table.as_str()), None) } AlterTableAction::DropConstraint { .. } => { (Operation::DropConstraint, Some(table.as_str()), None) } // `RENAME TO ` — the failure concerns the table being // renamed (the old name); the executor authors the // existing-target / same-name refusals (ADR-0035 §6, 4h). AlterTableAction::RenameTable { .. } => { (Operation::RenameTable, Some(table.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), // SQL `CREATE [UNIQUE] INDEX` shares the add-index operation // (it reuses `do_add_index`); route engine/validation errors // through it with the parsed table. C::SqlCreateIndex { 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), }, // The SQL `DROP INDEX` is name-only (the table is resolved by // the executor), like the named DSL drop. C::SqlDropIndex { .. } => (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::translate_context_for(query, facts, verbosity); } // 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, } } /// Parse the argument tail of an `import` command and /// return the corresponding `Action::Import`. /// /// Grammar: `import [as ]`. 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 { 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 { 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 { 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 { 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 { 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 { 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 = 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) { self.push_multiline(text.into(), OutputKind::System); } fn note_error(&mut self, text: impl Into) { 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 = 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 { 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::>() .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 " ", 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(), unique_constraints: Vec::new(), check_constraints: 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 submit_carries_the_three_way_effective_submission_mode() { // ADR-0037: ExecuteDsl carries the effective mode so the runtime // can gate the teaching echo (ADR-0038). let case = |mode: Mode, input: &str| -> EffectiveMode { let mut app = App::new(); app.mode = mode; type_str(&mut app, input); match submit(&mut app).as_slice() { [Action::ExecuteDsl { submission_mode, .. }] => *submission_mode, other => panic!("expected one ExecuteDsl; got {other:?}"), } }; assert_eq!( case(Mode::Advanced, "create table T with pk"), EffectiveMode::AdvancedPersistent ); assert_eq!( case(Mode::Simple, ":create table T with pk"), EffectiveMode::AdvancedOneShot ); assert_eq!( case(Mode::Simple, "create table T with pk"), EffectiveMode::Simple ); } #[test] fn dsl_success_renders_the_teaching_echo_beneath_ok() { // ADR-0038: the echo carried on the success event renders as a // line immediately beneath the `[ok]` summary. let cmd = Command::CreateTable { name: "Other".to_string(), columns: vec![crate::dsl::ColumnSpec::new("id", Type::Serial)], primary_key: vec!["id".to_string()], }; let mut app = App::new(); app.update(AppEvent::DslSucceeded { command: cmd.clone(), description: None, echo: Some("CREATE TABLE Other (id serial PRIMARY KEY)".to_string()), }); let texts: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect(); let ok_idx = texts.iter().position(|t| t.starts_with("[ok]")).expect("an [ok] line"); let echo_idx = texts .iter() .position(|t| t.contains("Executing SQL:")) .expect("an echo line"); assert_eq!(echo_idx, ok_idx + 1, "echo sits immediately beneath [ok]: {texts:?}"); assert!(texts[echo_idx].contains("CREATE TABLE Other (id serial PRIMARY KEY)")); // No echo line when the event carries none (simple mode etc.). let mut app = App::new(); app.update(AppEvent::DslSucceeded { command: cmd, description: None, echo: None, }); assert!( !app.output.iter().any(|l| l.text.contains("Executing SQL:")), "no echo line when echo is None" ); } #[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::>(), ); 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()), echo: None, }); 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 ` 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::>() .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 ` > ` 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::>() .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::>() .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::>() .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::>() .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, set_literals: Vec::new(), }, 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 = 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, set_literals: Vec::new(), }, 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 = 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 = 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 = 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:?}", ); } }