22bec61d11
DC3 — navigation-mode scroll: the focused Tables / Relationships panel scrolls (Up/Down by a line, PageUp/PageDown by its visible-row count). Per-panel offsets are clamped to content at render time, and the renderer reports each panel's visible rows for paging — mirroring the output panel's scroll. render_items_panel / render_relationships_panel take &mut App, count their rows, and store+clamp the offset before building the borrowing lines. DC2 refinement: the expand-on-focus overlay now clears only the sidebar strip plus a one-column gutter, leaving the base output/input/hint visible (unchanged) to the right rather than blanking the whole area — truer to "underneath keeps its layout", with the gutter keeping the cut-off edge clean (chosen after eyeballing both variants). ADR DC2 and the overlay snapshot updated to match. Tests: line/page scroll move only the focused panel and clamp; the render clamps a past-the-end offset so the last row stays visible.
5895 lines
229 KiB
Rust
5895 lines
229 KiB
Rust
//! Application state and the single `update` entry point.
|
|
//!
|
|
//! `update` is pure with respect to the runtime: it mutates
|
|
//! state in place and returns a list of `Action`s. Side effects
|
|
//! (DB execution, quit, etc.) live in the runtime. This keeps
|
|
//! every behaviour drivable from synthetic events in tests,
|
|
//! which is what makes ADR-0008's Tier 1/3 testing tractable.
|
|
|
|
use std::collections::VecDeque;
|
|
|
|
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
|
use tracing::{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,
|
|
/// 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,
|
|
/// 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;
|
|
|
|
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,
|
|
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,
|
|
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 validity-indicator verdict for the current input
|
|
/// (ADR-0027 §3). `None` when the input would run clean.
|
|
///
|
|
/// Computed only in simple mode — advanced mode is raw SQL,
|
|
/// which the DSL walker does not parse (ADR-0027 §7). A
|
|
/// pure query the runtime calls once the typing debounce
|
|
/// settles; the result is stored in `input_indicator`.
|
|
///
|
|
/// ADR-0032 §10.6 — the verdict reads the walker view of
|
|
/// the *active* effective mode so a SQL form in Advanced
|
|
/// mode lights up the same `[ERR]` / `[WRN]` indicator the
|
|
/// DSL surface uses. Without this the SQL predicate
|
|
/// warnings (ADR-0032 §11.6) would emit but never reach
|
|
/// the validity indicator the user sees.
|
|
#[must_use]
|
|
pub fn input_validity_verdict(&self) -> Option<crate::dsl::walker::Severity> {
|
|
let mode = match self.effective_mode() {
|
|
EffectiveMode::Simple => Mode::Simple,
|
|
EffectiveMode::AdvancedPersistent
|
|
| EffectiveMode::AdvancedOneShot => Mode::Advanced,
|
|
};
|
|
crate::dsl::walker::input_verdict_in_mode(
|
|
&self.input,
|
|
Some(&self.schema_cache),
|
|
mode,
|
|
)
|
|
}
|
|
|
|
/// Process one event from the runtime, mutating state and
|
|
/// returning any actions for the runtime to enact.
|
|
pub fn update(&mut self, event: AppEvent) -> Vec<Action> {
|
|
match event {
|
|
AppEvent::Key(key) => self.handle_key(key),
|
|
AppEvent::Resize { .. } | AppEvent::Tick => Vec::new(),
|
|
AppEvent::DslSucceeded {
|
|
command,
|
|
description,
|
|
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::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");
|
|
|
|
// 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(),
|
|
}
|
|
}
|
|
|
|
fn cursor_left(&mut self) {
|
|
let mut idx = self.input_cursor;
|
|
while idx > 0 {
|
|
idx -= 1;
|
|
if self.input.is_char_boundary(idx) {
|
|
self.input_cursor = idx;
|
|
return;
|
|
}
|
|
}
|
|
self.input_cursor = 0;
|
|
}
|
|
|
|
fn cursor_right(&mut self) {
|
|
let mut idx = self.input_cursor;
|
|
while idx < self.input.len() {
|
|
idx += 1;
|
|
if self.input.is_char_boundary(idx) {
|
|
self.input_cursor = idx;
|
|
return;
|
|
}
|
|
}
|
|
self.input_cursor = self.input.len();
|
|
}
|
|
|
|
fn insert_at_cursor(&mut self, c: char) {
|
|
// Defensive clamp: callers (and tests) may mutate
|
|
// `input` directly; keep the cursor inside the buffer.
|
|
if self.input_cursor > self.input.len() {
|
|
self.input_cursor = self.input.len();
|
|
}
|
|
self.input.insert(self.input_cursor, c);
|
|
self.input_cursor += c.len_utf8();
|
|
}
|
|
|
|
fn delete_before_cursor(&mut self) {
|
|
if self.input_cursor == 0 {
|
|
return;
|
|
}
|
|
// Find the start of the previous character.
|
|
let mut idx = self.input_cursor - 1;
|
|
while !self.input.is_char_boundary(idx) {
|
|
idx -= 1;
|
|
}
|
|
self.input.replace_range(idx..self.input_cursor, "");
|
|
self.input_cursor = idx;
|
|
}
|
|
|
|
/// Tab key handler — insert / cycle forward through the
|
|
/// candidates at the cursor (ADR-0022 stage 8). Behaviour
|
|
/// is split between single and multi-candidate cases per
|
|
/// the user's stage-8 feedback round 2:
|
|
///
|
|
/// - **Single candidate**: insert with trailing space,
|
|
/// no memo. The space is the natural commit; subsequent
|
|
/// Tab fresh-computes at the new cursor. Esc/Backspace
|
|
/// are normal.
|
|
/// - **Multi candidate**: insert WITHOUT trailing space,
|
|
/// create memo for cycling. Tab again cycles; any other
|
|
/// key clears the memo. Pressing space (the natural
|
|
/// "I'm done picking" gesture) clears the memo and adds
|
|
/// the space, completing the chosen candidate.
|
|
/// - **Memo present**: cycle to next candidate, replacing
|
|
/// the inserted text in place (still no trailing space).
|
|
/// - **No candidates**: no-op.
|
|
fn completion_tab_forward(&mut self) -> Vec<Action> {
|
|
self.cancel_history_navigation();
|
|
if let Some(memo) = self.last_completion.take() {
|
|
let next = memo.next_idx();
|
|
self.last_completion = Some(self.replace_inserted(memo, next));
|
|
return Vec::new();
|
|
}
|
|
self.start_or_complete_at(0);
|
|
Vec::new()
|
|
}
|
|
|
|
/// Shift-Tab key handler — symmetric to forward; on a
|
|
/// fresh multi-candidate position starts from the last
|
|
/// candidate (per the user's #2 wrap-from-both-ends).
|
|
/// Single candidate behaves identically to Tab.
|
|
fn completion_tab_backward(&mut self) -> Vec<Action> {
|
|
self.cancel_history_navigation();
|
|
if let Some(memo) = self.last_completion.take() {
|
|
let prev = memo.prev_idx();
|
|
self.last_completion = Some(self.replace_inserted(memo, prev));
|
|
return Vec::new();
|
|
}
|
|
self.start_or_complete_last();
|
|
Vec::new()
|
|
}
|
|
|
|
/// Esc / Backspace handler while a completion memo is
|
|
/// alive — restore the original text in `inserted_range`
|
|
/// and place the cursor where the user was when they hit
|
|
/// Tab. The memo is cleared. Only fires on multi-candidate
|
|
/// completions (single-candidate paths don't create a
|
|
/// memo); the user accepts that single-candidate Tab
|
|
/// requires regular backspace to undo.
|
|
fn undo_last_completion(&mut self) {
|
|
let Some(memo) = self.last_completion.take() else {
|
|
return;
|
|
};
|
|
let (start, end) = memo.inserted_range;
|
|
self.input.replace_range(start..end, &memo.original_text);
|
|
self.input_cursor = start + memo.original_text.len();
|
|
}
|
|
|
|
fn start_or_complete_at(&mut self, multi_start_idx: usize) {
|
|
let cursor = self.input_cursor.min(self.input.len());
|
|
let Some(comp) = crate::completion::candidates_at_cursor_in_mode(
|
|
&self.input,
|
|
cursor,
|
|
&self.schema_cache,
|
|
self.effective_mode().as_mode(),
|
|
) else {
|
|
return;
|
|
};
|
|
if comp.candidates.len() == 1 {
|
|
self.commit_unique(&comp);
|
|
} else {
|
|
let idx = multi_start_idx % comp.candidates.len();
|
|
self.last_completion = Some(self.commit_multi(comp, idx));
|
|
}
|
|
}
|
|
|
|
fn start_or_complete_last(&mut self) {
|
|
let cursor = self.input_cursor.min(self.input.len());
|
|
let Some(comp) = crate::completion::candidates_at_cursor_in_mode(
|
|
&self.input,
|
|
cursor,
|
|
&self.schema_cache,
|
|
self.effective_mode().as_mode(),
|
|
) else {
|
|
return;
|
|
};
|
|
if comp.candidates.len() == 1 {
|
|
self.commit_unique(&comp);
|
|
} else {
|
|
let idx = comp.candidates.len() - 1;
|
|
self.last_completion = Some(self.commit_multi(comp, idx));
|
|
}
|
|
}
|
|
|
|
/// Single-candidate commit: insert "<text> " (with trailing
|
|
/// space) and DO NOT create a memo. The user can keep
|
|
/// typing or press Tab again to fresh-complete at the new
|
|
/// cursor.
|
|
fn commit_unique(&mut self, comp: &crate::completion::Completion) {
|
|
let inserted = format!("{} ", comp.candidates[0].text);
|
|
self.input
|
|
.replace_range(comp.replaced_range.0..comp.replaced_range.1, &inserted);
|
|
self.input_cursor = comp.replaced_range.0 + inserted.len();
|
|
}
|
|
|
|
/// Multi-candidate commit: insert just the candidate text
|
|
/// (no trailing space) and return the memo carrying the
|
|
/// full candidate list for cycling. The user presses space
|
|
/// (or any other non-Tab key) to commit the choice — that
|
|
/// clears the memo and inserts whatever they typed
|
|
/// normally, naturally producing "<chosen> " as the
|
|
/// completed text.
|
|
fn commit_multi(
|
|
&mut self,
|
|
comp: crate::completion::Completion,
|
|
idx: usize,
|
|
) -> crate::completion::LastCompletion {
|
|
let inserted = comp.candidates[idx].text.clone();
|
|
let original_text =
|
|
self.input[comp.replaced_range.0..comp.replaced_range.1].to_string();
|
|
self.input
|
|
.replace_range(comp.replaced_range.0..comp.replaced_range.1, &inserted);
|
|
let new_end = comp.replaced_range.0 + inserted.len();
|
|
self.input_cursor = new_end;
|
|
crate::completion::LastCompletion {
|
|
inserted_range: (comp.replaced_range.0, new_end),
|
|
original_text,
|
|
candidates: comp.candidates,
|
|
selection_idx: idx,
|
|
}
|
|
}
|
|
|
|
/// Replace the inserted text with `candidates[idx]` (no
|
|
/// trailing space — same multi-candidate convention) and
|
|
/// return an updated memo. Used by Tab/Shift-Tab cycling.
|
|
fn replace_inserted(
|
|
&mut self,
|
|
memo: crate::completion::LastCompletion,
|
|
idx: usize,
|
|
) -> crate::completion::LastCompletion {
|
|
let new_inserted = memo.candidates[idx].text.clone();
|
|
let (start, end) = memo.inserted_range;
|
|
self.input.replace_range(start..end, &new_inserted);
|
|
let new_end = start + new_inserted.len();
|
|
self.input_cursor = new_end;
|
|
crate::completion::LastCompletion {
|
|
inserted_range: (start, new_end),
|
|
selection_idx: idx,
|
|
..memo
|
|
}
|
|
}
|
|
|
|
fn delete_at_cursor(&mut self) {
|
|
if self.input_cursor >= self.input.len() {
|
|
return;
|
|
}
|
|
// Find the end of the character at the cursor.
|
|
let mut idx = self.input_cursor + 1;
|
|
while idx < self.input.len() && !self.input.is_char_boundary(idx) {
|
|
idx += 1;
|
|
}
|
|
self.input.replace_range(self.input_cursor..idx, "");
|
|
}
|
|
|
|
/// Move backwards in history (towards older entries).
|
|
fn history_back(&mut self) {
|
|
if self.history.is_empty() {
|
|
return;
|
|
}
|
|
let next_index = match self.history_cursor {
|
|
None => {
|
|
// Starting navigation: save the current draft so the
|
|
// user can return to it.
|
|
self.history_draft = Some(self.input.clone());
|
|
self.history.len() - 1
|
|
}
|
|
Some(0) => 0,
|
|
Some(i) => i - 1,
|
|
};
|
|
self.history_cursor = Some(next_index);
|
|
self.input = self.history[next_index].clone();
|
|
self.input_cursor = self.input.len();
|
|
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);
|
|
}
|
|
}
|
|
|
|
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),
|
|
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()
|
|
}
|
|
KeyCode::Up => {
|
|
if state.selected > 0 {
|
|
state.selected -= 1;
|
|
}
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
KeyCode::Down => {
|
|
if state.selected + 1 < state.entries.len() {
|
|
state.selected += 1;
|
|
}
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
KeyCode::Enter => {
|
|
if let Some(entry) = state.entries.get(state.selected).cloned() {
|
|
self.modal = None;
|
|
return vec![Action::LoadProject {
|
|
path: entry.path,
|
|
source: "load".to_string(),
|
|
}];
|
|
}
|
|
self.note_error(crate::t!("modal.load_picker_nothing"));
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
KeyCode::Char('b') | KeyCode::Char('B') => {
|
|
state.sub_mode = LoadPickerSubMode::PathEntry {
|
|
input: String::new(),
|
|
cursor: 0,
|
|
};
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
_ => {
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
},
|
|
LoadPickerSubMode::PathEntry { input, cursor } => match key.code {
|
|
KeyCode::Esc => {
|
|
state.sub_mode = LoadPickerSubMode::List;
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
KeyCode::Enter => {
|
|
let target = input.trim().to_string();
|
|
if target.is_empty() {
|
|
self.note_error(crate::t!("modal.path_entry_empty_path"));
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
return Vec::new();
|
|
}
|
|
self.modal = None;
|
|
vec![Action::LoadProject {
|
|
path: std::path::PathBuf::from(target),
|
|
source: "load".to_string(),
|
|
}]
|
|
}
|
|
KeyCode::Char(c) => {
|
|
input.insert(*cursor, c);
|
|
*cursor += c.len_utf8();
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
KeyCode::Backspace => {
|
|
if *cursor > 0
|
|
&& let Some(c) = input[..*cursor].chars().next_back()
|
|
{
|
|
let new_cursor = *cursor - c.len_utf8();
|
|
input.drain(new_cursor..*cursor);
|
|
*cursor = new_cursor;
|
|
}
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
KeyCode::Left => {
|
|
if *cursor > 0
|
|
&& let Some(c) = input[..*cursor].chars().next_back()
|
|
{
|
|
*cursor -= c.len_utf8();
|
|
}
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
KeyCode::Right => {
|
|
if *cursor < input.len()
|
|
&& let Some(c) = input[*cursor..].chars().next()
|
|
{
|
|
*cursor += c.len_utf8();
|
|
}
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
_ => {
|
|
self.modal = Some(Modal::LoadPicker(state));
|
|
Vec::new()
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Note the list of currently-supported commands to the
|
|
/// output panel.
|
|
///
|
|
/// Assembled from the command REGISTRY (ADR-0024 §help_id):
|
|
/// the framing (`help.intro`, `help.dsl_section`,
|
|
/// `help.types_reference`) comes from the catalog, and each
|
|
/// command's body is the catalog entry named by its
|
|
/// `help_id`. A newly-registered command appears here
|
|
/// automatically — no edit to this function or a hand-kept
|
|
/// list. Each catalog line becomes its own `OutputLine` so
|
|
/// the scroll-position math (one logical line = one display
|
|
/// row) stays accurate per the renderer's invariant.
|
|
fn note_help(&mut self) {
|
|
use crate::dsl::grammar::REGISTRY;
|
|
|
|
let mut lines: Vec<String> = Vec::new();
|
|
lines.push(crate::t!("help.intro"));
|
|
// REGISTRY is ordered app-commands first; emit the
|
|
// "DSL data commands" sub-header at the first command
|
|
// whose help_id leaves the `app.` namespace.
|
|
let mut dsl_header_done = false;
|
|
for (command, _category) in REGISTRY {
|
|
let Some(help_id) = command.help_id else {
|
|
continue;
|
|
};
|
|
if !dsl_header_done && !help_id.starts_with("app.") {
|
|
lines.push(crate::t!("help.dsl_section"));
|
|
dsl_header_done = true;
|
|
}
|
|
let key = format!("help.{help_id}");
|
|
let body = crate::friendly::translate(&key, &[]);
|
|
lines.extend(body.lines().map(str::to_string));
|
|
}
|
|
lines.extend(
|
|
crate::t!("help.types_reference")
|
|
.lines()
|
|
.map(str::to_string),
|
|
);
|
|
// 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))
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
#[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 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:?}",
|
|
);
|
|
}
|
|
}
|