Files
rdbms-playground/src/app.rs
T
claude@clouddev1 f7155ceafc fix(input): thread the : one-shot escape into live SQL feedback
The `:` one-shot escape (ADR-0003) is stripped at submission, but the
*live* feedback kept the leading `:` in the buffer it handed the
walker — so Tab completion, the validity verdict, and the highlight
overlays all bailed at the `:` and treated the SQL as an unknown
command. Effect: in `:`-mode, Tab completed nothing and a valid query
could flash an error, while the identical line in full `mode advanced`
worked. (The ambient hint already stripped it, which is why the hint
showed the right column name while Tab did nothing.)

Add `App::feedback_view()` — the `:`-stripped SQL view, the cursor
mapped into it, and the stripped byte offset — and route all four live
paths through it:

- completion (Tab): complete against the view, then shift the returned
  `replaced_range` back by the offset so the edit lands in the buffer;
- validity verdict: verdict the SQL, not the sigil;
- highlight/overlays: new `render_input_runs_feedback` highlights and
  diagnoses the view (shifted by the offset) while the `:` renders as
  plain text;
- ambient hint: consolidated onto `feedback_view`, replacing the
  duplicate local `strip_one_shot_prefix`.
2026-06-12 12:43:00 +00:00

6504 lines
256 KiB
Rust

//! Application state and the single `update` entry point.
//!
//! `update` is pure with respect to the runtime: it mutates
//! state in place and returns a list of `Action`s. Side effects
//! (DB execution, quit, etc.) live in the runtime. This keeps
//! every behaviour drivable from synthetic events in tests,
//! which is what makes ADR-0008's Tier 1/3 testing tractable.
use std::collections::VecDeque;
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::Rect;
use tracing::{debug, 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 DSL → SQL teaching echo (ADR-0038 §4). Visually a `[system]`
/// line, but rendered with a custom path: a dimmed `Executing SQL:`
/// prefix followed by the SQL re-lexed through `input_render::
/// lex_to_runs_in_mode(Advanced)` — same syntax highlighting the
/// input echo gets, so the suggested SQL reads like code (ADR-0028
/// §5 styled-runs).
TeachingEcho,
}
/// Completion state of an `OutputKind::Echo` line (ADR-0040).
///
/// An echo for an *executed* command is pushed `Pending` (rendered
/// `running: <input>`) and resolves to `Ok`/`Err` when the result
/// arrives — rendered `<input> ✓` / `<input> ✗`, replacing the old
/// `[ok]`/`failed:` summary line. Parse-time and pre-flight
/// rejections are not executed and carry `None` (they keep the
/// `running:` + caret rendering); non-echo lines also carry `None`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EchoStatus {
Pending,
Ok,
Err,
}
/// 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,
/// De-emphasised text — `Executing SQL:` prefix on teaching
/// echo lines (ADR-0038 §4), the DontConvert caveat, and
/// every `[client-side]` category-3 prose note (ADR-0038 §6).
/// Resolves to `theme.muted`.
Hint,
/// A relationship-diagram box's title row — the table name
/// (ADR-0044 §2.1). Bold accent so it cannot read as a column.
DiagramTableName,
/// A relationship-diagram key marker — `(PK)` / `●` on the
/// participating columns (ADR-0044 §2.2).
DiagramKey,
/// A relationship-diagram cardinality label — `1` / `n`
/// (ADR-0044 §2).
DiagramCardinality,
/// A relationship-diagram connector — box-drawing line, elbows
/// and arrowhead between the two boxes (ADR-0044 §2.3). Muted so
/// the structure, not the wiring, leads.
DiagramConnector,
}
/// A styled span of an output line: a byte range over the
/// line's text and the semantic class it carries (ADR-0028 §5).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutputSpan {
/// Half-open byte range `[start, end)` into the line text.
pub byte_range: (usize, usize),
pub class: OutputStyleClass,
}
#[derive(Debug, Clone)]
pub struct OutputLine {
pub text: String,
pub kind: OutputKind,
pub mode_at_submission: Mode,
/// Optional per-span styling (ADR-0028 §5). When `Some`,
/// `render_output_line` colours the text span-by-span from
/// these runs; when `None` it falls back to whole-line
/// styling by `kind`.
pub styled_runs: Option<Vec<OutputSpan>>,
/// Echo completion state (ADR-0040). `Some(_)` only on
/// `OutputKind::Echo` lines for executed commands; `None`
/// everywhere else (non-echo lines, parse/pre-flight echoes).
pub status: Option<EchoStatus>,
}
impl OutputLine {
/// An output line carrying per-span styled runs (ADR-0028
/// §5) — the text is coloured per `runs`, not by `kind`.
#[must_use]
pub const fn styled(
text: String,
kind: OutputKind,
mode_at_submission: Mode,
runs: Vec<OutputSpan>,
) -> Self {
Self {
text,
kind,
mode_at_submission,
styled_runs: Some(runs),
status: None,
}
}
/// A `running: <input>` echo for an executed command, pushed
/// `Pending` and resolved to `Ok`/`Err` on completion (ADR-0040).
#[must_use]
pub fn echo(input: &str, mode: Mode) -> Self {
Self {
text: crate::t!("dsl.running", input = input),
kind: OutputKind::Echo,
mode_at_submission: mode,
styled_runs: None,
status: Some(EchoStatus::Pending),
}
}
/// The plain-text form of this line *as rendered* (ADR-0041) — the
/// `[tag]`, the body, and the echo decoration (`running:` prefix or
/// trailing `✓`/`✗`) — without colour, viewport padding, or
/// wrapping. This is what the `copy` command puts on the clipboard.
///
/// It mirrors the content `render_output_line` (`ui.rs`) produces;
/// the line content is theme-independent (only colour is not), so no
/// theme is needed. A drift-lock test in `ui.rs`
/// (`plain_text_matches_rendered_line_content`) pins the two
/// together so a renderer change can't silently desync the copy.
#[must_use]
pub fn plain_text(&self) -> String {
let tag = match self.kind {
OutputKind::Echo => {
format!("[{}] ", self.mode_at_submission.label().to_lowercase())
}
OutputKind::System | OutputKind::TeachingEcho => "[system] ".to_string(),
OutputKind::Error => "[error] ".to_string(),
};
if self.kind == OutputKind::Echo {
// Pending / untracked echoes keep the `running: ` prefix;
// completed ones drop it and gain a ✓/✗ marker (ADR-0040).
let input = self
.text
.strip_prefix(crate::dsl::ECHO_PREFIX)
.unwrap_or(self.text.as_str());
let mut s = tag;
if !matches!(self.status, Some(EchoStatus::Ok | EchoStatus::Err)) {
s.push_str(crate::dsl::ECHO_PREFIX);
}
s.push_str(input);
match self.status {
Some(EchoStatus::Ok) => s.push_str(""),
Some(EchoStatus::Err) => s.push_str(""),
_ => {}
}
return s;
}
// System / Error / TeachingEcho / styled lines: the body is
// `self.text` verbatim (the teaching-echo `Executing SQL: `
// label and the styled-run slices all tile `self.text`).
format!("{tag}{}", self.text)
}
}
/// 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,
}
}
}
/// Navigation-mode focus cursor (ADR-0046 DC1).
///
/// `Input` means not in navigation mode — keystrokes edit the command
/// input as usual. `Ctrl-O` cycles Input → SidebarTables →
/// SidebarRelationships → Input; while a sidebar panel is focused the
/// sidebar is revealed (peek) and expanded as an overlay, and scroll
/// keys drive it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum NavFocus {
#[default]
Input,
SidebarTables,
SidebarRelationships,
}
impl NavFocus {
/// True while a sidebar panel is focused (navigation mode is active).
pub const fn in_sidebar(self) -> bool {
matches!(self, Self::SidebarTables | Self::SidebarRelationships)
}
}
#[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,
/// First visible display column of the input line when it is too
/// long to fit the input panel (ADR-0046 DA3). The renderer keeps
/// the cursor in view by adjusting this; it resets to 0 whenever the
/// buffer is replaced wholesale (submit / history navigation).
pub input_scroll_offset: usize,
/// Navigation-mode focus cursor (ADR-0046 DC1). `Input` when not in
/// navigation mode. Driven by `Ctrl-O` / `Esc`; the renderer reveals
/// + expands the focused sidebar panel as an overlay.
pub nav_focus: NavFocus,
pub output: VecDeque<OutputLine>,
pub hint: Option<String>,
/// The validity indicator's currently-visible verdict
/// (ADR-0027). `None` means the indicator shows nothing —
/// the input is clean, or it is hidden mid-typing while the
/// debounce settles. The runtime owns the timing: it clears
/// this on a keystroke and sets it from
/// [`App::input_validity_verdict`] once typing pauses.
pub input_indicator: Option<crate::dsl::walker::Severity>,
pub tables: Vec<String>,
/// All relationships as full schema records, for the sidebar
/// relationships panel (ADR-0046 DB2). Refreshed by the runtime
/// alongside `tables`. Kept on the App (not `SchemaCache`) because
/// only the UI needs the details — the walker/completion need just
/// the names, which stay in `SchemaCache::relationships`.
pub relationships: Vec<crate::persistence::RelationshipSchema>,
/// Last successfully described table, shown in the output
/// pane until the next DDL operation.
pub current_table: Option<TableDescription>,
/// In-memory history of submitted lines, oldest first.
/// Persistent history across sessions (I2 second half) lands
/// when track 2's project storage is in place.
pub history: Vec<String>,
/// Position within `history` while navigating with Up/Down.
/// `None` means "not navigating; `input` is the user's
/// in-progress draft."
history_cursor: Option<usize>,
/// Snapshot of the user's in-progress draft taken when they
/// start navigating history, restored if they navigate back
/// past the most recent entry.
history_draft: Option<String>,
/// Number of lines from the bottom we've scrolled up. `0`
/// means "showing the most recent lines"; positive values
/// reveal older lines. Reset to `0` whenever a new output
/// line is appended so newly-arrived results are always
/// visible after a command. The full V4 session-log spec
/// supersedes this; we ship a minimal subset now to address
/// the immediate "ran out of space" UX problem.
pub output_scroll: usize,
/// The most recent visible-row count of the output panel,
/// reported by the renderer. Used to cap `output_scroll` —
/// without this, scrolling past `len - visible` would slide
/// the visible window off the top of the buffer and shrink
/// what the user sees.
pub last_output_visible: usize,
/// The most recent total *wrapped* row count of the output
/// panel — counted in display rows after wrapping, not in
/// logical OutputLines. Required for accurate scroll capping
/// when long lines wrap to multiple display rows.
pub last_output_total_wrapped: usize,
/// The most recent inner width (in columns) of the output panel,
/// recorded by the renderer (ADR-0044 §3). Drives the relationship
/// diagram's side-by-side vs vertical layout choice. Defaults to
/// `80` until the first render measures the real width.
pub last_output_width: u16,
/// The most recent **inner area** (inside the border) of the output
/// panel, recorded by the renderer (ADR-0047 D4). The demo overlays
/// anchor to its bottom-right corner; read at the top-level draw
/// pass, which otherwise does not know where the output panel sits.
/// Zero-sized until the first render measures it.
pub last_output_area: Rect,
/// Top visible row of the Tables / Relationships sidebar panels
/// while scrolled in navigation mode (ADR-0046 DC3), with the most
/// recent visible-row count the renderer reported for each (used to
/// page-scroll and to clamp the offset). `0` = showing from the top.
pub tables_scroll: usize,
pub relationships_scroll: usize,
pub last_tables_visible: usize,
pub last_relationships_visible: usize,
/// Prettified display name of the currently-open project,
/// rendered in the status bar (P-NAME-3, ADR-0015 §2). `None`
/// during very-early startup before the runtime has opened a
/// project; otherwise always populated.
pub project_name: Option<String>,
/// Whether the open project is auto-named temporary or
/// user-named permanent. Drives the `[TEMP]` prefix in the
/// status bar and the `save` command's behaviour.
pub project_is_temp: bool,
/// Set when a fatal persistence failure has occurred
/// (ADR-0015 §8). The runtime reads this after the event
/// loop exits and prints it to stderr post-teardown so the
/// banner remains above the shell prompt.
pub fatal_message: Option<String>,
/// Active modal dialog (rebuild confirmation, save-as path
/// prompt, load picker, …). While `Some`, `update`
/// dispatches keys to the modal instead of the input
/// field.
pub modal: Option<Modal>,
/// Memo of the most recent Tab-completion (ADR-0022
/// stage 8). Carries enough state to cycle to the next /
/// previous candidate on subsequent Tab / Shift-Tab
/// presses, and to undo the whole insertion in one
/// keystroke via Esc / Backspace. Cleared by *any* other
/// keystroke — no completion mode, just a transient
/// memory of "the last thing Tab did."
pub last_completion: Option<crate::completion::LastCompletion>,
/// Per-project schema lookup cache feeding Tab completion
/// for identifier slots (ADR-0022 §9 + stage 8c). Empty
/// by default; refreshed by the runtime on project load
/// and after successful DDL.
pub schema_cache: crate::completion::SchemaCache,
/// Whether the undo/snapshot machinery is active this session
/// (ADR-0006 Amendment 1). `false` under the `--no-undo` CLI
/// flag; the `undo` / `redo` commands then report undo is off
/// rather than emitting a prepare action.
pub undo_enabled: bool,
/// Whether **demonstration mode** is active this session (ADR-0047,
/// issue #22). `true` under `--demo` / `RDBMS_PLAYGROUND_DEMO`. When
/// off (the default) none of the demo key handling or overlay
/// rendering runs — zero footprint. When on, otherwise-invisible
/// keys raise a transient badge (`demo_badge`) and `Ctrl+]` drives
/// the stealth step-caption buffer (`demo_caption` / `demo_capturing`,
/// Phase C).
pub demo_mode: bool,
/// The keystroke badge currently displayed in demo mode (ADR-0047
/// D2), e.g. `"[TAB]"`. Set in `update()` when an otherwise-invisible
/// key is handled; cleared by the runtime when its ~1.5 s timer
/// elapses (the timing lives in the runtime, mirroring how
/// `input_indicator` is driven from `IndicatorDebounce`). `None` when
/// no badge is showing.
pub demo_badge: Option<&'static str>,
/// Monotonic counter bumped every time `demo_badge` is (re)set
/// (ADR-0047 D5). The runtime watches it so a *new* badge — even the
/// same label twice in a row (Tab, Tab) — restarts the expiry timer.
pub demo_badge_seq: u64,
/// The step-caption currently displayed in demo mode (ADR-0047 D3),
/// or `None`. Committed from the stealth buffer on the closing
/// `Ctrl+]`; cleared by the next ordinary keystroke (or an empty
/// commit). Rendered as a wrapped box stacked above the badge.
pub demo_caption: Option<String>,
/// Whether the stealth caption buffer is open (ADR-0047 D3): between
/// the opening and closing `Ctrl+]`, typed characters accumulate into
/// `demo_caption_buffer` invisibly and every other key is inert.
pub demo_caption_capturing: bool,
/// The invisible accumulator for the caption being typed while
/// `demo_caption_capturing` (ADR-0047 D3). Never rendered directly;
/// its trimmed contents become `demo_caption` on commit.
pub demo_caption_buffer: String,
/// 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<Vec<String>>,
}
/// Dialogs that take over keyboard input when active.
///
/// Track-2 lifecycle commands (`rebuild`, `save as`, `load`,
/// `new`) need confirmation prompts or path entry that the
/// single-line input field can't naturally express. Each
/// modal owns a small state machine; the renderer draws an
/// overlay and `App::update` routes keys through it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Modal {
/// `rebuild` confirmation. Shows a summary of what would
/// be reconstructed; `Y` confirms, `N` / `Esc` dismisses.
RebuildConfirm(RebuildConfirmModal),
/// One-line text prompt used by `save` / `save as` for
/// the target name/path.
PathEntry(PathEntryModal),
/// Load picker. Shows a list of projects in the active
/// data root; `b` switches to a path-entry sub-mode for
/// projects outside the data root (ADR-0015 §7).
LoadPicker(LoadPickerModal),
/// `undo` / `redo` confirmation (ADR-0006 Amendment 1). Names
/// the command that will be undone / re-applied; `Y` confirms,
/// `N` / `Esc` dismisses.
UndoConfirm(UndoConfirmModal),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UndoConfirmModal {
/// The command text of the snapshot being restored — the thing
/// that will be undone (or re-applied, for redo).
pub command: String,
/// When that snapshot was taken (ISO-8601 `Z`).
pub timestamp: String,
/// `false` for undo, `true` for redo — selects the wording.
pub is_redo: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RebuildConfirmModal {
/// One-line summary derived from `project.yaml` + `data/`
/// (e.g. `"3 tables, 47 rows will be reconstructed"`).
pub summary: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathEntryModal {
pub title: String,
pub prompt: String,
pub input: String,
/// Byte offset of the insertion point inside `input`.
pub cursor: usize,
pub purpose: PathEntryPurpose,
}
/// What the runtime should do with the path the user typed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathEntryPurpose {
/// Save the current project to the typed name/path.
/// Relative names resolve against `<data-root>/projects/`.
SaveAs,
/// Load the project at the typed path. Used by the load
/// picker's `b` (browse) sub-mode.
LoadByPath,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadPickerModal {
pub entries: Vec<LoadPickerEntry>,
pub selected: usize,
/// Sub-mode: list-of-recents (default) or path-entry
/// (after `b`).
pub sub_mode: LoadPickerSubMode,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoadPickerEntry {
pub display_name: String,
pub modified: String,
pub path: std::path::PathBuf,
pub is_temp: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoadPickerSubMode {
List,
/// Switched to via `b`. Same input/cursor surface as
/// `PathEntryModal`; kept inline so the picker can flip
/// back to List with `Esc`.
PathEntry { input: String, cursor: usize },
}
const PAGE_SCROLL_LINES: usize = 5;
const HISTORY_CAPACITY: usize = 1000;
/// The demo-mode keystroke badge for `key`, or `None` if the key
/// produces a glyph of its own (and so needs no badge) — ADR-0047 D2.
///
/// The set is exactly the *otherwise-invisible* keys: motion, editing,
/// submission, and the `Ctrl-O` navigation toggle. Plain character keys
/// already appear on the input line, and `Ctrl-C` (quit) / `Ctrl+]`
/// (the caption toggle) are deliberately excluded. Pure and total, so
/// it is exhaustively unit-testable without a running app.
pub const fn demo_badge_label(key: &KeyEvent) -> Option<&'static str> {
match (key.code, key.modifiers) {
(KeyCode::Tab, _) => Some("[TAB]"),
(KeyCode::BackTab, _) => Some("[SHIFT-TAB]"),
(KeyCode::Enter, _) => Some("[ENTER]"),
(KeyCode::Esc, _) => Some("[ESC]"),
(KeyCode::Up, _) => Some("[UP]"),
(KeyCode::Down, _) => Some("[DOWN]"),
(KeyCode::Left, _) => Some("[LEFT]"),
(KeyCode::Right, _) => Some("[RIGHT]"),
(KeyCode::Home, _) => Some("[HOME]"),
(KeyCode::End, _) => Some("[END]"),
(KeyCode::PageUp, _) => Some("[PGUP]"),
(KeyCode::PageDown, _) => Some("[PGDN]"),
(KeyCode::Backspace, _) => Some("[BKSP]"),
(KeyCode::Delete, _) => Some("[DEL]"),
// The only badged control chord: the ADR-0046 navigation toggle.
(KeyCode::Char('o'), m) if m.contains(KeyModifiers::CONTROL) => Some("[CTRL-O]"),
_ => None,
}
}
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,
input_scroll_offset: 0,
nav_focus: NavFocus::Input,
output: VecDeque::with_capacity(OUTPUT_CAPACITY),
hint: None,
input_indicator: None,
tables: Vec::new(),
relationships: 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,
last_output_width: 80,
last_output_area: Rect::new(0, 0, 0, 0),
tables_scroll: 0,
relationships_scroll: 0,
last_tables_visible: 0,
last_relationships_visible: 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,
// Demo mode is off by default; the runtime flips it on for
// a `--demo` session (ADR-0047).
demo_mode: false,
demo_badge: None,
demo_badge_seq: 0,
demo_caption: None,
demo_caption_capturing: false,
demo_caption_buffer: String::new(),
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<String>) {
self.history = entries;
while self.history.len() > HISTORY_CAPACITY {
self.history.remove(0);
}
self.history_cursor = None;
self.history_draft = None;
}
/// Effective mode for the *next* submission, given the
/// persistent mode and the current input buffer. See
/// [`EffectiveMode`].
#[must_use]
pub fn effective_mode(&self) -> EffectiveMode {
match self.mode {
Mode::Advanced => EffectiveMode::AdvancedPersistent,
Mode::Simple if self.input.trim_start().starts_with(':') => {
EffectiveMode::AdvancedOneShot
}
Mode::Simple => EffectiveMode::Simple,
}
}
/// The input view the **live-feedback** walkers (completion, ambient
/// hint, validity verdict, highlight overlays) should see, plus the
/// byte offset stripped from the front and the cursor mapped into the
/// view.
///
/// Under the `:` one-shot escape (ADR-0003) the buffer carries a
/// leading `:` (and an auto-inserted space) that is *not* advanced
/// SQL — submission already strips it before parsing, but the live
/// feedback did not, so the walker bailed at the `:` and resolved
/// nothing (no completion / hint, a spurious error overlay). This
/// returns the stripped SQL exactly as submission sees it, so the
/// feedback matches a real advanced-mode session. `offset` maps any
/// walker-returned byte position (completion `replaced_range`,
/// overlay spans) back to real-buffer coordinates.
///
/// For every non-one-shot input this is the identity
/// `(&input, cursor, 0)`.
#[must_use]
pub fn feedback_view(&self) -> (&str, usize, usize) {
if matches!(self.effective_mode(), EffectiveMode::AdvancedOneShot) {
// The first non-whitespace char is the `:` (per
// `effective_mode`); strip up to and including it, then any
// following whitespace — mirroring submission's
// `trimmed[1..].trim()`.
let leading_ws = self.input.len() - self.input.trim_start().len();
let mut offset = leading_ws + 1; // past the `:`
while offset < self.input.len()
&& self.input.as_bytes()[offset].is_ascii_whitespace()
{
offset += 1;
}
let view = &self.input[offset..];
let cursor = self.input_cursor.saturating_sub(offset).min(view.len());
return (view, cursor, offset);
}
(&self.input, self.input_cursor, 0)
}
/// The validity-indicator verdict for the current input
/// (ADR-0027 §3). `None` when the input would run clean.
///
/// Computed only in simple mode — advanced mode is raw SQL,
/// which the DSL walker does not parse (ADR-0027 §7). A
/// pure query the runtime calls once the typing debounce
/// settles; the result is stored in `input_indicator`.
///
/// ADR-0032 §10.6 — the verdict reads the walker view of
/// the *active* effective mode so a SQL form in Advanced
/// mode lights up the same `[ERR]` / `[WRN]` indicator the
/// DSL surface uses. Without this the SQL predicate
/// warnings (ADR-0032 §11.6) would emit but never reach
/// the validity indicator the user sees.
#[must_use]
pub fn input_validity_verdict(&self) -> Option<crate::dsl::walker::Severity> {
let mode = match self.effective_mode() {
EffectiveMode::Simple => Mode::Simple,
EffectiveMode::AdvancedPersistent
| EffectiveMode::AdvancedOneShot => Mode::Advanced,
};
// Strip the `:` one-shot prefix so the walker verdicts the SQL
// itself, not the escape marker (which it can't parse).
let (view, _cursor, _offset) = self.feedback_view();
crate::dsl::walker::input_verdict_in_mode(view, Some(&self.schema_cache), mode)
}
/// Process one event from the runtime, mutating state and
/// returning any actions for the runtime to enact.
pub fn update(&mut self, event: AppEvent) -> Vec<Action> {
match event {
AppEvent::Key(key) => self.handle_key(key),
AppEvent::Resize { .. } | AppEvent::Tick => Vec::new(),
AppEvent::DslSucceeded {
command,
description,
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): a successful no-op — mark the
// echo ✓ (ADR-0040), then the skip note + structure.
self.mark_oldest_pending_echo(EchoStatus::Ok);
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): successful no-op — echo ✓ + skip note.
self.mark_oldest_pending_echo(EchoStatus::Ok);
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): successful no-op — echo ✓ + skip note.
// `target_table()` returns the index name for `SqlDropIndex`.
self.mark_oldest_pending_echo(EchoStatus::Ok);
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): successful no-op — echo ✓ + skip
// note (the resolved index name; unnamed form's auto-name
// isn't on the command).
self.mark_oldest_pending_echo(EchoStatus::Ok);
self.note_system(crate::t!("ddl.create_index_skipped_exists", name = name));
Vec::new()
}
AppEvent::DslDataSucceeded {
command,
data,
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_query_success(&command, &data);
Vec::new()
}
AppEvent::DslExplainSucceeded { command, plan } => {
self.handle_dsl_explain_success(&command, &plan);
Vec::new()
}
AppEvent::DslShowListSucceeded { command, lines } => {
// Mark the echo ✓ (ADR-0040), then emit the
// worker-formatted list as system output lines.
self.note_ok_summary(&command);
for line in lines {
self.note_system(line);
}
Vec::new()
}
AppEvent::DslShowRelationshipSucceeded { command, data } => {
self.handle_dsl_show_relationship_success(&command, data.as_ref());
Vec::new()
}
AppEvent::DslInsertSucceeded { command, result } => {
self.handle_dsl_insert_success(&command, &result);
Vec::new()
}
AppEvent::DslSeedSucceeded { command, result } => {
self.handle_dsl_seed_success(&command, &result);
Vec::new()
}
AppEvent::DslUpdateSucceeded {
command,
result,
echo,
} => {
self.pending_echo = echo;
self.handle_dsl_update_success(&command, &result);
Vec::new()
}
AppEvent::DslDeleteSucceeded {
command,
result,
echo,
} => {
self.pending_echo = echo;
self.handle_dsl_delete_success(&command, &result);
Vec::new()
}
AppEvent::DslChangeColumnSucceeded {
command,
result,
echo,
dont_convert_caveat,
} => {
self.pending_echo = echo;
self.handle_dsl_change_column_success(&command, result, dont_convert_caveat);
Vec::new()
}
AppEvent::DslAddColumnSucceeded {
command,
result,
echo,
} => {
self.pending_echo = echo;
self.handle_dsl_add_column_success(&command, result);
Vec::new()
}
AppEvent::DslDropColumnSucceeded {
command,
result,
echo,
} => {
self.pending_echo = echo;
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::RelationshipsRefreshed(relationships) => {
trace!(count = relationships.len(), "relationships refreshed");
self.relationships = relationships;
Vec::new()
}
AppEvent::PersistenceFatal {
operation,
path,
message,
} => {
// ADR-0040: if a command's persistence failed fatally,
// resolve its (pending) echo to ✗ before the quit banner,
// so the dying session doesn't leave a `running:` line.
self.mark_oldest_pending_echo(EchoStatus::Err);
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,
mode,
} => {
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);
// Restore the switched-to project's stored input
// mode (ADR-0015 mode-restore amendment, issue #14).
self.mode = mode;
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,
} => {
// ADR-0040: the `replay` echo resolves ✓; the
// `[ok] replay — N command(s)` summary is payload-bearing
// (the count) and stays.
self.mark_oldest_pending_echo(EchoStatus::Ok);
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,
} => {
// ADR-0040: the `replay` echo resolves ✗.
self.mark_oldest_pending_echo(EchoStatus::Err);
// 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()
}
}
}
/// ADR-0046 DC1: advance the navigation focus cycle. From `Input`
/// it enters navigation mode on the Tables panel (revealing +
/// expanding the sidebar via the renderer); the third press returns
/// to the command input.
fn nav_advance(&mut self) {
self.nav_focus = match self.nav_focus {
NavFocus::Input => NavFocus::SidebarTables,
NavFocus::SidebarTables => NavFocus::SidebarRelationships,
NavFocus::SidebarRelationships => NavFocus::Input,
};
trace!(nav_focus = ?self.nav_focus, "navigation focus advanced");
}
/// Leave navigation mode, returning focus to the command input
/// (ADR-0046 DC1 — the `Esc` shortcut for the cycle's last step).
const fn nav_exit(&mut self) {
self.nav_focus = NavFocus::Input;
}
/// ADR-0046 DC3/DC4: key handling while a sidebar panel is focused.
/// `Esc` exits navigation mode; scroll keys drive the focused panel
/// (wired in DC3); every other key is inert because the command
/// input is occluded by the expanded sidebar overlay.
fn handle_nav_key(&mut self, key: KeyEvent) -> Vec<Action> {
match key.code {
KeyCode::Esc => self.nav_exit(),
KeyCode::Up => self.nav_scroll(-1),
KeyCode::Down => self.nav_scroll(1),
KeyCode::PageUp => self.nav_scroll_page(-1),
KeyCode::PageDown => self.nav_scroll_page(1),
_ => {}
}
Vec::new()
}
/// Scroll the focused sidebar panel by `lines` (ADR-0046 DC3); the
/// renderer clamps the offset to the panel's content on the next
/// frame, so over-scrolling is harmless.
const fn nav_scroll(&mut self, lines: i32) {
let slot = match self.nav_focus {
NavFocus::SidebarTables => &mut self.tables_scroll,
NavFocus::SidebarRelationships => &mut self.relationships_scroll,
NavFocus::Input => return,
};
*slot = slot.saturating_add_signed(lines as isize);
}
/// Page-scroll the focused panel by its last reported visible-row
/// count (ADR-0046 DC3).
fn nav_scroll_page(&mut self, dir: i32) {
let visible = match self.nav_focus {
NavFocus::SidebarTables => self.last_tables_visible,
NavFocus::SidebarRelationships => self.last_relationships_visible,
NavFocus::Input => return,
};
self.nav_scroll(dir * (visible.max(1) as i32));
}
fn handle_key(&mut self, key: KeyEvent) -> Vec<Action> {
// On Windows, key events fire for both Press and Release;
// honour only Press to avoid double-handling. Other
// platforms emit Press only, so this is a no-op there.
if key.kind != KeyEventKind::Press {
return Vec::new();
}
trace!(?key, "handle_key");
// ADR-0047 D3: the demo step-caption stealth buffer runs before
// every other gate — even ahead of the badge and the modal gate —
// so it can be authored over the load picker (the `#24` cast) and
// so captured keystrokes never leak into the input, a badge, or a
// command. `Ctrl+]` toggles capture; while capturing, the key is
// consumed here.
if self.demo_mode {
if let Some(actions) = self.handle_demo_caption_key(key) {
return actions;
}
// Not a caption key: any ordinary keystroke dismisses a
// visible caption (it then falls through to normal handling).
self.demo_caption = None;
}
// ADR-0047 D2: in demo mode raise a transient badge for an
// otherwise-invisible key. Done before the modal / nav gates so
// it fires even while a modal is open (the `#24` projects cast)
// or in navigation mode. The runtime times its expiry (D5).
if self.demo_mode
&& let Some(label) = demo_badge_label(&key)
{
self.demo_badge = Some(label);
self.demo_badge_seq = self.demo_badge_seq.wrapping_add(1);
}
// 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-0046 DC1: `Ctrl-O` cycles navigation focus from any state
// (Input → Tables → Relationships → Input), inert only behind a
// modal (handled above).
if (key.code, key.modifiers) == (KeyCode::Char('o'), KeyModifiers::CONTROL) {
self.nav_advance();
return Vec::new();
}
// DC3/DC4: in navigation mode, keys drive the focused sidebar
// panel (scroll) or are inert; the command input is occluded.
if self.nav_focus.in_sidebar() {
return self.handle_nav_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(),
}
}
/// Drive the demo step-caption stealth buffer (ADR-0047 D3).
///
/// Returns `Some(_)` when the key belongs to the caption mechanism
/// (the `Ctrl+]` toggle, or any key while capturing) — the caller
/// then returns it and processes nothing else. Returns `None` when
/// the key is not consumed, so normal handling continues.
///
/// `Ctrl+]` decodes to `Char('5') + CONTROL` (ADR-0047 D3, verified
/// against crossterm 0.29). Only active in demo mode (the caller
/// gates on `self.demo_mode`).
fn handle_demo_caption_key(&mut self, key: KeyEvent) -> Option<Vec<Action>> {
let is_toggle = key.code == KeyCode::Char('5')
&& key.modifiers.contains(KeyModifiers::CONTROL);
if self.demo_caption_capturing {
if is_toggle {
// Commit: a trimmed, non-empty buffer becomes the caption;
// an empty commit dismisses any caption (explicit clear).
self.demo_caption_capturing = false;
let text = std::mem::take(&mut self.demo_caption_buffer);
let trimmed = text.trim();
self.demo_caption =
(!trimmed.is_empty()).then(|| trimmed.to_string());
} else {
match key.code {
// Plain characters accumulate invisibly; the prompt
// and output are untouched.
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
self.demo_caption_buffer.push(c);
}
KeyCode::Backspace => {
self.demo_caption_buffer.pop();
}
// Every other key (Enter, arrows, Tab, …) is inert
// while capturing.
_ => {}
}
}
return Some(Vec::new());
}
if is_toggle {
// Open capture. Starting a new annotation clears any caption
// currently on screen.
self.demo_caption_capturing = true;
self.demo_caption_buffer.clear();
self.demo_caption = None;
return Some(Vec::new());
}
None
}
fn cursor_left(&mut self) {
let mut idx = self.input_cursor;
while idx > 0 {
idx -= 1;
if self.input.is_char_boundary(idx) {
self.input_cursor = idx;
return;
}
}
self.input_cursor = 0;
}
fn cursor_right(&mut self) {
let mut idx = self.input_cursor;
while idx < self.input.len() {
idx += 1;
if self.input.is_char_boundary(idx) {
self.input_cursor = idx;
return;
}
}
self.input_cursor = self.input.len();
}
fn insert_at_cursor(&mut self, c: char) {
// Defensive clamp: callers (and tests) may mutate
// `input` directly; keep the cursor inside the buffer.
if self.input_cursor > self.input.len() {
self.input_cursor = self.input.len();
}
self.input.insert(self.input_cursor, c);
self.input_cursor += c.len_utf8();
}
fn delete_before_cursor(&mut self) {
if self.input_cursor == 0 {
return;
}
// Find the start of the previous character.
let mut idx = self.input_cursor - 1;
while !self.input.is_char_boundary(idx) {
idx -= 1;
}
self.input.replace_range(idx..self.input_cursor, "");
self.input_cursor = idx;
}
/// Tab key handler — insert / cycle forward through the
/// candidates at the cursor (ADR-0022 stage 8). Behaviour
/// is split between single and multi-candidate cases per
/// the user's stage-8 feedback round 2:
///
/// - **Single candidate**: insert with trailing space,
/// no memo. The space is the natural commit; subsequent
/// Tab fresh-computes at the new cursor. Esc/Backspace
/// are normal.
/// - **Multi candidate**: insert WITHOUT trailing space,
/// create memo for cycling. Tab again cycles; any other
/// key clears the memo. Pressing space (the natural
/// "I'm done picking" gesture) clears the memo and adds
/// the space, completing the chosen candidate.
/// - **Memo present**: cycle to next candidate, replacing
/// the inserted text in place (still no trailing space).
/// - **No candidates**: no-op.
fn completion_tab_forward(&mut self) -> Vec<Action> {
self.cancel_history_navigation();
if let Some(memo) = self.last_completion.take() {
let next = memo.next_idx();
self.last_completion = Some(self.replace_inserted(memo, next));
return Vec::new();
}
self.start_or_complete_at(0);
Vec::new()
}
/// Shift-Tab key handler — symmetric to forward; on a
/// fresh multi-candidate position starts from the last
/// candidate (per the user's #2 wrap-from-both-ends).
/// Single candidate behaves identically to Tab.
fn completion_tab_backward(&mut self) -> Vec<Action> {
self.cancel_history_navigation();
if let Some(memo) = self.last_completion.take() {
let prev = memo.prev_idx();
self.last_completion = Some(self.replace_inserted(memo, prev));
return Vec::new();
}
self.start_or_complete_last();
Vec::new()
}
/// Esc / Backspace handler while a completion memo is
/// alive — restore the original text in `inserted_range`
/// and place the cursor where the user was when they hit
/// Tab. The memo is cleared. Only fires on multi-candidate
/// completions (single-candidate paths don't create a
/// memo); the user accepts that single-candidate Tab
/// requires regular backspace to undo.
fn undo_last_completion(&mut self) {
let Some(memo) = self.last_completion.take() else {
return;
};
let (start, end) = memo.inserted_range;
self.input.replace_range(start..end, &memo.original_text);
self.input_cursor = start + memo.original_text.len();
}
fn start_or_complete_at(&mut self, multi_start_idx: usize) {
let Some(comp) = self.completion_for_feedback() 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 Some(comp) = self.completion_for_feedback() 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));
}
}
/// Completion at the cursor, computed against the `:`-stripped
/// feedback view (ADR-0003 one-shot) with its `replaced_range`
/// mapped back to real-buffer coordinates so `commit_*` edit the
/// right span. Identity for non-one-shot input (offset 0).
fn completion_for_feedback(&self) -> Option<crate::completion::Completion> {
let (view, view_cursor, offset) = self.feedback_view();
let mut comp = crate::completion::candidates_at_cursor_in_mode(
view,
view_cursor.min(view.len()),
&self.schema_cache,
self.effective_mode().as_mode(),
)?;
comp.replaced_range = (comp.replaced_range.0 + offset, comp.replaced_range.1 + offset);
Some(comp)
}
/// Single-candidate commit: insert "<text> " (with trailing
/// space) and DO NOT create a memo. The user can keep
/// typing or press Tab again to fresh-complete at the new
/// cursor.
fn commit_unique(&mut self, comp: &crate::completion::Completion) {
let inserted = format!("{} ", comp.candidates[0].text);
self.input
.replace_range(comp.replaced_range.0..comp.replaced_range.1, &inserted);
self.input_cursor = comp.replaced_range.0 + inserted.len();
}
/// Multi-candidate commit: insert just the candidate text
/// (no trailing space) and return the memo carrying the
/// full candidate list for cycling. The user presses space
/// (or any other non-Tab key) to commit the choice — that
/// clears the memo and inserts whatever they typed
/// normally, naturally producing "<chosen> " as the
/// completed text.
fn commit_multi(
&mut self,
comp: crate::completion::Completion,
idx: usize,
) -> crate::completion::LastCompletion {
let inserted = comp.candidates[idx].text.clone();
let original_text =
self.input[comp.replaced_range.0..comp.replaced_range.1].to_string();
self.input
.replace_range(comp.replaced_range.0..comp.replaced_range.1, &inserted);
let new_end = comp.replaced_range.0 + inserted.len();
self.input_cursor = new_end;
crate::completion::LastCompletion {
inserted_range: (comp.replaced_range.0, new_end),
original_text,
candidates: comp.candidates,
selection_idx: idx,
}
}
/// Replace the inserted text with `candidates[idx]` (no
/// trailing space — same multi-candidate convention) and
/// return an updated memo. Used by Tab/Shift-Tab cycling.
fn replace_inserted(
&mut self,
memo: crate::completion::LastCompletion,
idx: usize,
) -> crate::completion::LastCompletion {
let new_inserted = memo.candidates[idx].text.clone();
let (start, end) = memo.inserted_range;
self.input.replace_range(start..end, &new_inserted);
let new_end = start + new_inserted.len();
self.input_cursor = new_end;
crate::completion::LastCompletion {
inserted_range: (start, new_end),
selection_idx: idx,
..memo
}
}
fn delete_at_cursor(&mut self) {
if self.input_cursor >= self.input.len() {
return;
}
// Find the end of the character at the cursor.
let mut idx = self.input_cursor + 1;
while idx < self.input.len() && !self.input.is_char_boundary(idx) {
idx += 1;
}
self.input.replace_range(self.input_cursor..idx, "");
}
/// Move backwards in history (towards older entries).
fn history_back(&mut self) {
if self.history.is_empty() {
return;
}
let next_index = match self.history_cursor {
None => {
// Starting navigation: save the current draft so the
// user can return to it.
self.history_draft = Some(self.input.clone());
self.history.len() - 1
}
Some(0) => 0,
Some(i) => i - 1,
};
self.history_cursor = Some(next_index);
self.input = self.history[next_index].clone();
self.input_cursor = self.input.len();
self.input_scroll_offset = 0;
}
/// 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();
self.input_scroll_offset = 0;
}
fn cancel_history_navigation(&mut self) {
self.history_cursor = None;
// Drop the saved draft: the user has begun editing again,
// so what's in `input` *is* the new draft.
self.history_draft = None;
}
fn push_history(&mut self, line: &str) {
// Submitting a command always ends history navigation —
// the next Up restarts from the newest entry. Reset here,
// before the early-return guards below, so a recalled
// command re-submitted unchanged (a consecutive duplicate)
// doesn't strand the cursor at its old position.
self.history_cursor = None;
self.history_draft = None;
// Skip empties and consecutive duplicates — the same
// trick most shells use to keep navigation pleasant.
if line.is_empty() {
return;
}
if self.history.last().map(String::as_str) == Some(line) {
return;
}
self.history.push(line.to_string());
while self.history.len() > HISTORY_CAPACITY {
self.history.remove(0);
}
}
fn submit(&mut self) -> Vec<Action> {
let raw = std::mem::take(&mut self.input);
self.input_cursor = 0;
self.input_scroll_offset = 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();
}
debug!(
persistent_mode = ?self.mode,
submission_mode = ?submission_mode,
len = effective_input.len(),
"submit"
);
// 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<Action> {
use crate::dsl::{AppCommand, MessagesValue, ModeValue};
debug!(command = ?cmd, "dispatch app command");
match cmd {
AppCommand::Quit => vec![Action::Quit],
AppCommand::Help { topic } => {
match &topic {
Some(t) => self.note_help_topic(t),
None => 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 } => {
// A path-bearing import carries a non-empty path
// from the walker. Bare `import` parses with an
// empty path string — surface the usage hint here
// at dispatch (not a parse error; ADR-0024 replaced
// the old chumsky source-slice path).
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}"));
// Persist the new mode so it is restored on the next
// open (ADR-0015 mode-restore amendment, issue #14).
vec![Action::PersistMode(self.mode)]
}
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),
AppCommand::Copy { scope } => self.handle_copy_command(scope),
}
}
/// `copy` / `copy all` / `copy last` (ADR-0041). Builds the
/// plain-text payload from the output panel and hands it to the
/// runtime via [`Action::CopyToClipboard`]; the confirmation note
/// is pushed *after* the text is captured, so it is never copied.
fn handle_copy_command(&mut self, scope: crate::dsl::CopyScope) -> Vec<Action> {
match self.copy_text(scope) {
None => {
self.note_system(crate::t!("copy.nothing"));
Vec::new()
}
Some(text) => {
let count = text.lines().count();
self.note_system(crate::t!("copy.done", count = count));
vec![Action::CopyToClipboard(text)]
}
}
}
/// The clipboard payload for `scope`: every output line's
/// [`OutputLine::plain_text`] joined by `\n`. `All` is the whole
/// buffer; `Last` is from the most recent echo line to the end
/// (ADR-0041). `None` when there is nothing to copy (empty buffer,
/// or `Last` with no echo line).
fn copy_text(&self, scope: crate::dsl::CopyScope) -> Option<String> {
let start = match scope {
crate::dsl::CopyScope::All => 0,
crate::dsl::CopyScope::Last => self
.output
.iter()
.rposition(|l| l.kind == OutputKind::Echo)?,
};
let lines: Vec<String> = self
.output
.iter()
.skip(start)
.map(OutputLine::plain_text)
.collect();
if lines.is_empty() {
return None;
}
Some(lines.join("\n"))
}
/// `undo` / `redo` dispatch. When undo is disabled (`--no-undo`)
/// the command reports that and does nothing; otherwise it asks
/// the runtime to peek the snapshot and open the confirmation
/// modal (ADR-0006 Amendment 1).
fn handle_undo_command(&mut self, is_redo: bool) -> Vec<Action> {
if !self.undo_enabled {
self.note_system(crate::t!("undo.disabled"));
return Vec::new();
}
if is_redo {
vec![Action::PrepareRedo]
} else {
vec![Action::PrepareUndo]
}
}
fn dispatch_dsl(&mut self, input: &str, submission_mode: EffectiveMode) -> Vec<Action> {
// 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::echo(input, mode));
vec![Action::Replay { path }]
}
Ok(cmd) => {
// Issue #1 sub-task 3: advanced-mode positional
// `INSERT INTO T VALUES (…)` (no column list) with a
// value count that doesn't match the column count gets
// a teaching note here, *before* dispatch. The engine
// would otherwise surface a raw NOT-NULL / type error
// that doesn't mention the column-list override.
if let Some(note) = crate::input_render::form_b_positional_count_mismatch_note(
&cmd,
&self.schema_cache,
) {
// Pre-flight rejection (not executed): plain
// `running:` echo, `status: None` (ADR-0040 scope).
self.push_output(OutputLine {
text: crate::t!("dsl.running", input = input),
kind: OutputKind::Echo,
mode_at_submission: mode,
styled_runs: None,
status: None,
});
self.note_error(note);
return vec![Action::JournalFailure {
source: input.to_string(),
}];
}
// Issue #17: simple-mode (DSL) counterpart. A wrong-count
// DSL insert now parses `Ok` (so the typing-time arity
// diagnostic can fire), so dispatch is gated here — the
// same teaching the old parse-error path showed, now with
// the insert reliably blocked from reaching the worker.
if let Some(notes) = crate::input_render::dsl_insert_count_mismatch_notes(
input,
&cmd,
&self.schema_cache,
) {
// Pre-flight rejection (not executed): plain
// `running:` echo, `status: None` (ADR-0040 scope).
self.push_output(OutputLine {
text: crate::t!("dsl.running", input = input),
kind: OutputKind::Echo,
mode_at_submission: mode,
styled_runs: None,
status: None,
});
for note in notes {
self.note_error(note);
}
self.note_error(render_usage_block(input, mode));
return vec![Action::JournalFailure {
source: input.to_string(),
}];
}
self.push_output(OutputLine::echo(input, mode));
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).
// Parse error (not executed): plain `running:` echo,
// `status: None` — the caret aligns to `running: `
// (ADR-0040 scope).
self.push_output(OutputLine {
text: crate::t!("dsl.running", input = input),
kind: OutputKind::Echo,
mode_at_submission: mode,
styled_runs: None,
status: 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);
}
// Issue #1 sub-task 2's Form B teaching note used to be
// appended here, because a wrong-count Form B insert
// failed to parse and landed in this Err arm. As of issue
// #17 such tuples parse `Ok` (so the typing-time arity
// diagnostic fires) and the teaching + dispatch block now
// live in the Ok arm's `dsl_insert_count_mismatch_notes`
// pre-flight — a single model shared with advanced mode.
// 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, mode));
}
// ADR-0034 §1/§2: a submitted line that failed to
// parse is journalled `err` so it is recallable
// across sessions (the same `source` an `ok`
// command would record). The runtime does the
// append; the App only emits the intent.
vec![Action::JournalFailure {
source: input.to_string(),
}]
}
}
}
/// Emit the standard `[ok] <verb> <subject>` header used by
/// every successful DSL command. Routes through the i18n
/// catalog (ADR-0019 §9 sweep).
/// Resolve the oldest still-`Pending` echo to `status`
/// (ADR-0040). Results arrive in submission order (the db worker
/// is FIFO), so the oldest pending echo is this command's; a
/// finished command can never leave an earlier one stuck on
/// `running:`. No-op if there is no pending echo (e.g. a result
/// for a command whose echo path didn't mark one).
fn mark_oldest_pending_echo(&mut self, status: EchoStatus) {
if let Some(line) = self
.output
.iter_mut()
.find(|l| l.kind == OutputKind::Echo && l.status == Some(EchoStatus::Pending))
{
line.status = Some(status);
}
}
/// Mark a command's echo successful (ADR-0040 — replaces the old
/// `[ok] <verb> <subject>` summary line) and emit the ADR-0038
/// DSL → SQL teaching echo that was stashed on the success event.
fn note_ok_summary(&mut self, command: &Command) {
debug!(verb = command.verb(), "dsl command succeeded");
self.mark_oldest_pending_echo(EchoStatus::Ok);
// ADR-0038 §4: one `OutputKind::TeachingEcho` line per
// statement — the dimmed `Executing SQL:` prefix + the SQL
// re-lexed in advanced mode for highlighting (see
// `ui::render_output_line`'s `TeachingEcho` branch).
if let Some(lines) = self.pending_echo.take() {
for line in lines {
self.push_teaching_echo(&line);
}
}
}
fn handle_dsl_success(&mut self, command: &Command, description: Option<TableDescription>) {
self.note_ok_summary(command);
if let Some(desc) = description.as_ref() {
// ADR-0044 §1 "relationship-relevant" reach: when a
// relationship is the subject of the command (`show table`,
// `add`/`drop relationship`), render the table's
// relationships as compact diagrams; every other DDL echo
// keeps the prose `References:` / `Referenced by:` form.
if matches!(
command,
Command::ShowTable { .. }
| Command::AddRelationship { .. }
| Command::DropRelationship { .. }
) {
debug!(verb = command.verb(), width = self.last_output_width, "render: relationship diagrams (ADR-0044)");
for line in crate::output_render::render_structure_with_diagrams(
desc,
self.last_output_width,
self.mode,
) {
self.push_output(line);
}
} else {
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);
}
}
/// `show relationship <name>` (ADR-0044): render the relationship
/// as a styled two-table diagram, App-side, sized to the current
/// output-panel width. `None` is the friendly not-found line.
fn handle_dsl_show_relationship_success(
&mut self,
command: &Command,
data: Option<&crate::db::RelationshipDiagramData>,
) {
self.note_ok_summary(command);
match data {
Some(data) => {
for line in crate::output_render::render_relationship_diagram(
data,
self.last_output_width,
self.mode,
) {
self.push_output(line);
}
}
None => {
let name = match command {
Command::ShowList { name: Some(n), .. } => n.as_str(),
_ => "",
};
self.note_system(format!("No relationship named `{name}`."));
}
}
}
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);
}
}
/// Render a successful `seed` (ADR-0048): the ✓ echo, the seeded-row
/// count (with a cap note when the unique-value space ran out), the
/// capped preview table (D18), and a Hint-styled advisory naming
/// columns filled with generic text that look like fixed value sets
/// (D12/D13).
fn handle_dsl_seed_success(&mut self, command: &Command, result: &crate::db::SeedResult) {
self.note_ok_summary(command);
let mut summary = crate::t!(
"ok.rows_seeded",
count = result.produced,
table = result.table
);
if result.produced < result.requested {
summary.push(' ');
summary.push_str(&crate::t!("seed.capped", requested = result.requested));
}
self.note_system(summary);
for line in crate::output_render::render_data_table(&result.data) {
self.note_system(line);
}
if !result.advisory_columns.is_empty() {
// `column` (the first advised column) seeds the concrete
// repair examples (D13 Phase 2/3 wording); `columns` lists
// them all.
self.push_category_three_prose(crate::t!(
"seed.advisory_generic",
columns = result.advisory_columns.join(", "),
column = result.advisory_columns[0],
table = result.table
));
}
}
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 / ADR-0038 §6 category 3: 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. De-emphasised per §6 (illuminating prose).
for note in result.client_side_notes {
self.push_category_three_prose(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,
dont_convert_caveat: bool,
) {
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).
// ADR-0038 §6 category 3: both lines are illuminating prose
// (the SQL line is equivalent; the note merely reveals a
// value-add SQL doesn't show). De-emphasised.
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.push_category_three_prose(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.push_category_three_prose(crate::t!(
"client_side.auto_fill_transition",
count = note.auto_filled,
kind = kind
));
}
}
// ADR-0038 §6 category 3 caveat: `--dont-convert` skips the
// client-side layer entirely, so the headline echo is the
// nearest SQL but *not* equivalent (the only Bucket A caveat —
// every other category-3 line is illuminating). Sits between
// the client-side notes and the structure render so it reads
// alongside the echo, not after the table view. De-emphasised
// prose per §6.
if dont_convert_caveat {
self.push_category_three_prose(crate::t!("client_side.dont_convert_caveat"));
}
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"
);
// ADR-0040: the echo line carries the ✗; the redundant
// `"<verb> <subject>" failed:` prefix is dropped — only the
// rendered reason is shown. `note_error` splits on newlines
// internally — refusal diagnostics from `change column …`
// (ADR-0017 §7) flow through as a multi-line bordered table.
self.mark_oldest_pending_echo(EchoStatus::Err);
self.note_error(crate::t!("dsl.failed", 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 <new>` — 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_columns,
..
} => (
Operation::AddRelationship,
Some(parent_table.as_str()),
// Single-column facts model (ADR-0019): the first PK
// column for a compound FK (ADR-0043).
parent_columns.first().map(String::as_str),
),
// m:n builds a junction table; its errors (missing parent,
// no PK, self-reference, name collision) name the relevant
// table in the message, so no fallback table/column here.
C::CreateM2nRelationship { .. } => (Operation::CreateTable, None, None),
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),
// Seed generates inserts; FK/constraint failures read as
// insert errors (ADR-0048).
C::Seed { 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 `show <kind>` list spans no single table; a failure
// routes through Query with no table fallback.
C::ShowList { .. } => (Operation::Query, None, 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 <zip-path> [as <target>]`. The
/// separator is the literal ` as ` (whitespace + "as" +
/// whitespace) so a zip path containing the substring
/// "as" is fine — the separator only matches when
/// surrounded by spaces. `split_once` is used (first
/// occurrence wins), which is the natural reading.
/// Dispatch for the `save` and `save as` commands.
///
/// `save` on a temp project is identical to `save as`
/// (prompts for a target). `save` on a named project is a
/// no-op with a friendly hint, since auto-save guarantees
/// the named project is already persistent (ADR-0015 §11).
fn handle_save_command(&mut self, force_save_as: bool) -> Vec<Action> {
if !force_save_as && !self.project_is_temp {
self.note_system(crate::t!("save.already_saved"));
return Vec::new();
}
let title = if force_save_as {
crate::t!("save.title_as")
} else {
crate::t!("save.title_save")
};
self.modal = Some(Modal::PathEntry(PathEntryModal {
title,
prompt: crate::t!("save.path_prompt"),
input: String::new(),
cursor: 0,
purpose: PathEntryPurpose::SaveAs,
}));
Vec::new()
}
/// Route a keypress through whichever modal is active.
///
/// Each modal owns its own tiny state machine. On
/// confirmation, the modal yields one or more `Action`s
/// for the runtime to enact. On dismissal it simply
/// closes itself.
fn handle_modal_key(&mut self, key: KeyEvent) -> Vec<Action> {
let Some(modal) = self.modal.clone() else {
return Vec::new();
};
match modal {
Modal::RebuildConfirm(_) => self.handle_rebuild_confirm_key(key),
Modal::PathEntry(state) => self.handle_path_entry_key(key, state),
Modal::LoadPicker(state) => self.handle_load_picker_key(key, state),
Modal::UndoConfirm(state) => self.handle_undo_confirm_key(key, &state),
}
}
fn handle_undo_confirm_key(&mut self, key: KeyEvent, state: &UndoConfirmModal) -> Vec<Action> {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
self.modal = None;
if state.is_redo {
vec![Action::Redo]
} else {
vec![Action::Undo]
}
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
self.modal = None;
if state.is_redo {
self.note_system(crate::t!("modal.redo_cancelled"));
} else {
self.note_system(crate::t!("modal.undo_cancelled"));
}
Vec::new()
}
_ => Vec::new(),
}
}
fn handle_rebuild_confirm_key(&mut self, key: KeyEvent) -> Vec<Action> {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
self.modal = None;
vec![Action::Rebuild {
source: "rebuild".to_string(),
}]
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
self.modal = None;
self.note_system(crate::t!("modal.rebuild_cancelled"));
Vec::new()
}
_ => Vec::new(),
}
}
fn handle_path_entry_key(
&mut self,
key: KeyEvent,
mut state: PathEntryModal,
) -> Vec<Action> {
match key.code {
KeyCode::Esc => {
self.modal = None;
self.note_system(crate::t!(
"modal.generic_cancelled",
title = state.title.to_lowercase()
));
Vec::new()
}
KeyCode::Enter => {
let target = state.input.trim().to_string();
if target.is_empty() {
self.note_error(crate::t!("modal.path_entry_empty_name"));
self.modal = Some(Modal::PathEntry(state));
return Vec::new();
}
self.modal = None;
match state.purpose {
PathEntryPurpose::SaveAs => vec![Action::SaveAs {
target,
source: "save as".to_string(),
}],
PathEntryPurpose::LoadByPath => vec![Action::LoadProject {
path: std::path::PathBuf::from(target),
source: "load".to_string(),
}],
}
}
KeyCode::Char(c) => {
state.input.insert(state.cursor, c);
state.cursor += c.len_utf8();
self.modal = Some(Modal::PathEntry(state));
Vec::new()
}
KeyCode::Backspace => {
if state.cursor > 0 {
let before = state.input[..state.cursor].chars().next_back();
if let Some(c) = before {
let new_cursor = state.cursor - c.len_utf8();
state.input.drain(new_cursor..state.cursor);
state.cursor = new_cursor;
}
}
self.modal = Some(Modal::PathEntry(state));
Vec::new()
}
KeyCode::Left => {
if state.cursor > 0
&& let Some(c) = state.input[..state.cursor].chars().next_back()
{
state.cursor -= c.len_utf8();
}
self.modal = Some(Modal::PathEntry(state));
Vec::new()
}
KeyCode::Right => {
if state.cursor < state.input.len()
&& let Some(c) = state.input[state.cursor..].chars().next()
{
state.cursor += c.len_utf8();
}
self.modal = Some(Modal::PathEntry(state));
Vec::new()
}
KeyCode::Home => {
state.cursor = 0;
self.modal = Some(Modal::PathEntry(state));
Vec::new()
}
KeyCode::End => {
state.cursor = state.input.len();
self.modal = Some(Modal::PathEntry(state));
Vec::new()
}
_ => {
self.modal = Some(Modal::PathEntry(state));
Vec::new()
}
}
}
fn handle_load_picker_key(
&mut self,
key: KeyEvent,
mut state: LoadPickerModal,
) -> Vec<Action> {
match &mut state.sub_mode {
LoadPickerSubMode::List => match key.code {
KeyCode::Esc => {
self.modal = None;
self.note_system(crate::t!("modal.load_cancelled"));
Vec::new()
}
// `k` mirrors Up; vi-style keys keep the picker drivable by
// autocast, which can only emit typeable characters (#24).
KeyCode::Up | KeyCode::Char('k') => {
if state.selected > 0 {
state.selected -= 1;
}
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
// `j` mirrors Down (see the Up arm above).
KeyCode::Down | KeyCode::Char('j') => {
if state.selected + 1 < state.entries.len() {
state.selected += 1;
}
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
// `g` jumps to the first entry, `G` to the last (vi convention).
KeyCode::Char('g') => {
state.selected = 0;
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
KeyCode::Char('G') => {
state.selected = state.entries.len().saturating_sub(1);
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
KeyCode::Enter => {
if let Some(entry) = state.entries.get(state.selected).cloned() {
self.modal = None;
return vec![Action::LoadProject {
path: entry.path,
source: "load".to_string(),
}];
}
self.note_error(crate::t!("modal.load_picker_nothing"));
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
KeyCode::Char('b') | KeyCode::Char('B') => {
state.sub_mode = LoadPickerSubMode::PathEntry {
input: String::new(),
cursor: 0,
};
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
_ => {
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
},
LoadPickerSubMode::PathEntry { input, cursor } => match key.code {
KeyCode::Esc => {
state.sub_mode = LoadPickerSubMode::List;
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
KeyCode::Enter => {
let target = input.trim().to_string();
if target.is_empty() {
self.note_error(crate::t!("modal.path_entry_empty_path"));
self.modal = Some(Modal::LoadPicker(state));
return Vec::new();
}
self.modal = None;
vec![Action::LoadProject {
path: std::path::PathBuf::from(target),
source: "load".to_string(),
}]
}
KeyCode::Char(c) => {
input.insert(*cursor, c);
*cursor += c.len_utf8();
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
KeyCode::Backspace => {
if *cursor > 0
&& let Some(c) = input[..*cursor].chars().next_back()
{
let new_cursor = *cursor - c.len_utf8();
input.drain(new_cursor..*cursor);
*cursor = new_cursor;
}
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
KeyCode::Left => {
if *cursor > 0
&& let Some(c) = input[..*cursor].chars().next_back()
{
*cursor -= c.len_utf8();
}
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
KeyCode::Right => {
if *cursor < input.len()
&& let Some(c) = input[*cursor..].chars().next()
{
*cursor += c.len_utf8();
}
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
_ => {
self.modal = Some(Modal::LoadPicker(state));
Vec::new()
}
},
}
}
/// Note the list of currently-supported commands to the
/// output panel.
///
/// Assembled from the command REGISTRY (ADR-0024 §help_id):
/// the framing (`help.intro`, `help.dsl_section`,
/// `help.types_reference`) comes from the catalog, and each
/// command's body is the catalog entry named by its
/// `help_id`. A newly-registered command appears here
/// automatically — no edit to this function or a hand-kept
/// list. Each catalog line becomes its own `OutputLine` so
/// the scroll-position math (one logical line = one display
/// row) stays accurate per the renderer's invariant.
fn note_help(&mut self) {
use crate::dsl::grammar::REGISTRY;
let mut lines: Vec<String> = Vec::new();
lines.push(crate::t!("help.intro"));
// REGISTRY is ordered app-commands first; emit the
// "DSL data commands" sub-header at the first command
// whose help_id leaves the `app.` namespace.
let mut dsl_header_done = false;
for (command, _category) in REGISTRY {
let Some(help_id) = command.help_id else {
continue;
};
if !dsl_header_done && !help_id.starts_with("app.") {
lines.push(crate::t!("help.dsl_section"));
dsl_header_done = true;
}
let key = format!("help.{help_id}");
let body = crate::friendly::translate(&key, &[]);
lines.extend(body.lines().map(str::to_string));
}
lines.extend(
crate::t!("help.types_reference")
.lines()
.map(str::to_string),
);
// H3: point at the focused per-command form.
lines.push(crate::t!("help.detail_hint"));
for line in lines {
self.note_system(line);
}
}
/// Focused per-command help (H3): `help <topic>`, where `topic`
/// is a command entry word (`insert`, `create`, `show`, …) or
/// the special `types`. Renders the help block(s) of every
/// command sharing that entry word — so `help create` covers
/// both the DSL and SQL create forms — or a friendly pointer
/// back to `help` when nothing matches.
fn note_help_topic(&mut self, topic: &str) {
use crate::dsl::grammar::REGISTRY;
let topic = topic.trim();
// `help types` re-shows just the type reference.
if topic.eq_ignore_ascii_case("types") {
for line in crate::t!("help.types_reference").lines() {
self.note_system(line.to_string());
}
return;
}
let mut lines: Vec<String> = Vec::new();
for (command, _category) in REGISTRY {
let Some(help_id) = command.help_id else {
continue;
};
if command.entry.matches(topic) {
let key = format!("help.{help_id}");
let body = crate::friendly::translate(&key, &[]);
lines.extend(body.lines().map(str::to_string));
}
}
if lines.is_empty() {
// No command owns that entry word — name it and point
// back at the full list rather than failing silently.
self.note_system(crate::t!("help.unknown_topic", topic = topic));
return;
}
for line in lines {
self.note_system(line);
}
}
fn handle_messages_command(&mut self, raw: &str) {
let arg = raw.strip_prefix("messages").unwrap_or(raw).trim();
match arg {
"" => {
let current = match self.messages_verbosity {
crate::friendly::Verbosity::Short => "short",
crate::friendly::Verbosity::Verbose => "verbose",
};
self.note_system(crate::t!("messages.show", current = current));
}
"short" => {
self.messages_verbosity = crate::friendly::Verbosity::Short;
self.note_system(crate::t!("messages.set_short"));
}
"verbose" => {
self.messages_verbosity = crate::friendly::Verbosity::Verbose;
self.note_system(crate::t!("messages.set_verbose"));
}
other => self.note_error(crate::t!("messages.unknown", value = other)),
}
}
fn handle_mode_command(&mut self, raw: &str) {
let arg = raw.strip_prefix("mode").unwrap_or(raw).trim();
match arg {
"simple" => {
self.mode = Mode::Simple;
self.note_system(crate::t!("mode.set_simple"));
}
"advanced" => {
self.mode = Mode::Advanced;
self.note_system(crate::t!("mode.set_advanced"));
}
"" => self.note_error(crate::t!("mode.usage")),
other => self.note_error(crate::t!("mode.unknown", value = other)),
}
}
fn note_system(&mut self, text: impl Into<String>) {
self.push_multiline(text.into(), OutputKind::System);
}
/// Push one teaching-echo line (ADR-0038 §4 styled-runs polish).
/// The text is `"<TEACHING_ECHO_LABEL><sql>"`; the `TeachingEcho`
/// kind triggers `ui::render_output_line`'s custom branch, which
/// renders the prefix dimmed (`theme.muted`) and lexes the SQL in
/// advanced mode for syntax highlighting — the same treatment the
/// input echo receives.
fn push_teaching_echo(&mut self, sql: &str) {
let text = format!("{}{sql}", crate::echo::TEACHING_ECHO_LABEL);
self.push_output(OutputLine {
text,
kind: OutputKind::TeachingEcho,
mode_at_submission: self.mode,
styled_runs: None,
status: None,
});
}
/// Push one category-3 prose line (ADR-0038 §6) — the
/// `--dont-convert` caveat and the existing illuminating
/// `client_side.*` notes (shortid auto-fill, type-conversion
/// transforms). The whole line text is rendered dimmed
/// (`OutputStyleClass::Hint` → `theme.muted`); the `[system]`
/// tag keeps its kind styling. De-emphasised, per §6.
fn push_category_three_prose(&mut self, text: impl Into<String>) {
let text = text.into();
let runs = if text.is_empty() {
Vec::new()
} else {
vec![OutputSpan {
byte_range: (0, text.len()),
class: OutputStyleClass::Hint,
}]
};
self.push_output(OutputLine::styled(
text,
OutputKind::System,
self.mode,
runs,
));
}
fn note_error(&mut self, text: impl Into<String>) {
self.push_multiline(text.into(), OutputKind::Error);
}
/// Push possibly-multi-line `text` as a sequence of single-line
/// `OutputLine`s. Keeping one display row per `OutputLine` is
/// what makes the scroll-position math (line count = display
/// rows) accurate; the renderer therefore truncates rather
/// than wraps long lines.
fn push_multiline(&mut self, text: String, kind: OutputKind) {
if text.is_empty() {
self.push_output(OutputLine {
text,
kind,
mode_at_submission: self.mode,
styled_runs: None,
status: 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,
status: 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, mode: Mode) -> 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.
// Mode-aware (ADR-0042 G3): in advanced mode a shared entry
// word shows its SQL forms, not the DSL templates.
let catalog_keys: Vec<&'static str> =
crate::dsl::grammar::usage_key_for_input_in_mode(input, mode)
.map(|key| vec![key])
.or_else(|| {
crate::dsl::grammar::usage_keys_for_input_in_mode(input, mode)
.map(|(_word, all)| all)
})
.unwrap_or_default();
if !catalog_keys.is_empty() {
let mut out = String::from("usage:");
for key in catalog_keys {
let template = crate::friendly::translate(key, &[]);
for line in template.lines() {
out.push('\n');
out.push_str(" ");
out.push_str(line);
}
}
return out;
}
// No-prefix fallback. Each entry word renders backticked
// verbatim (replaces the old `parse.token.keyword.*` catalog
// lookup; ADR-0024 §cleanup-pass §F prescribes the same
// wrapping helper).
let names: Vec<String> = crate::dsl::grammar::entry_words_alphabetised()
.into_iter()
.map(|w| format!("`{w}`"))
.collect();
crate::t!(
"parse.available_commands",
commands = names.join(", ")
)
}
fn render_cascade_effect(effect: &CascadeEffect) -> String {
use crate::dsl::ReferentialAction;
let action_key = match effect.action {
ReferentialAction::Cascade => "db.cascade.action_deleted",
ReferentialAction::SetNull => "db.cascade.action_set_null",
ReferentialAction::Restrict | ReferentialAction::NoAction => {
"db.cascade.action_blocked"
}
};
crate::t!(
"db.cascade.summary",
count = effect.rows_changed,
action = crate::friendly::translate(action_key, &[]),
child_table = effect.child_table,
rel = effect.relationship_name,
on_delete = effect.action,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::ColumnDescription;
use crate::dsl::Type;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use pretty_assertions::assert_eq;
fn key(code: KeyCode) -> AppEvent {
AppEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
fn key_mod(code: KeyCode, mods: KeyModifiers) -> AppEvent {
AppEvent::Key(KeyEvent::new(code, mods))
}
// ---- ADR-0047 (issue #22): demo-mode keystroke badges ----
fn ke(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, mods)
}
#[test]
fn demo_badge_label_maps_the_invisible_keys() {
let none = KeyModifiers::NONE;
assert_eq!(demo_badge_label(&ke(KeyCode::Tab, none)), Some("[TAB]"));
assert_eq!(demo_badge_label(&ke(KeyCode::BackTab, KeyModifiers::SHIFT)), Some("[SHIFT-TAB]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Enter, none)), Some("[ENTER]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Esc, none)), Some("[ESC]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Up, none)), Some("[UP]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Down, none)), Some("[DOWN]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Left, none)), Some("[LEFT]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Right, none)), Some("[RIGHT]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Home, none)), Some("[HOME]"));
assert_eq!(demo_badge_label(&ke(KeyCode::End, none)), Some("[END]"));
assert_eq!(demo_badge_label(&ke(KeyCode::PageUp, none)), Some("[PGUP]"));
assert_eq!(demo_badge_label(&ke(KeyCode::PageDown, none)), Some("[PGDN]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Backspace, none)), Some("[BKSP]"));
assert_eq!(demo_badge_label(&ke(KeyCode::Delete, none)), Some("[DEL]"));
assert_eq!(
demo_badge_label(&ke(KeyCode::Char('o'), KeyModifiers::CONTROL)),
Some("[CTRL-O]")
);
}
#[test]
fn demo_badge_label_none_for_glyphs_and_excluded_chords() {
// Plain characters render their own glyph — no badge.
assert_eq!(demo_badge_label(&ke(KeyCode::Char('a'), KeyModifiers::NONE)), None);
assert_eq!(demo_badge_label(&ke(KeyCode::Char(' '), KeyModifiers::NONE)), None);
// Quit and the (Phase C) caption toggle are deliberately excluded.
assert_eq!(demo_badge_label(&ke(KeyCode::Char('c'), KeyModifiers::CONTROL)), None);
// Ctrl+] decodes to Char('5')+CONTROL — must not badge.
assert_eq!(demo_badge_label(&ke(KeyCode::Char('5'), KeyModifiers::CONTROL)), None);
}
#[test]
fn demo_mode_off_never_sets_a_badge() {
let mut app = App::new();
assert!(!app.demo_mode);
app.update(key(KeyCode::Tab));
assert_eq!(app.demo_badge, None);
assert_eq!(app.demo_badge_seq, 0);
}
#[test]
fn demo_mode_on_sets_badge_and_bumps_seq() {
let mut app = App::new();
app.demo_mode = true;
app.update(key(KeyCode::Tab));
assert_eq!(app.demo_badge, Some("[TAB]"));
assert_eq!(app.demo_badge_seq, 1);
app.update(key(KeyCode::Enter));
assert_eq!(app.demo_badge, Some("[ENTER]"));
assert_eq!(app.demo_badge_seq, 2);
// The same label twice still bumps the seq so the runtime
// restarts the expiry timer.
app.update(key(KeyCode::Enter));
assert_eq!(app.demo_badge, Some("[ENTER]"));
assert_eq!(app.demo_badge_seq, 3);
// A glyph key leaves the badge (and seq) untouched — the
// runtime's timer is what clears it, not the next key.
app.update(key(KeyCode::Char('x')));
assert_eq!(app.demo_badge, Some("[ENTER]"));
assert_eq!(app.demo_badge_seq, 3);
}
#[test]
fn demo_badge_fires_over_an_open_modal() {
// Badges are set before the modal gate, so the `#24` projects
// cast can show [ENTER]/[DOWN] while the load picker is up.
let mut app = App::new();
app.demo_mode = true;
app.modal = Some(Modal::LoadPicker(LoadPickerModal {
entries: Vec::new(),
selected: 0,
sub_mode: LoadPickerSubMode::List,
}));
app.update(key(KeyCode::Down));
assert_eq!(app.demo_badge, Some("[DOWN]"));
assert_eq!(app.demo_badge_seq, 1);
}
// ---- ADR-0047 (issue #22): demo-mode step-caption stealth buffer ----
/// `Ctrl+]` — the caption toggle (decodes to Char('5')+CONTROL).
fn caption_toggle() -> AppEvent {
key_mod(KeyCode::Char('5'), KeyModifiers::CONTROL)
}
#[test]
fn demo_caption_toggle_captures_then_commits() {
let mut app = App::new();
app.demo_mode = true;
app.update(caption_toggle());
assert!(app.demo_caption_capturing, "first Ctrl+] opens capture");
assert_eq!(app.demo_caption, None);
type_str(&mut app, "Press Tab");
// The text accumulates invisibly — nothing on the input line.
assert_eq!(app.input, "");
assert_eq!(app.demo_caption_buffer, "Press Tab");
assert_eq!(app.demo_caption, None, "not shown until committed");
app.update(caption_toggle());
assert!(!app.demo_caption_capturing, "second Ctrl+] commits");
assert_eq!(app.demo_caption.as_deref(), Some("Press Tab"));
assert_eq!(app.demo_caption_buffer, "", "buffer drained on commit");
assert_eq!(app.input, "", "input never touched");
}
#[test]
fn demo_caption_backspace_edits_the_buffer() {
let mut app = App::new();
app.demo_mode = true;
app.update(caption_toggle());
type_str(&mut app, "Helloo");
app.update(key(KeyCode::Backspace));
assert_eq!(app.demo_caption_buffer, "Hello");
assert_eq!(app.input, "");
}
#[test]
fn demo_caption_other_keys_are_inert_while_capturing() {
let mut app = App::new();
app.demo_mode = true;
app.update(caption_toggle());
type_str(&mut app, "note");
// Enter must not submit, Tab must not complete, arrows do nothing.
let a1 = app.update(key(KeyCode::Enter));
let a2 = app.update(key(KeyCode::Tab));
let a3 = app.update(key(KeyCode::Up));
assert!(a1.is_empty() && a2.is_empty() && a3.is_empty());
assert!(app.demo_caption_capturing, "still capturing");
assert_eq!(app.demo_caption_buffer, "note");
assert_eq!(app.input, "");
assert_eq!(app.demo_badge, None, "inert keys raise no badge while capturing");
}
#[test]
fn demo_caption_empty_commit_dismisses() {
let mut app = App::new();
app.demo_mode = true;
app.demo_caption = Some("old".to_string());
// Open (clears the visible caption) then commit empty.
app.update(caption_toggle());
assert_eq!(app.demo_caption, None, "opening clears the visible caption");
app.update(caption_toggle());
assert_eq!(app.demo_caption, None, "empty commit leaves nothing");
assert!(!app.demo_caption_capturing);
}
#[test]
fn demo_caption_cleared_by_next_ordinary_keystroke() {
let mut app = App::new();
app.demo_mode = true;
app.demo_caption = Some("step 1".to_string());
// An ordinary key clears the caption, then is processed normally.
app.update(key(KeyCode::Char('a')));
assert_eq!(app.demo_caption, None);
assert_eq!(app.input, "a", "the key still reaches the input");
}
#[test]
fn demo_caption_captures_over_an_open_modal() {
// The stealth buffer sits before the modal gate, so captions can
// be authored while the load picker is up (the `#24` cast).
let mut app = App::new();
app.demo_mode = true;
app.modal = Some(Modal::LoadPicker(LoadPickerModal {
entries: Vec::new(),
selected: 0,
sub_mode: LoadPickerSubMode::List,
}));
app.update(caption_toggle());
type_str(&mut app, "pick one");
app.update(caption_toggle());
assert_eq!(app.demo_caption.as_deref(), Some("pick one"));
// The modal is untouched by the capture.
assert!(matches!(app.modal, Some(Modal::LoadPicker(_))));
}
#[test]
fn demo_mode_off_makes_ctrl_rbracket_inert() {
let mut app = App::new();
assert!(!app.demo_mode);
app.update(caption_toggle());
type_str(&mut app, "x");
assert!(!app.demo_caption_capturing);
assert_eq!(app.demo_caption, None);
// Ctrl+] did nothing; the later 'x' is an ordinary character.
assert_eq!(app.input, "x");
}
fn type_str(app: &mut App, s: &str) {
for c in s.chars() {
app.update(key(KeyCode::Char(c)));
}
}
fn submit(app: &mut App) -> Vec<Action> {
app.update(key(KeyCode::Enter))
}
/// Render every error-kind output line, one per line, for
/// failed-assertion error messages.
fn error_lines(app: &App) -> String {
app.output
.iter()
.filter(|l| l.kind == OutputKind::Error)
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n")
}
fn output_contains(app: &App, needle: &str) -> bool {
app.output.iter().any(|l| l.text.contains(needle))
}
// ---- undo / redo dispatch + modal (ADR-0006 Amendment 1) ----
#[test]
fn undo_command_emits_prepare_undo() {
let mut app = App::new();
type_str(&mut app, "undo");
assert_eq!(submit(&mut app), vec![Action::PrepareUndo]);
}
#[test]
fn redo_command_emits_prepare_redo() {
let mut app = App::new();
type_str(&mut app, "redo");
assert_eq!(submit(&mut app), vec![Action::PrepareRedo]);
}
#[test]
fn undo_when_disabled_notes_and_emits_no_action() {
let mut app = App::new();
app.undo_enabled = false;
type_str(&mut app, "undo");
let actions = submit(&mut app);
assert!(actions.is_empty(), "no action when disabled: {actions:?}");
assert!(
output_contains(&app, "turned off"),
"expected a 'turned off' note, output: {:?}",
app.output
);
}
#[test]
fn undo_prepared_opens_modal_naming_the_command() {
let mut app = App::new();
let actions = app.update(AppEvent::UndoPrepared {
command: "delete from Customers where id = 2".to_string(),
timestamp: "2026-05-24T10:00:00Z".to_string(),
is_redo: false,
});
assert!(actions.is_empty());
match &app.modal {
Some(Modal::UndoConfirm(m)) => {
assert_eq!(m.command, "delete from Customers where id = 2");
assert!(!m.is_redo);
}
other => panic!("expected UndoConfirm modal, got {other:?}"),
}
}
#[test]
fn undo_modal_y_confirms_and_emits_undo() {
let mut app = App::new();
app.update(AppEvent::UndoPrepared {
command: "x".to_string(),
timestamp: "t".to_string(),
is_redo: false,
});
let actions = app.update(key(KeyCode::Char('y')));
assert_eq!(actions, vec![Action::Undo]);
assert!(app.modal.is_none(), "modal closes on confirm");
}
#[test]
fn redo_modal_y_emits_redo() {
let mut app = App::new();
app.update(AppEvent::UndoPrepared {
command: "x".to_string(),
timestamp: "t".to_string(),
is_redo: true,
});
assert_eq!(app.update(key(KeyCode::Char('y'))), vec![Action::Redo]);
}
#[test]
fn undo_modal_esc_cancels_without_action() {
let mut app = App::new();
app.update(AppEvent::UndoPrepared {
command: "x".to_string(),
timestamp: "t".to_string(),
is_redo: false,
});
let actions = app.update(key(KeyCode::Esc));
assert!(actions.is_empty());
assert!(app.modal.is_none());
assert!(output_contains(&app, "cancelled"));
}
#[test]
fn undo_unavailable_notes_nothing_to_undo() {
let mut app = App::new();
let actions = app.update(AppEvent::UndoUnavailable { is_redo: false });
assert!(actions.is_empty());
assert!(output_contains(&app, "nothing to undo"));
}
#[test]
fn undo_succeeded_closes_modal_and_notes_command() {
let mut app = App::new();
app.modal = Some(Modal::UndoConfirm(UndoConfirmModal {
command: "x".to_string(),
timestamp: "t".to_string(),
is_redo: false,
}));
app.update(AppEvent::UndoSucceeded {
command: "delete from T --all-rows".to_string(),
is_redo: false,
});
assert!(app.modal.is_none());
assert!(output_contains(&app, "delete from T --all-rows"));
}
// ---- ADR-0022 stage 8: Tab completion + Esc/Backspace undo ----
#[test]
fn tab_with_unique_candidate_inserts_with_space_and_no_memo() {
// Single-candidate path: insert "<text> ", no memo.
// Stage-8 follow-up #2 (testing-round-2): no memo
// for unique completions so subsequent Tab fresh-
// computes at the new cursor.
let mut app = App::new();
type_str(&mut app, "cre");
let actions = app.update(key(KeyCode::Tab));
assert!(actions.is_empty());
assert_eq!(app.input, "create ");
assert_eq!(app.input_cursor, 7);
assert!(app.last_completion.is_none());
}
#[test]
fn tab_at_word_boundary_inserts_next_expected_keyword() {
// `change ` → expects only `column`. Single candidate;
// insert "column " with space, no memo. (Uses `change`, not
// `create`: ADR-0045 made `create ` ambiguous — `table` vs
// `m:n` — so it is no longer a single-candidate boundary.)
let mut app = App::new();
type_str(&mut app, "change ");
let actions = app.update(key(KeyCode::Tab));
assert!(actions.is_empty());
assert_eq!(app.input, "change column ");
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() {
// `show ` offers five subcommands in grammar order:
// data / table / tables / relationships / indexes (V5).
let mut app = App::new();
type_str(&mut app, "show ");
for expected in [
"show data",
"show table",
"show tables",
"show relationships",
"show indexes",
"show relationship",
"show index",
] {
app.update(key(KeyCode::Tab));
assert_eq!(app.input, expected);
}
// Wrap-around back to the first.
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 ");
// Backward starts from the last candidate (`index`, the
// V5a singular form).
for expected in ["show index", "show relationship", "show indexes"] {
app.update(key(KeyCode::BackTab));
assert_eq!(app.input, expected);
}
}
#[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 "ch", Tab → "change ", Tab → "change
// column ". (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. `change` is used rather
// than `create`: ADR-0045 made `create ` ambiguous (`table`
// vs `m:n`), so it no longer chains as a unique completion.)
let mut app = App::new();
type_str(&mut app, "ch");
app.update(key(KeyCode::Tab));
assert_eq!(app.input, "change ");
app.update(key(KeyCode::Tab));
assert_eq!(app.input, "change column ");
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());
}
// ---- ADR-0015 mode-restore amendment (issue #14) ----
#[test]
fn mode_command_changes_mode_and_emits_persist_action() {
// The `mode` command flips the live mode AND emits
// `PersistMode` so the runtime records it to project.yaml.
let mut app = App::new();
app.mode = Mode::Advanced;
type_str(&mut app, "mode simple");
let actions = submit(&mut app);
assert_eq!(app.mode, Mode::Simple, "live mode flipped");
assert_eq!(
actions,
vec![Action::PersistMode(Mode::Simple)],
"the mode change is persisted",
);
}
#[test]
fn mode_command_via_one_shot_escape_persists_advanced() {
// Reaching `mode advanced` from simple via the `:` one-shot
// (ADR-0003) still emits the persist action for advanced.
let mut app = App::new();
assert_eq!(app.mode, Mode::Simple);
type_str(&mut app, ":mode advanced");
let actions = submit(&mut app);
assert_eq!(app.mode, Mode::Advanced);
assert!(
actions.contains(&Action::PersistMode(Mode::Advanced)),
"expected PersistMode(Advanced), got {actions:?}",
);
}
// ---- copy to clipboard (ADR-0041, issue #11) ----
/// Two output lines: a completed (`✓`) simple-mode echo and a
/// `[system]` structure line — exercises the echo decoration and
/// the tag prefix in `plain_text`.
fn app_with_two_output_lines() -> App {
let mut app = App::new();
let mut echo = OutputLine::echo("create table T", Mode::Simple);
echo.status = Some(EchoStatus::Ok);
app.output.push_back(echo);
app.output.push_back(OutputLine {
text: " T".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
styled_runs: None,
status: None,
});
app
}
#[test]
fn copy_all_emits_action_with_whole_panel_and_confirms() {
let mut app = app_with_two_output_lines();
let before = app.output.len();
type_str(&mut app, "copy");
let actions = submit(&mut app);
// The payload is every line's rendered form, joined by \n —
// tag + ✓ on the echo, tag on the system line.
let expected = "[simple] create table T ✓\n[system] T";
assert_eq!(
actions,
vec![Action::CopyToClipboard(expected.to_string())],
"copy emits the whole-panel payload",
);
// A confirmation [system] line is appended — and is NOT part
// of the copied text (captured before the push).
assert_eq!(app.output.len(), before + 1, "one confirmation line added");
let note = app.output.back().unwrap();
assert_eq!(note.kind, OutputKind::System);
assert!(
note.text.contains("clipboard"),
"confirmation mentions the clipboard: {:?}",
note.text,
);
assert!(
!expected.contains("clipboard"),
"the confirmation is never in the copied text",
);
}
#[test]
fn copy_bare_is_identical_to_copy_all() {
let mut a1 = app_with_two_output_lines();
type_str(&mut a1, "copy");
let bare = submit(&mut a1);
let mut a2 = app_with_two_output_lines();
type_str(&mut a2, "copy all");
let all = submit(&mut a2);
assert_eq!(bare, all, "`copy` defaults to `copy all`");
}
#[test]
fn copy_last_slices_from_the_most_recent_echo() {
let mut app = app_with_two_output_lines();
// An earlier, unrelated command + a fresh echo with a body.
app.output.push_front(OutputLine {
text: "earlier note".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
styled_runs: None,
status: None,
});
let mut echo2 = OutputLine::echo("show data T", Mode::Simple);
echo2.status = Some(EchoStatus::Ok);
app.output.push_back(echo2);
app.output.push_back(OutputLine {
text: "(0 rows)".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
styled_runs: None,
status: None,
});
type_str(&mut app, "copy last");
let actions = submit(&mut app);
// Only the LAST command (its echo + body), not the earlier one.
let expected = "[simple] show data T ✓\n[system] (0 rows)";
assert_eq!(actions, vec![Action::CopyToClipboard(expected.to_string())]);
}
#[test]
fn copy_on_empty_panel_notes_nothing_and_emits_no_action() {
let mut app = App::new();
assert!(app.output.is_empty());
type_str(&mut app, "copy");
let actions = submit(&mut app);
assert!(actions.is_empty(), "nothing to copy → no clipboard action");
let note = app.output.back().expect("a 'nothing to copy' note");
assert_eq!(note.kind, OutputKind::System);
assert!(note.text.contains("nothing to copy"), "{:?}", note.text);
}
#[test]
fn copy_last_without_an_echo_notes_nothing() {
// A panel with only echo-less [system] lines has no "last
// command" boundary → nothing to copy (ADR-0041 boundary note).
let mut app = App::new();
app.output.push_back(OutputLine {
text: "just a note".to_string(),
kind: OutputKind::System,
mode_at_submission: Mode::Simple,
styled_runs: None,
status: None,
});
type_str(&mut app, "copy last");
let actions = submit(&mut app);
assert!(actions.is_empty());
assert!(
app.output.back().unwrap().text.contains("nothing to copy"),
"no echo → nothing to copy",
);
}
#[test]
fn copy_with_unknown_target_is_a_friendly_error() {
let mut app = app_with_two_output_lines();
type_str(&mut app, "copy sideways");
let actions = submit(&mut app);
assert!(
!actions.iter().any(|a| matches!(a, Action::CopyToClipboard(_))),
"an unknown target does not copy",
);
let rendered = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(
rendered.contains("unknown copy target"),
"friendly copy.unknown wording shown: {rendered}",
);
}
#[test]
fn project_switched_event_restores_the_stored_mode() {
// A switch carries the target project's stored mode; the
// App adopts it ("loading triggers the mode switch").
let mut app = App::new();
assert_eq!(app.mode, Mode::Simple);
app.update(AppEvent::ProjectSwitched {
display_name: "Other".to_string(),
is_temp: false,
history_entries: Vec::new(),
mode: Mode::Advanced,
});
assert_eq!(app.mode, Mode::Advanced);
}
#[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),
);
}
/// Helper: install a `Customers(id serial, Name text, Age int,
/// SerNo serial)` schema cache on `app` for the Form-B education
/// tests (issue #1).
fn install_customers_schema_two_serials(app: &mut App) {
use crate::completion::TableColumn;
use crate::dsl::types::Type;
let cols = [
("id", Type::Serial),
("Name", Type::Text),
("Age", Type::Int),
("SerNo", Type::Serial),
];
app.schema_cache.tables.push("Customers".to_string());
let tc: Vec<TableColumn> = cols
.iter()
.map(|(n, t)| TableColumn {
name: (*n).to_string(),
user_type: *t,
not_null: false,
has_default: false,
})
.collect();
for c in &tc {
app.schema_cache.columns.push(c.name.clone());
}
app.schema_cache
.table_columns
.insert("Customers".to_string(), tc);
}
#[test]
fn simple_mode_submit_form_b_extra_value_shows_only_one_advice_line() {
// Issue #1 sub-task 2 + sub-task 1 interaction: in the
// 3-column Form B case (`Customers(id serial, Name, Email)` +
// 3 values, e.g. `insert into Customers values (1, 'Alice',
// 'a@b.c')`) the line IS valid in advanced mode, so the
// cross-mode pointer fires. The sub-task 2 teaching note
// ("list every column") would be parallel advice — both are
// valid escape hatches — and showing both clutters the error.
// The teaching note must defer to the cross-mode pointer.
use crate::completion::TableColumn;
use crate::dsl::types::Type;
let mut app = App::new();
let cols = [
("id", Type::Serial),
("Name", Type::Text),
("Email", Type::Text),
];
app.schema_cache.tables.push("Customers".to_string());
let tc: Vec<TableColumn> = cols
.iter()
.map(|(n, t)| TableColumn {
name: (*n).to_string(),
user_type: *t,
not_null: false,
has_default: false,
})
.collect();
for c in &tc {
app.schema_cache.columns.push(c.name.clone());
}
app.schema_cache
.table_columns
.insert("Customers".to_string(), tc);
type_str(
&mut app,
"insert into Customers values (1, 'Alice', 'a@b.c')",
);
let _ = submit(&mut app);
let out = error_lines(&app);
// The cross-mode pointer (sub-task 1) fires — the line works
// in advanced. Substring "mode advanced" is the durable
// actionable fragment.
assert!(
out.contains("mode advanced"),
"cross-mode pointer must fire for the 3-col line: {out}",
);
// The sub-task 2 teaching note is suppressed: no "list every
// column" / "auto-generated and filled" prose alongside it.
assert!(
!out.contains("auto-generated and filled"),
"sub-task 2 note must defer to the cross-mode pointer: {out}",
);
}
#[test]
fn advanced_mode_submit_of_form_b_value_count_mismatch_shows_preflight_note() {
// Issue #1 sub-task 3: advanced-mode positional `INSERT INTO T
// VALUES (…)` (no column list) requires every column. When the
// value count doesn't match, today's flow lets the engine
// produce a raw constraint or type error; we'd rather catch it
// at dispatch time and surface a teaching note that names the
// table's columns and shows the column-list override.
let mut app = App::new();
install_customers_schema_two_serials(&mut app);
app.mode = Mode::Advanced;
type_str(&mut app, "insert into Customers values ('Oli', 52, 3)");
let actions = submit(&mut app);
// The pre-flight rejected the line — no ExecuteDsl dispatch.
assert!(
!actions
.iter()
.any(|a| matches!(a, Action::ExecuteDsl { .. })),
"advanced-mode pre-flight must suppress dispatch on Form-B count mismatch; got: {actions:?}",
);
let out = error_lines(&app);
// The teaching note names the rule …
assert!(
out.contains("every column"),
"missing the positional-VALUES rule in: {out}",
);
// … names the table's columns so the user can see what's needed …
assert!(
out.contains("Name") && out.contains("Age") && out.contains("id") && out.contains("SerNo"),
"missing the column-name list in: {out}",
);
// … and shows the column-list override targeting the non-auto columns.
assert!(
out.contains("insert into Customers (Name, Age)"),
"missing the column-list override example in: {out}",
);
}
#[test]
fn simple_mode_submit_of_form_b_extra_value_teaches_serial_skip() {
// Issue #1 sub-task 2: when the user supplies too many positional
// values in simple-mode Form B, the bare "expected `)`" parse
// error doesn't explain why fewer values are expected. The
// submit error gets a teaching line that names the columns
// Form B fills automatically, names the columns it expects
// values for, and points at the column-list (Form A) override.
let mut app = App::new();
install_customers_schema_two_serials(&mut app);
type_str(&mut app, "insert into Customers values ('Oli', 52, 3)");
let _ = submit(&mut app);
let out = error_lines(&app);
// The teaching line names the user-supplied columns …
assert!(out.contains("Name") && out.contains("Age"), "missing non-auto column names in: {out}");
// … the auto-generated columns …
assert!(out.contains("id") && out.contains("SerNo"), "missing auto column names in: {out}");
// … signals the contract …
assert!(
out.contains("auto-generated"),
"missing the contract word in: {out}",
);
// … and shows the Form-A override path with every column listed.
assert!(
out.contains("insert into Customers (id, Name, Age, SerNo)"),
"missing the Form-A override example in: {out}",
);
// Issue #1 sub-task 1 gate: the cross-mode pointer must be
// suppressed for the user's reported case (count mismatches in
// advanced too — switching modes wouldn't help). Without this
// assertion, a regression of the gate would silently restore
// the misleading pointer alongside the teaching note.
assert!(
!out.contains("mode advanced"),
"cross-mode pointer must NOT fire for the 4-col mismatch case: {out}",
);
}
#[test]
fn simple_mode_submit_of_form_b_undersupply_teaches() {
// Issue #1 siblings task: simple-mode under-supply
// (`insert into Customers values ('Oli')` for a 4-col table
// whose Form B expects 2 values for `Name`, `Age`). Today the
// user sees a bare parse error; with the extended teaching
// note they get the same forward-looking explanation as the
// over-supply case — what's expected, what's auto-filled, how
// to use the column-list form to override.
let mut app = App::new();
install_customers_schema_two_serials(&mut app);
type_str(&mut app, "insert into Customers values ('Oli')");
let _ = submit(&mut app);
let out = error_lines(&app);
assert!(
out.contains("Name") && out.contains("Age"),
"missing non-auto column names in: {out}",
);
assert!(
out.contains("id") && out.contains("SerNo"),
"missing auto column names in: {out}",
);
assert!(
out.contains("auto-generated"),
"missing contract word in: {out}",
);
assert!(
out.contains("insert into Customers (id, Name, Age, SerNo)"),
"missing column-list override in: {out}",
);
}
#[test]
fn simple_mode_submit_of_form_b_oversupply_beyond_total_teaches() {
// Issue #1 siblings task: simple-mode over-supply *beyond* the
// total column count (5 values for a 4-col table). The
// teaching note still fires — the explanation of Form B's
// contract applies regardless of how far past Form B's count
// the user went.
let mut app = App::new();
install_customers_schema_two_serials(&mut app);
type_str(
&mut app,
"insert into Customers values ('Oli', 52, 3, 13, 99)",
);
let _ = submit(&mut app);
let out = error_lines(&app);
assert!(
out.contains("Name") && out.contains("Age"),
"missing non-auto column names in: {out}",
);
assert!(
out.contains("auto-generated"),
"missing contract word in: {out}",
);
assert!(
out.contains("insert into Customers (id, Name, Age, SerNo)"),
"missing column-list override in: {out}",
);
}
#[test]
fn advanced_mode_submit_with_all_values_for_serials_succeeds_no_preflight() {
// Issue #1 — the user explicitly confirmed that supplying every
// column including the serials works in advanced mode
// (`insert into Customers values (13, 'Oli', 42, 13)`). The
// pre-flight must not interfere with that happy path: it fires
// only on count mismatch, and the dispatch reaches the worker
// normally. This locks down the bottom edge of the pre-flight
// gate.
let mut app = App::new();
install_customers_schema_two_serials(&mut app);
app.mode = Mode::Advanced;
type_str(
&mut app,
"insert into Customers values (13, 'Oli', 42, 13)",
);
let actions = submit(&mut app);
assert!(
actions
.iter()
.any(|a| matches!(a, Action::ExecuteDsl { .. })),
"the line must dispatch normally; pre-flight must not fire when value count matches column count; got: {actions:?}",
);
// And no teaching note prose appears in the output …
let out = error_lines(&app);
assert!(
!out.contains("requires a value for every column"),
"pre-flight must stay silent when counts match: {out}",
);
}
#[test]
fn simple_mode_submit_of_form_b_count_mismatch_does_not_dispatch() {
// Issue #17 EXECUTION SAFETY. Once simple-mode wrong-count Form B
// tuples parse `Ok` (so the typing-time arity diagnostic can
// fire), the submit path must still NOT dispatch the insert — a
// wrong-count insert would otherwise reach the worker and fail
// (or, worse, write the wrong row). The unified Ok-arm pre-flight
// must block dispatch exactly as the old parse-error path did.
let mut app = App::new();
install_customers_schema_two_serials(&mut app);
type_str(&mut app, "insert into Customers values ('Oli', 52, 3)");
let actions = submit(&mut app);
assert!(
!actions.iter().any(|a| matches!(a, Action::ExecuteDsl { .. })),
"simple-mode Form B count mismatch must NOT dispatch; got: {actions:?}",
);
}
#[test]
fn simple_mode_submit_of_form_b_undersupply_does_not_dispatch() {
// Companion to the above for under-supply.
let mut app = App::new();
install_customers_schema_two_serials(&mut app);
type_str(&mut app, "insert into Customers values ('Oli')");
let actions = submit(&mut app);
assert!(
!actions.iter().any(|a| matches!(a, Action::ExecuteDsl { .. })),
"simple-mode Form B under-supply must NOT dispatch; got: {actions:?}",
);
}
#[test]
fn simple_mode_submit_of_form_a_count_mismatch_does_not_dispatch() {
// Form A (explicit column list) wrong count must also not
// dispatch — it previously parse-errored; the unified pre-flight
// must keep it blocked.
let mut app = App::new();
install_customers_schema_two_serials(&mut app);
type_str(&mut app, "insert into Customers (Name, Age) values ('Oli')");
let actions = submit(&mut app);
assert!(
!actions.iter().any(|a| matches!(a, Action::ExecuteDsl { .. })),
"simple-mode Form A count mismatch must NOT dispatch; got: {actions:?}",
);
}
#[test]
fn simple_mode_submit_of_sql_construct_appends_advanced_pointer() {
// ADR-0033 Amendment 3 (+ Amendment 5): submitting a line in
// simple mode that fails as DSL but would be valid 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 with a real schema (the validity verdict needs the
// table to exist; an unknown-table diagnostic would correctly
// suppress the pointer).
use crate::completion::TableColumn;
use crate::dsl::types::Type;
let mut app = App::new();
app.schema_cache.tables.push("T".to_string());
let tc = vec![
TableColumn {
name: "a".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
TableColumn {
name: "b".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
];
for c in &tc {
app.schema_cache.columns.push(c.name.clone());
}
app.schema_cache
.table_columns
.insert("T".to_string(), tc);
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("mode advanced"));
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);
// The pointer's current phrasing (`also_valid_sql`) is
// "trying to write SQL? …"; an unknown command produces no
// advanced-mode hint at all, so we look for any line carrying
// the "mode advanced" actionable fragment that the pointer
// always emits.
let has_pointer = app
.output
.iter()
.any(|l| l.text.contains("mode advanced"));
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(vec!["CREATE TABLE Other (id serial PRIMARY KEY)".to_string()]),
});
let texts: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
// ADR-0040: no `[ok]` summary; with no preceding `running:` echo
// (event fired directly), the teaching echo leads.
assert!(
!texts.iter().any(|t| t.starts_with("[ok]")),
"no [ok] summary line (ADR-0040): {texts:?}",
);
let echo_idx = texts
.iter()
.position(|t| t.contains("Executing SQL:"))
.expect("an echo line");
assert_eq!(echo_idx, 0, "teaching echo leads the output: {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 bucket_a_success_events_render_the_teaching_echo_beneath_ok() {
// ADR-0038 Phase 1: every Bucket A success event that gained an
// `echo` field stashes it for `note_ok_summary` to render
// immediately beneath `[ok]`. One case per event guards its
// update() arm's stash + ordering — including the handlers that
// render extra content (structure / notes), where the echo must
// still sit directly beneath `[ok]`, above that content.
use crate::db::{
AddColumnResult, ChangeColumnTypeResult, DataResult, DeleteResult, DropColumnResult,
UpdateResult,
};
use crate::dsl::command::{ChangeColumnMode, RowFilter};
use crate::dsl::value::Value;
fn assert_echo_beneath_ok(app: &App, expected: &str) {
let texts: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
// ADR-0040: no `[ok]` summary line. These events are fired
// without a preceding `running:` echo (they bypass
// `dispatch_dsl`), so the teaching echo — pushed first by
// `note_ok_summary` — leads the output.
assert!(
!texts.iter().any(|t| t.starts_with("[ok]")),
"no [ok] summary line (ADR-0040): {texts:?}",
);
let echo_idx = texts
.iter()
.position(|t| t.contains("Executing SQL:"))
.expect("an echo line");
assert_eq!(echo_idx, 0, "teaching echo leads the output: {texts:?}");
assert!(texts[echo_idx].contains(expected), "echo carries the SQL: {texts:?}");
// ADR-0038 §4 polish: every success arm now wires the echo as
// `OutputKind::TeachingEcho` so `ui::render_output_line` fires
// the dim-prefix + advanced-lex custom branch. Pinning this
// here guards every event-arm wiring covered by this test —
// a future refactor that regressed `push_teaching_echo` to
// emit `System` would leave the text intact but break the
// styling, and would be caught here.
assert_eq!(
app.output[echo_idx].kind,
OutputKind::TeachingEcho,
"echo line carries TeachingEcho kind: {:?}",
app.output[echo_idx],
);
}
fn empty_data() -> DataResult {
DataResult {
table_name: "T".to_string(),
columns: Vec::new(),
column_types: Vec::new(),
rows: Vec::new(),
}
}
// show data → DslDataSucceeded (the post-execution query path).
let mut app = App::new();
app.update(AppEvent::DslDataSucceeded {
command: Command::ShowData {
name: "T".to_string(),
filter: None,
limit: None,
},
data: empty_data(),
echo: Some(vec!["SELECT * FROM T".to_string()]),
});
assert_echo_beneath_ok(&app, "SELECT * FROM T");
// update … --all-rows → DslUpdateSucceeded.
let mut app = App::new();
app.update(AppEvent::DslUpdateSucceeded {
command: Command::Update {
table: "T".to_string(),
assignments: vec![("v".to_string(), Value::Number("1".to_string()))],
filter: RowFilter::AllRows,
},
result: UpdateResult {
rows_affected: 1,
data: empty_data(),
},
echo: Some(vec!["UPDATE T SET v = 1".to_string()]),
});
assert_echo_beneath_ok(&app, "UPDATE T SET v = 1");
// delete … --all-rows → DslDeleteSucceeded.
let mut app = App::new();
app.update(AppEvent::DslDeleteSucceeded {
command: Command::Delete {
table: "T".to_string(),
filter: RowFilter::AllRows,
},
result: DeleteResult {
rows_affected: 1,
cascade: Vec::new(),
data: empty_data(),
},
echo: Some(vec!["DELETE FROM T".to_string()]),
});
assert_echo_beneath_ok(&app, "DELETE FROM T");
// add column → DslAddColumnSucceeded (handler also renders structure).
let mut app = App::new();
app.update(AppEvent::DslAddColumnSucceeded {
command: Command::AddColumn {
table: "T".to_string(),
column: "c".to_string(),
ty: Type::Int,
not_null: false,
unique: false,
default: None,
check: None,
},
result: AddColumnResult {
description: sample_description("T"),
client_side_notes: Vec::new(),
},
echo: Some(vec!["ALTER TABLE T ADD COLUMN c int".to_string()]),
});
assert_echo_beneath_ok(&app, "ALTER TABLE T ADD COLUMN c int");
// drop column → DslDropColumnSucceeded.
let mut app = App::new();
app.update(AppEvent::DslDropColumnSucceeded {
command: Command::DropColumn {
table: "T".to_string(),
column: "c".to_string(),
cascade: false,
},
result: DropColumnResult {
description: sample_description("T"),
dropped_indexes: Vec::new(),
},
echo: Some(vec!["ALTER TABLE T DROP COLUMN c".to_string()]),
});
assert_echo_beneath_ok(&app, "ALTER TABLE T DROP COLUMN c");
// change column → DslChangeColumnSucceeded.
let mut app = App::new();
app.update(AppEvent::DslChangeColumnSucceeded {
command: Command::ChangeColumnType {
table: "T".to_string(),
column: "c".to_string(),
ty: Type::Text,
mode: ChangeColumnMode::Default,
},
result: ChangeColumnTypeResult {
description: sample_description("T"),
client_side: None,
},
echo: Some(vec!["ALTER TABLE T ALTER COLUMN c SET DATA TYPE text".to_string()]),
dont_convert_caveat: false,
});
assert_echo_beneath_ok(&app, "ALTER TABLE T ALTER COLUMN c SET DATA TYPE text");
}
#[test]
fn add_column_client_side_notes_render_as_category_three_prose() {
// ADR-0038 §4 polish (broader scope): the existing illuminating
// `client_side_notes` on an `AddColumnResult` (e.g. the shortid
// / serial auto-fill messages) now route through
// `push_category_three_prose` — a System line with a whole-text
// Hint span — so every category-3 prose line renders dim
// consistently per §6, not just the new DontConvert caveat.
//
// The broader scope was user-confirmed in the polish session
// (see ADR-0038 Status); this test pins that wiring so a
// regression to plain `note_system` would be caught.
use crate::db::{AddColumnResult, ColumnDescription};
let mut app = App::new();
app.update(AppEvent::DslAddColumnSucceeded {
command: Command::AddColumn {
table: "Customers".to_string(),
column: "code".to_string(),
ty: Type::ShortId,
not_null: false,
unique: false,
default: None,
check: None,
},
result: AddColumnResult {
description: TableDescription {
name: "Customers".to_string(),
columns: vec![ColumnDescription {
name: "code".to_string(),
user_type: Some(Type::ShortId),
sqlite_type: "TEXT".to_string(),
notnull: false,
primary_key: false,
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(),
},
// A representative illuminating note — the wording is
// the one the worker would emit via the
// `client_side.auto_fill_add_shortid` i18n key.
client_side_notes: vec![
"[client-side] 5 row(s) given auto-generated shortid values. In raw SQL this would need an explicit UPDATE to populate.".to_string(),
],
},
echo: None, // simple-mode submission, no echo
});
let note_line = app
.output
.iter()
.find(|l| l.text.starts_with("[client-side]"))
.expect("the illuminating client_side note");
assert_eq!(note_line.kind, OutputKind::System);
let runs = note_line
.styled_runs
.as_ref()
.expect("client_side note carries a styled-runs payload after the polish");
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].class, OutputStyleClass::Hint);
assert_eq!(
runs[0].byte_range,
(0, note_line.text.len()),
"the dim span covers the whole prose body — same shape as the caveat",
);
}
#[test]
fn polished_echo_carries_teaching_echo_kind_and_caveat_a_hint_span() {
// ADR-0038 §4 styled-runs polish: the App-side wiring places
// every echo line as an OutputKind::TeachingEcho (so
// `ui::render_output_line`'s custom branch fires — dim prefix
// + lexed SQL) and every category-3 prose line as a System
// line with a single Hint span covering the whole text (so
// the body renders dimmed via `output_span_style`).
use crate::db::ChangeColumnTypeResult;
let mut app = App::new();
app.update(AppEvent::DslChangeColumnSucceeded {
command: Command::ChangeColumnType {
table: "T".to_string(),
column: "c".to_string(),
ty: Type::Int,
mode: crate::dsl::ChangeColumnMode::DontConvert,
},
result: ChangeColumnTypeResult {
description: sample_description("T"),
client_side: None,
},
echo: Some(vec![
"ALTER TABLE T ALTER COLUMN c SET DATA TYPE int".to_string(),
]),
dont_convert_caveat: true,
});
// The echo line is a TeachingEcho.
let echo_line = app
.output
.iter()
.find(|l| l.text.contains("Executing SQL:"))
.expect("an echo line");
assert_eq!(
echo_line.kind,
OutputKind::TeachingEcho,
"echo line carries TeachingEcho so ui.rs fires the dim-prefix + lex-rest branch",
);
// The echo line carries no styled_runs payload — the custom
// ui.rs branch builds its own spans from the kind alone.
assert!(
echo_line.styled_runs.is_none(),
"echo line uses kind-driven custom rendering, not styled-runs",
);
// The caveat is a System line with a single Hint span covering
// the whole text — the whole prose body renders dim.
let caveat_line = app
.output
.iter()
.find(|l| l.text.contains("`--dont-convert` kept the stored values"))
.expect("a caveat line");
assert_eq!(caveat_line.kind, OutputKind::System);
let runs = caveat_line
.styled_runs
.as_ref()
.expect("caveat carries a styled-runs payload");
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].class, OutputStyleClass::Hint);
assert_eq!(
runs[0].byte_range,
(0, caveat_line.text.len()),
"the dim span covers the entire prose body",
);
}
#[test]
fn change_column_dont_convert_renders_the_caveat_between_notes_and_structure() {
// ADR-0038 §6 category 3 (Phase 3): when `change column …
// --dont-convert` ran in an advanced effective mode, the runtime
// sets `dont_convert_caveat = true`; the App emits the prose
// caveat between the existing client-side notes (none here, since
// --dont-convert skips that layer entirely) and the structure
// render, so it reads alongside the echo. Simple mode ⇒
// `dont_convert_caveat = false` ⇒ no caveat line.
use crate::db::ChangeColumnTypeResult;
use crate::dsl::ChangeColumnMode;
// Advanced mode + DontConvert → caveat fires.
let mut app = App::new();
app.update(AppEvent::DslChangeColumnSucceeded {
command: Command::ChangeColumnType {
table: "T".to_string(),
column: "c".to_string(),
ty: Type::Int,
mode: ChangeColumnMode::DontConvert,
},
result: ChangeColumnTypeResult {
description: sample_description("T"),
// --dont-convert skips the client-side layer → None.
client_side: None,
},
echo: Some(vec![
"ALTER TABLE T ALTER COLUMN c SET DATA TYPE int".to_string(),
]),
dont_convert_caveat: true,
});
let texts: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
let echo_idx = texts
.iter()
.position(|t| t.contains("Executing SQL:"))
.expect("echo line");
let caveat_idx = texts
.iter()
.position(|t| t.contains("`--dont-convert` kept the stored values"))
.expect("caveat line");
assert!(
caveat_idx > echo_idx,
"caveat sits after the echo (reads alongside the line above): {texts:?}",
);
// And before the structure render (the structure shows column names).
if let Some(structure_idx) = texts.iter().position(|t| t.contains("Columns")) {
assert!(
caveat_idx < structure_idx,
"caveat sits before the structure render: {texts:?}",
);
}
// Simple mode → no echo, no caveat.
let mut app = App::new();
app.update(AppEvent::DslChangeColumnSucceeded {
command: Command::ChangeColumnType {
table: "T".to_string(),
column: "c".to_string(),
ty: Type::Int,
mode: ChangeColumnMode::DontConvert,
},
result: ChangeColumnTypeResult {
description: sample_description("T"),
client_side: None,
},
echo: None,
dont_convert_caveat: false,
});
assert!(
!app.output
.iter()
.any(|l| l.text.contains("--dont-convert")),
"no caveat in simple mode (no echo to refer to)",
);
}
#[test]
fn bucket_b_multi_line_echo_renders_one_line_per_statement_beneath_ok() {
// ADR-0038 §6 category 2 / §4 / Phase 2 Slice 2b: a `drop column
// --cascade` echo carries one `DROP INDEX <name>` line per
// covering index plus the final `ALTER TABLE … DROP COLUMN …`.
// The App renders each as its own `Executing SQL:` line beneath
// `[ok]`, in order — the styled-runs polish refines the
// presentation later, but ordering and one-per-statement are the
// semantic invariants pinned here.
use crate::db::DropColumnResult;
let mut app = App::new();
app.update(AppEvent::DslDropColumnSucceeded {
command: Command::DropColumn {
table: "Customers".to_string(),
column: "Email".to_string(),
cascade: true,
},
result: DropColumnResult {
description: sample_description("Customers"),
dropped_indexes: vec![
"Customers_Email_idx".to_string(),
"Customers_Email_Day_idx".to_string(),
],
},
echo: Some(vec![
"DROP INDEX Customers_Email_idx".to_string(),
"DROP INDEX Customers_Email_Day_idx".to_string(),
"ALTER TABLE Customers DROP COLUMN Email".to_string(),
]),
});
let texts: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
// ADR-0040: no `[ok]` summary; the event is fired without a
// preceding `running:` echo, so the three teaching echoes lead
// the output at indices 0/1/2, in order.
assert!(
!texts.iter().any(|t| t.starts_with("[ok]")),
"no [ok] summary line (ADR-0040): {texts:?}",
);
assert!(
texts[0].contains("Executing SQL: DROP INDEX Customers_Email_idx"),
"first echo line: {texts:?}",
);
assert!(
texts[1].contains("Executing SQL: DROP INDEX Customers_Email_Day_idx"),
"second echo line: {texts:?}",
);
// ADR-0038 §4 polish: every one of the multi-statement echo lines
// carries TeachingEcho — the polish styling fires per line. A
// regression that pushed the multi-line case as System would
// leave the text intact but break the per-line styling.
for (idx, label) in [(0, "first"), (1, "second"), (2, "third")] {
assert_eq!(
app.output[idx].kind,
OutputKind::TeachingEcho,
"{label} echo line carries TeachingEcho: {:?}",
app.output[idx],
);
}
assert!(
texts[2].contains("Executing SQL: ALTER TABLE Customers DROP COLUMN Email"),
"third echo line: {texts:?}",
);
// Pin the `Executing SQL:` prefix repeats once per statement
// (the plain-rendering shape until the styled-runs polish lands).
let exec_count = texts.iter().filter(|t| t.contains("Executing SQL:")).count();
assert_eq!(exec_count, 3, "one Executing SQL: per statement: {texts:?}");
}
#[test]
fn mode_command_switches_persistently() {
let mut app = App::new();
type_str(&mut app, "mode advanced");
submit(&mut app);
assert_eq!(app.mode, Mode::Advanced);
type_str(&mut app, "mode simple");
submit(&mut app);
assert_eq!(app.mode, Mode::Simple);
}
#[test]
fn mode_command_with_unknown_arg_errors() {
let mut app = App::new();
type_str(&mut app, "mode sideways");
submit(&mut app);
assert_eq!(app.mode, Mode::Simple);
// The error surfaces somewhere in the output buffer
// (could be the caret line, the parse-error detail
// line, or the usage line). Scan for the friendly
// "unknown mode" anchor phrase.
let anywhere = app
.output
.iter()
.any(|l| l.text.contains("unknown mode"));
assert!(
anywhere,
"expected 'unknown mode' somewhere in output: {:?}",
app.output.iter().map(|l| &l.text).collect::<Vec<_>>(),
);
let any_error = app
.output
.iter()
.any(|l| l.kind == OutputKind::Error);
assert!(any_error, "expected at least one Error line");
}
#[test]
fn colon_prefix_in_simple_mode_evaluates_as_advanced_one_shot() {
let mut app = App::new();
type_str(&mut app, ":select 1");
let actions = submit(&mut app);
// The persistent mode is unchanged.
assert_eq!(app.mode, Mode::Simple);
let echoed = app
.output
.iter()
.rfind(|l| l.kind == OutputKind::Echo)
.unwrap();
// The line ran under the one-shot effective mode, so
// the echo carries the Advanced tag…
assert_eq!(echoed.mode_at_submission, Mode::Advanced);
// …and the `:` is stripped before dispatch (the SQL
// executed is `select 1`, not `:select 1`).
assert!(
echoed.text.contains("select 1") && !echoed.text.contains(":select"),
"echo carries the stripped input: {:?}",
echoed.text,
);
// The one-shot dispatched the SELECT through the same
// path as a persistent-advanced submission.
assert!(
matches!(
actions.as_slice(),
[Action::ExecuteDsl {
command: Command::Select { .. },
..
}],
),
"`:select 1` should produce ExecuteDsl(Select); got {actions:?}",
);
}
#[test]
fn quit_command_returns_quit_action() {
let mut app = App::new();
type_str(&mut app, "quit");
let actions = submit(&mut app);
assert_eq!(actions, vec![Action::Quit]);
}
#[test]
fn ctrl_c_returns_quit_action() {
let mut app = App::new();
let actions = app.update(key_mod(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert_eq!(actions, vec![Action::Quit]);
}
#[test]
fn empty_submission_is_a_noop() {
let mut app = App::new();
let actions = submit(&mut app);
assert!(actions.is_empty());
assert!(app.output.is_empty());
}
#[test]
fn output_buffer_is_capped() {
let mut app = App::new();
for i in 0..(OUTPUT_CAPACITY + 50) {
app.note_system(format!("line{i}"));
}
assert_eq!(app.output.len(), OUTPUT_CAPACITY);
// Oldest entries were dropped.
assert!(app.output.front().unwrap().text.starts_with("line50"));
}
#[test]
fn effective_mode_reflects_persistent_mode_when_no_input() {
let mut app = App::new();
assert_eq!(app.effective_mode(), EffectiveMode::Simple);
app.mode = Mode::Advanced;
assert_eq!(app.effective_mode(), EffectiveMode::AdvancedPersistent);
}
/// Build a two-table cache (`Orders(id, customer_id)` +
/// `Customers(id, name)`) for the `:` one-shot SQL-feedback tests.
fn install_join_schema(app: &mut App) {
use crate::completion::TableColumn;
use crate::dsl::types::Type;
app.schema_cache.tables = vec!["Orders".into(), "Customers".into()];
app.schema_cache.table_columns.insert(
"Orders".into(),
vec![TableColumn::new("id", Type::Serial), TableColumn::new("customer_id", Type::Int)],
);
app.schema_cache.table_columns.insert(
"Customers".into(),
vec![TableColumn::new("id", Type::Serial), TableColumn::new("name", Type::Text)],
);
for t in app.schema_cache.tables.clone() {
for c in &app.schema_cache.table_columns[&t] {
app.schema_cache.columns.push(c.name.clone());
}
}
}
#[test]
fn colon_one_shot_gives_sql_completion_the_stripped_view() {
// Bug (manual testing): the `:` one-shot escape (ADR-0003) left
// the leading `:` in the buffer passed to the live SQL feedback,
// so the walker bailed at `:` and Tab completed nothing — while
// the identical line in full `mode advanced` completed. Now the
// feedback view strips the `:`, so both behave the same.
let body = "select c.name from Orders o join Customers c on c.id=o.cu";
// Full advanced mode: completes `o.cu` → `o.customer_id`.
let mut adv = App::new();
adv.mode = Mode::Advanced;
install_join_schema(&mut adv);
type_str(&mut adv, body);
adv.update(key(KeyCode::Tab));
assert!(
adv.input.ends_with("o.customer_id "),
"full advanced should complete: {:?}",
adv.input
);
// `:` one-shot from simple mode: must complete the same way, and
// the `:` prefix must be preserved in the buffer.
let mut one = App::new();
one.mode = Mode::Simple;
install_join_schema(&mut one);
one.update(key(KeyCode::Char(':')));
type_str(&mut one, body);
assert_eq!(one.effective_mode(), EffectiveMode::AdvancedOneShot);
one.update(key(KeyCode::Tab));
assert!(
one.input.trim_start().starts_with(':'),
"the `:` prefix is kept: {:?}",
one.input
);
assert!(
one.input.ends_with("o.customer_id "),
"`:` one-shot must complete the SQL column too: {:?}",
one.input
);
}
#[test]
fn colon_one_shot_validity_is_clean_for_a_valid_query() {
// A *valid* `:`-prefixed query must not light the `[ERR]`
// indicator (the walker used to choke on the `:` and always
// report Error).
let mut app = App::new();
install_join_schema(&mut app);
app.update(key(KeyCode::Char(':')));
type_str(&mut app, "select name from Customers");
assert_eq!(
app.input_validity_verdict(),
None,
"a valid one-shot query should verdict clean, got {:?}",
app.input_validity_verdict(),
);
}
#[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",
);
// ADR-0040: no `[ok]` summary line — success is signalled by
// the echo's ✓ marker (no echo pushed in this direct-event
// test) and the structure render itself.
assert!(
!app.output.iter().any(|l| l.text.starts_with("[ok]")),
"no [ok] summary line (ADR-0040)",
);
}
#[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,
});
// ADR-0040: no `[ok] explain …` header — the (no-echo here)
// command's success shows via the marker; the plan output
// itself carries the content.
assert!(
!app.output.iter().any(|l| l.text.starts_with("[ok]")),
"no [ok] summary line (ADR-0040)",
);
// 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",
);
}
// ---- ADR-0040: completion marker on the echo line ----------
#[test]
fn mark_oldest_pending_echo_resolves_in_submission_order() {
// Two echoes in flight; results arrive in submission order, so
// the oldest pending echo is the correct target each time — a
// finished command never leaves an earlier one stuck Pending.
let mut app = App::new();
app.output
.push_back(OutputLine::echo("first", crate::mode::Mode::Advanced));
app.output
.push_back(OutputLine::echo("second", crate::mode::Mode::Advanced));
app.mark_oldest_pending_echo(EchoStatus::Ok);
app.mark_oldest_pending_echo(EchoStatus::Err);
let echoes: Vec<_> = app
.output
.iter()
.filter(|l| l.kind == OutputKind::Echo)
.collect();
assert_eq!(echoes[0].status, Some(EchoStatus::Ok), "first → Ok");
assert_eq!(echoes[1].status, Some(EchoStatus::Err), "second → Err");
}
#[test]
fn successful_command_resolves_its_echo_to_ok_with_no_summary_line() {
// Full flow: dispatch pushes a Pending echo; the success event
// resolves it to ✓ and emits no `[ok]` summary line (ADR-0040).
let mut app = App::new();
type_str(&mut app, "create table T with pk");
submit(&mut app);
let echo = app
.output
.iter()
.find(|l| l.kind == OutputKind::Echo)
.expect("dispatch pushed an echo");
assert_eq!(echo.status, Some(EchoStatus::Pending), "pending before result");
app.update(AppEvent::DslSucceeded {
command: Command::CreateTable {
name: "T".to_string(),
columns: vec![crate::dsl::ColumnSpec::new("id", Type::Serial)],
primary_key: vec!["id".to_string()],
},
description: None,
echo: None,
});
let echo = app
.output
.iter()
.find(|l| l.kind == OutputKind::Echo)
.expect("echo still present");
assert_eq!(echo.status, Some(EchoStatus::Ok), "resolved to Ok");
assert!(
!app.output.iter().any(|l| l.text.starts_with("[ok]")),
"no [ok] summary line",
);
}
#[test]
fn parse_error_echo_stays_pending_and_keeps_running_prefix() {
// ADR-0040 scope: a parse error never reaches the worker, so
// its echo is not marker-tracked (status None) and keeps the
// `running:` rendering + caret.
let mut app = App::new();
type_str(&mut app, "frobnicate widgets");
submit(&mut app);
let echo = app
.output
.iter()
.find(|l| l.kind == OutputKind::Echo)
.expect("parse error still echoes the input");
assert_eq!(echo.status, None, "parse-error echo is not marker-tracked");
}
#[test]
fn replay_command_dispatches_replay_action_not_execute_dsl() {
// Submitting `replay <path>` must NOT produce an
// `Action::ExecuteDsl` (otherwise the worker thread
// would try to execute Replay, which has no semantics
// there, and history.log would record the replay
// invocation itself — see ADR-related runtime comments).
let mut app = App::new();
type_str(&mut app, "replay history.log");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
match &actions[0] {
Action::Replay { path } => assert_eq!(path, "history.log"),
other => panic!("expected Action::Replay, got {other:?}"),
}
}
#[test]
fn replay_completed_event_writes_ok_summary() {
let mut app = App::new();
app.update(AppEvent::ReplayCompleted {
path: "seed.commands".to_string(),
count: 4,
warnings: Vec::new(),
});
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::System);
assert!(last.text.starts_with("[ok] replay"), "{}", last.text);
assert!(last.text.contains("4 command(s)"), "{}", last.text);
assert!(last.text.contains("seed.commands"), "{}", last.text);
}
#[test]
fn replay_completed_event_renders_skip_warnings() {
// ADR-0034 Amendment 1: `[skip]` warnings (import / nested
// replay) surface in the output after the summary line.
let mut app = App::new();
app.update(AppEvent::ReplayCompleted {
path: "history.log".to_string(),
count: 2,
warnings: vec![
"[skip] line 3: `import a.zip` — replay does not re-import".to_string(),
"[skip] line 7: nested `replay x` — its commands were not replayed".to_string(),
],
});
let text: String = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("[ok] replay"), "summary present:\n{text}");
assert!(text.contains("import a.zip"), "import skip warning rendered:\n{text}");
assert!(text.contains("nested `replay x`"), "nested-replay skip warning rendered:\n{text}");
}
#[test]
fn replay_failed_event_renders_line_number_and_command_echo() {
let mut app = App::new();
app.update(AppEvent::ReplayFailed {
path: "seed.commands".to_string(),
line_number: 3,
command: "this is not a command".to_string(),
error: "parse error: …".to_string(),
});
// Two error lines emitted: header with line number,
// then ` > <command>` echo for context.
let lines: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
assert!(
lines.iter().any(|l| l.contains("at line 3")),
"missing line-number header in {lines:?}"
);
assert!(
lines.iter().any(|l| l.contains("> this is not a command")),
"missing command echo in {lines:?}"
);
}
#[test]
fn replay_failed_with_line_zero_skips_command_echo() {
// Line-number 0 is the runtime's signal that file-open
// itself failed; there's no per-line command to echo.
let mut app = App::new();
app.update(AppEvent::ReplayFailed {
path: "missing.commands".to_string(),
line_number: 0,
command: String::new(),
error: "could not open `missing.commands`: not found".to_string(),
});
let lines: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
assert!(
lines.iter().any(|l| l.contains("could not open")),
"missing error in {lines:?}"
);
assert!(
!lines.iter().any(|l| l.contains("at line 0")),
"should not render `at line 0` header in {lines:?}"
);
}
#[test]
fn dsl_failure_event_renders_through_friendly_translator() {
// Synthetic DslFailed carries a structured DbError;
// the App applies its current verbosity and routes the
// payload through `friendly::translate_error` (ADR-0019).
let mut app = App::new();
let cmd = Command::DropTable {
name: "Ghost".to_string(),
};
app.update(AppEvent::DslFailed {
command: cmd,
error: crate::db::DbError::Sqlite {
message: "no such table: Ghost".to_string(),
kind: crate::db::SqliteErrorKind::NoSuchTable,
},
facts: crate::friendly::FailureContext::default(),
source: String::new(),
});
let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error);
// Anchor phrase + table name (ADR-0019 §10).
assert!(last.text.contains("no such table"), "{}", last.text);
assert!(last.text.contains("Ghost"), "{}", last.text);
}
#[test]
fn messages_command_toggles_verbosity_and_reports() {
let mut app = App::new();
// Default is verbose.
type_str(&mut app, "messages");
submit(&mut app);
let last = app.output.back().unwrap();
assert!(last.text.ends_with("verbose"), "{}", last.text);
// Switch to short.
type_str(&mut app, "messages short");
submit(&mut app);
assert_eq!(app.messages_verbosity, crate::friendly::Verbosity::Short);
let last = app.output.back().unwrap();
assert_eq!(last.text, "messages: short");
// And back.
type_str(&mut app, "messages verbose");
submit(&mut app);
assert_eq!(app.messages_verbosity, crate::friendly::Verbosity::Verbose);
}
#[test]
fn dsl_failure_threads_facts_value_into_unique_insert_headline() {
// The runtime resolves the user's attempted value into
// `FailureContext::value` (Phase C). The App threads
// it into `TranslateContext.value` and the catalog
// headline gets the concrete substitution. Here we
// simulate the runtime by populating `facts` directly.
let mut app = App::new();
let cmd = Command::Insert {
table: "thing".to_string(),
columns: None,
values: vec![crate::dsl::Value::Number("1".to_string())],
};
let err = crate::db::DbError::Sqlite {
message: "UNIQUE constraint failed: thing.id".to_string(),
kind: crate::db::SqliteErrorKind::UniqueViolation,
};
let facts = crate::friendly::FailureContext {
table: Some("thing".to_string()),
column: Some("id".to_string()),
value: Some("1".to_string()),
..crate::friendly::FailureContext::default()
};
app.update(AppEvent::DslFailed {
command: cmd,
error: err,
facts,
source: String::new(),
});
let body = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(
body.contains("`1`"),
"expected the attempted value `1` in headline:\n{body}"
);
assert!(
!body.contains("{value}"),
"{{value}} placeholder should have been substituted:\n{body}"
);
}
#[test]
fn dsl_failure_threads_facts_value_into_unique_update_headline() {
// UPDATE: same threading as INSERT, just that the
// runtime would have pulled `value` from the SET
// assignment matching the offending column.
let mut app = App::new();
let cmd = Command::Update {
table: "Customers".to_string(),
assignments: vec![(
"id".to_string(),
crate::dsl::Value::Number("7".to_string()),
)],
filter: crate::dsl::RowFilter::eq(
"name",
crate::dsl::Value::Text("Bob".to_string()),
),
};
let err = crate::db::DbError::Sqlite {
message: "UNIQUE constraint failed: Customers.id".to_string(),
kind: crate::db::SqliteErrorKind::UniqueViolation,
};
let facts = crate::friendly::FailureContext {
table: Some("Customers".to_string()),
column: Some("id".to_string()),
value: Some("7".to_string()),
..crate::friendly::FailureContext::default()
};
app.update(AppEvent::DslFailed {
command: cmd,
error: err,
facts,
source: String::new(),
});
let body = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(body.contains("`7`"), "expected attempted id `7`:\n{body}");
}
#[test]
fn messages_short_drops_the_hint_in_dsl_failure_render() {
// Verbose mode → headline + hint. Short mode → headline only.
// Use a UNIQUE-style violation since it has a meaty hint
// worth measuring against.
let mut app = App::new();
let cmd = Command::Insert {
table: "Customers".to_string(),
columns: Some(vec!["id".to_string()]),
values: vec![],
};
let err = || crate::db::DbError::Sqlite {
message: "UNIQUE constraint failed: Customers.id".to_string(),
kind: crate::db::SqliteErrorKind::UniqueViolation,
};
app.messages_verbosity = crate::friendly::Verbosity::Verbose;
app.update(AppEvent::DslFailed {
command: cmd.clone(),
error: err(),
facts: crate::friendly::FailureContext::default(),
source: String::new(),
});
let verbose_text = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(
verbose_text.contains("pick a different value"),
"verbose mode missing hint: {verbose_text}"
);
// Reset and try short.
let mut app = App::new();
app.messages_verbosity = crate::friendly::Verbosity::Short;
app.update(AppEvent::DslFailed {
command: cmd,
error: err(),
facts: crate::friendly::FailureContext::default(),
source: String::new(),
});
let short_text = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(
short_text.contains("already has the value"),
"short still has the headline: {short_text}"
);
assert!(
!short_text.contains("pick a different value"),
"short mode should not include the hint: {short_text}"
);
}
#[test]
fn tables_refreshed_event_replaces_cached_list() {
let mut app = App::new();
app.update(AppEvent::TablesRefreshed(vec![
"A".to_string(),
"B".to_string(),
]));
assert_eq!(app.tables, vec!["A".to_string(), "B".to_string()]);
app.update(AppEvent::TablesRefreshed(vec!["C".to_string()]));
assert_eq!(app.tables, vec!["C".to_string()]);
}
#[test]
fn add_column_command_with_unknown_type_reports_parse_error() {
let mut app = App::new();
type_str(&mut app, "add column to table T: c (varchar)");
let actions = submit(&mut app);
assert!(
matches!(actions.as_slice(), [Action::JournalFailure { .. }]),
"expected only a JournalFailure, no dispatch; got {actions:?}",
);
let mentions_varchar = app
.output
.iter()
.any(|l| l.kind == OutputKind::Error && l.text.contains("varchar"));
assert!(
mentions_varchar,
"no error line mentions `varchar`; output:\n{}",
error_lines(&app),
);
}
#[test]
fn drop_table_command_emits_execute_action() {
let mut app = App::new();
type_str(&mut app, "drop table T");
let actions = submit(&mut app);
assert_eq!(actions.len(), 1);
let Action::ExecuteDsl { command, .. } = &actions[0] else {
panic!("expected ExecuteDsl, got {:?}", actions[0]);
};
assert_eq!(
command,
&Command::DropTable {
name: "T".to_string(),
},
);
}
#[test]
fn typing_moves_cursor_to_end_of_input() {
let mut app = App::new();
type_str(&mut app, "hello");
assert_eq!(app.input, "hello");
assert_eq!(app.input_cursor, 5);
}
#[test]
fn left_arrow_moves_cursor_back_one_char() {
let mut app = App::new();
type_str(&mut app, "hello");
app.update(key(KeyCode::Left));
assert_eq!(app.input_cursor, 4);
app.update(key(KeyCode::Left));
assert_eq!(app.input_cursor, 3);
}
#[test]
fn left_arrow_at_zero_does_not_underflow() {
let mut app = App::new();
app.update(key(KeyCode::Left));
assert_eq!(app.input_cursor, 0);
}
#[test]
fn right_arrow_moves_cursor_forward() {
let mut app = App::new();
type_str(&mut app, "hello");
app.input_cursor = 0;
app.update(key(KeyCode::Right));
assert_eq!(app.input_cursor, 1);
}
#[test]
fn home_and_end_jump_to_extremes() {
let mut app = App::new();
type_str(&mut app, "hello");
app.update(key(KeyCode::Home));
assert_eq!(app.input_cursor, 0);
app.update(key(KeyCode::End));
assert_eq!(app.input_cursor, 5);
}
#[test]
fn typing_inserts_at_cursor_position() {
let mut app = App::new();
type_str(&mut app, "hello");
// Cursor between 'h' and 'e'.
app.input_cursor = 1;
type_str(&mut app, "X");
assert_eq!(app.input, "hXello");
assert_eq!(app.input_cursor, 2);
}
#[test]
fn backspace_removes_char_before_cursor() {
let mut app = App::new();
type_str(&mut app, "hello");
// Cursor at end.
app.update(key(KeyCode::Backspace));
assert_eq!(app.input, "hell");
assert_eq!(app.input_cursor, 4);
// Cursor in the middle.
app.input_cursor = 2; // between 'e' and 'l'
app.update(key(KeyCode::Backspace));
assert_eq!(app.input, "hll");
assert_eq!(app.input_cursor, 1);
}
#[test]
fn backspace_at_start_is_a_noop() {
let mut app = App::new();
type_str(&mut app, "hello");
app.input_cursor = 0;
app.update(key(KeyCode::Backspace));
assert_eq!(app.input, "hello");
assert_eq!(app.input_cursor, 0);
}
#[test]
fn delete_removes_char_at_cursor() {
let mut app = App::new();
type_str(&mut app, "hello");
app.input_cursor = 1; // between 'h' and 'e'
app.update(key(KeyCode::Delete));
assert_eq!(app.input, "hllo");
assert_eq!(app.input_cursor, 1);
}
#[test]
fn delete_at_end_is_a_noop() {
let mut app = App::new();
type_str(&mut app, "hello");
app.update(key(KeyCode::Delete));
assert_eq!(app.input, "hello");
assert_eq!(app.input_cursor, 5);
}
#[test]
fn cursor_handles_multibyte_chars() {
let mut app = App::new();
type_str(&mut app, "héllo"); // 'é' is 2 bytes
// input length is 6 bytes, 5 chars
assert_eq!(app.input.len(), 6);
assert_eq!(app.input_cursor, 6);
// Move left across the 2-byte char.
app.update(key(KeyCode::Left));
assert_eq!(app.input_cursor, 5);
app.update(key(KeyCode::Left));
assert_eq!(app.input_cursor, 4);
app.update(key(KeyCode::Left));
assert_eq!(app.input_cursor, 3);
app.update(key(KeyCode::Left));
// Now at the byte before 'é' — must skip the multi-byte char.
assert_eq!(app.input_cursor, 1);
}
#[test]
fn submit_resets_cursor_to_zero() {
let mut app = App::new();
type_str(&mut app, "drop table T");
submit(&mut app);
assert_eq!(app.input_cursor, 0);
}
#[test]
fn relationships_refreshed_event_updates_the_field() {
// ADR-0046 DB2: the runtime posts RelationshipsRefreshed; the
// App stores it for the sidebar relationships panel to render.
use crate::dsl::action::ReferentialAction;
let mut app = App::new();
assert!(app.relationships.is_empty());
app.update(AppEvent::RelationshipsRefreshed(vec![
crate::persistence::RelationshipSchema {
name: "Customers_Orders".to_string(),
parent_table: "Customers".to_string(),
parent_columns: vec!["id".to_string()],
child_table: "Orders".to_string(),
child_columns: vec!["customer_id".to_string()],
on_delete: ReferentialAction::Cascade,
on_update: ReferentialAction::NoAction,
},
]));
assert_eq!(app.relationships.len(), 1);
assert_eq!(app.relationships[0].name, "Customers_Orders");
}
#[test]
fn ctrl_o_cycles_navigation_focus() {
// ADR-0046 DC1: Input → Tables → Relationships → Input.
let mut app = App::new();
assert_eq!(app.nav_focus, NavFocus::Input);
let ctrl_o = || key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL);
app.update(ctrl_o());
assert_eq!(app.nav_focus, NavFocus::SidebarTables);
app.update(ctrl_o());
assert_eq!(app.nav_focus, NavFocus::SidebarRelationships);
app.update(ctrl_o());
assert_eq!(app.nav_focus, NavFocus::Input);
}
#[test]
fn esc_exits_navigation_mode() {
let mut app = App::new();
app.update(key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL));
assert!(app.nav_focus.in_sidebar());
app.update(key(KeyCode::Esc));
assert_eq!(app.nav_focus, NavFocus::Input);
}
#[test]
fn navigation_mode_ignores_input_keys() {
// ADR-0046 DC4: the input is occluded; printable/Enter/Backspace
// are inert while a sidebar panel is focused.
let mut app = App::new();
type_str(&mut app, "select");
app.update(key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL));
app.update(key(KeyCode::Char('x')));
app.update(key(KeyCode::Backspace));
let actions = app.update(key(KeyCode::Enter));
assert_eq!(app.input, "select", "input untouched in navigation mode");
assert!(actions.is_empty(), "Enter does not submit in navigation mode");
}
#[test]
fn nav_scroll_keys_move_only_the_focused_panel() {
// ADR-0046 DC3: Up/Down line-scroll the focused sidebar panel.
let mut app = App::new();
app.nav_focus = NavFocus::SidebarTables;
app.update(key(KeyCode::Down));
app.update(key(KeyCode::Down));
assert_eq!(app.tables_scroll, 2);
assert_eq!(app.relationships_scroll, 0, "only the focused panel scrolls");
app.update(key(KeyCode::Up));
assert_eq!(app.tables_scroll, 1);
// Up saturates at the top.
app.update(key(KeyCode::Up));
app.update(key(KeyCode::Up));
assert_eq!(app.tables_scroll, 0);
// Switching focus moves the other panel instead.
app.nav_focus = NavFocus::SidebarRelationships;
app.update(key(KeyCode::Down));
assert_eq!(app.relationships_scroll, 1);
assert_eq!(app.tables_scroll, 0);
}
#[test]
fn nav_page_scroll_uses_the_panels_visible_rows() {
// ADR-0046 DC3: PageUp/PageDown move by the last reported
// visible-row count.
let mut app = App::new();
app.nav_focus = NavFocus::SidebarTables;
app.last_tables_visible = 10;
app.update(key(KeyCode::PageDown));
assert_eq!(app.tables_scroll, 10);
app.update(key(KeyCode::PageUp));
assert_eq!(app.tables_scroll, 0);
}
#[test]
fn input_scroll_offset_resets_when_the_buffer_is_replaced() {
// ADR-0046 DA3: the horizontal scroll offset must not leak from
// one command to the next. Submitting and recalling from history
// both replace the buffer wholesale, so both reset it.
let mut app = App::new();
type_str(&mut app, "a long command line that would have scrolled");
app.input_scroll_offset = 25;
submit(&mut app);
assert_eq!(app.input_scroll_offset, 0, "submit resets the input scroll");
// Recall the submitted line from history — also a reset.
type_str(&mut app, "another draft line entirely");
app.input_scroll_offset = 25;
app.update(key(KeyCode::Up));
assert_eq!(
app.input_scroll_offset, 0,
"history recall resets the input scroll"
);
}
#[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(),
},
},
echo: None,
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(
texts.iter().any(|t| t.contains("2 row(s) updated")),
"affected-row count surfaced: {texts:?}",
);
assert!(
!texts.iter().any(|t| t.contains("(no rows)")),
"no misleading empty-table band: {texts:?}",
);
}
#[test]
fn update_success_with_columns_renders_the_table() {
// The guard only suppresses a column-less result: a result
// carrying columns (the DSL UPDATE path) still renders.
let mut app = App::new();
app.update(AppEvent::DslUpdateSucceeded {
command: Command::SqlUpdate {
sql: "update t set v = 1".to_string(),
target_table: "t".to_string(),
returning: false,
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())]],
},
},
echo: None,
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(
texts.iter().any(|t| t.contains("id") && t.contains('v')),
"header row rendered: {texts:?}",
);
}
#[test]
fn sql_delete_success_renders_count_and_cascade_summary() {
// ADR-0033 sub-phase 3f: a SQL DELETE reuses the DSL delete
// renderer (CommandOutcome::Delete -> handle_dsl_delete_
// success). This pins that the SHARED renderer produces the
// right user-facing strings for the SQL path — the row count
// and the per-relationship cascade line. ADR-0040: success is
// signalled by the echo's ✓ marker (no more `[ok]` summary), so
// we push the `running:` echo first (as `dispatch_dsl` does)
// and assert it resolves to `Ok`.
use crate::dsl::ReferentialAction;
let mut app = App::new();
app.output.push_back(OutputLine::echo(
"delete from Customers where id = 1",
crate::mode::Mode::Advanced,
));
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(),
},
},
echo: None,
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
// ADR-0040: the echo resolves to ✓ (Ok); no `[ok]` summary line.
assert!(
app.output
.iter()
.any(|l| l.kind == OutputKind::Echo && l.status == Some(EchoStatus::Ok)),
"the echo line resolves to Ok: {texts:?}",
);
assert!(
!texts.iter().any(|t| t.starts_with("[ok]")),
"no [ok] summary line (ADR-0040): {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 seed_success_renders_count_preview_and_advisory() {
// ADR-0048: handle_dsl_seed_success renders the seeded-row count,
// the preview table, and the enum/CHECK advisory.
let mut app = App::new();
app.output
.push_back(OutputLine::echo("seed users 20", crate::mode::Mode::Simple));
app.update(AppEvent::DslSeedSucceeded {
command: Command::Seed {
table: "users".to_string(),
target_column: None,
count: Some(20),
overrides: Vec::new(),
rng_seed: None,
},
result: crate::db::SeedResult {
table: "users".to_string(),
requested: 20,
produced: 20,
data: crate::db::DataResult {
table_name: "users".to_string(),
columns: vec!["name".to_string()],
column_types: vec![None],
rows: vec![vec![Some("Alice".to_string())]],
},
advisory_columns: vec!["status".to_string()],
},
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(
texts.iter().any(|t| t.contains("20 row(s) seeded into users")),
"seeded-row count surfaced: {texts:?}",
);
assert!(
texts.iter().any(|t| t.contains("status") && t.contains("generic text")),
"the advisory names the enum-ish column: {texts:?}",
);
}
#[test]
fn seed_success_reports_a_cap() {
// produced < requested → the cap note appears next to the count.
let mut app = App::new();
app.output
.push_back(OutputLine::echo("seed J 10", crate::mode::Mode::Simple));
app.update(AppEvent::DslSeedSucceeded {
command: Command::Seed {
table: "J".to_string(),
target_column: None,
count: Some(10),
overrides: Vec::new(),
rng_seed: None,
},
result: crate::db::SeedResult {
table: "J".to_string(),
requested: 10,
produced: 4,
data: crate::db::DataResult {
table_name: "J".to_string(),
columns: Vec::new(),
column_types: Vec::new(),
rows: Vec::new(),
},
advisory_columns: Vec::new(),
},
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(
texts.iter().any(|t| t.contains("4 row(s) seeded into J")
&& t.contains("of 10 requested")),
"the cap note surfaces requested vs produced: {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())]],
},
},
echo: None,
});
let texts: Vec<String> = app.output.iter().map(|l| l.text.clone()).collect();
assert!(
texts.iter().any(|t| t.contains("2 row(s) deleted in `Orders`")),
"cascade summary still surfaces alongside RETURNING: {texts:?}",
);
assert!(
texts.iter().any(|t| t.contains("Name")) && texts.iter().any(|t| t.contains("Alice")),
"the returned (deleted) row is tabulated: {texts:?}",
);
}
}