//! Application state and the single `update` entry point. //! //! `update` is pure with respect to the runtime: it mutates //! state in place and returns a list of `Action`s. Side effects //! (DB execution, quit, etc.) live in the runtime. This keeps //! every behaviour drivable from synthetic events in tests, //! which is what makes ADR-0008's Tier 1/3 testing tractable. use std::collections::VecDeque; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use tracing::{trace, warn}; use crate::action::Action; use crate::db::{ CascadeEffect, DataResult, DeleteResult, InsertResult, TableDescription, UpdateResult, }; use crate::dsl::{Command, ParseError, parse_command}; use crate::event::AppEvent; use crate::mode::Mode; /// Maximum number of output lines kept in the rolling buffer. const OUTPUT_CAPACITY: usize = 1000; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OutputKind { Echo, System, Error, } #[derive(Debug, Clone)] pub struct OutputLine { pub text: String, pub kind: OutputKind, pub mode_at_submission: Mode, } /// What mode the next submission would be evaluated in. /// /// Derived from the persistent mode and the current input /// buffer. The UI uses this to give immediate visual feedback /// for the `:` one-shot escape: the moment a leading `:` is /// typed in simple mode, the prompt flips to advanced styling, /// and reverts as soon as the `:` is deleted. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EffectiveMode { Simple, AdvancedPersistent, AdvancedOneShot, } impl EffectiveMode { #[must_use] pub const fn is_advanced(self) -> bool { matches!(self, Self::AdvancedPersistent | Self::AdvancedOneShot) } } #[derive(Debug)] pub struct App { pub mode: Mode, pub input: String, /// Byte offset into `input` where the next character will be /// inserted. Always lies on a UTF-8 character boundary. pub input_cursor: usize, pub output: VecDeque, pub hint: Option, pub tables: Vec, /// Last successfully described table, shown in the output /// pane until the next DDL operation. pub current_table: Option, /// In-memory history of submitted lines, oldest first. /// Persistent history across sessions (I2 second half) lands /// when track 2's project storage is in place. pub history: Vec, /// Position within `history` while navigating with Up/Down. /// `None` means "not navigating; `input` is the user's /// in-progress draft." history_cursor: Option, /// Snapshot of the user's in-progress draft taken when they /// start navigating history, restored if they navigate back /// past the most recent entry. history_draft: Option, /// Number of lines from the bottom we've scrolled up. `0` /// means "showing the most recent lines"; positive values /// reveal older lines. Reset to `0` whenever a new output /// line is appended so newly-arrived results are always /// visible after a command. The full V4 session-log spec /// supersedes this; we ship a minimal subset now to address /// the immediate "ran out of space" UX problem. pub output_scroll: usize, /// The most recent visible-row count of the output panel, /// reported by the renderer. Used to cap `output_scroll` — /// without this, scrolling past `len - visible` would slide /// the visible window off the top of the buffer and shrink /// what the user sees. pub last_output_visible: usize, /// The most recent total *wrapped* row count of the output /// panel — counted in display rows after wrapping, not in /// logical OutputLines. Required for accurate scroll capping /// when long lines wrap to multiple display rows. pub last_output_total_wrapped: usize, /// Prettified display name of the currently-open project, /// rendered in the status bar (P-NAME-3, ADR-0015 §2). `None` /// during very-early startup before the runtime has opened a /// project; otherwise always populated. pub project_name: Option, } const PAGE_SCROLL_LINES: usize = 5; const HISTORY_CAPACITY: usize = 1000; impl Default for App { fn default() -> Self { Self::new() } } impl App { #[must_use] pub fn new() -> Self { Self { mode: Mode::Simple, input: String::new(), input_cursor: 0, output: VecDeque::with_capacity(OUTPUT_CAPACITY), hint: None, tables: Vec::new(), current_table: None, history: Vec::new(), history_cursor: None, history_draft: None, output_scroll: 0, last_output_visible: 0, last_output_total_wrapped: 0, project_name: None, } } /// Called by the renderer with the current output-panel /// dimensions (row count + total wrapped-row count for the /// current buffer) so subsequent scroll input is capped /// correctly. Without `total_wrapped`, scroll math would /// incorrectly assume one logical line = one display row. pub const fn note_output_viewport(&mut self, visible_rows: usize, total_wrapped_rows: usize) { self.last_output_visible = visible_rows; self.last_output_total_wrapped = total_wrapped_rows; // If a previous PageUp drifted past the maximum useful // scroll (e.g. the user kept paging up past the top), // bring it back so the next PageDown is responsive. let max = total_wrapped_rows.saturating_sub(visible_rows); if self.output_scroll > max { self.output_scroll = max; } } /// Effective mode for the *next* submission, given the /// persistent mode and the current input buffer. See /// [`EffectiveMode`]. #[must_use] pub fn effective_mode(&self) -> EffectiveMode { match self.mode { Mode::Advanced => EffectiveMode::AdvancedPersistent, Mode::Simple if self.input.trim_start().starts_with(':') => { EffectiveMode::AdvancedOneShot } Mode::Simple => EffectiveMode::Simple, } } /// Process one event from the runtime, mutating state and /// returning any actions for the runtime to enact. pub fn update(&mut self, event: AppEvent) -> Vec { match event { AppEvent::Key(key) => self.handle_key(key), AppEvent::Resize { .. } | AppEvent::Tick => Vec::new(), AppEvent::DslSucceeded { command, description, } => { self.handle_dsl_success(&command, description); Vec::new() } AppEvent::DslDataSucceeded { command, data } => { self.handle_dsl_query_success(&command, &data); Vec::new() } AppEvent::DslInsertSucceeded { command, result } => { self.handle_dsl_insert_success(&command, &result); Vec::new() } AppEvent::DslUpdateSucceeded { command, result } => { self.handle_dsl_update_success(&command, &result); Vec::new() } AppEvent::DslDeleteSucceeded { command, result } => { self.handle_dsl_delete_success(&command, &result); Vec::new() } AppEvent::DslFailed { command, error } => { self.handle_dsl_failure(&command, &error); Vec::new() } AppEvent::TablesRefreshed(tables) => { trace!(count = tables.len(), "tables refreshed"); self.tables = tables; Vec::new() } } } fn handle_key(&mut self, key: KeyEvent) -> Vec { // On Windows, key events fire for both Press and Release; // honour only Press to avoid double-handling. Other // platforms emit Press only, so this is a no-op there. if key.kind != KeyEventKind::Press { return Vec::new(); } trace!(?key, "handle_key"); match (key.code, key.modifiers) { (KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit], (KeyCode::Enter, _) => self.submit(), (KeyCode::Up, _) => { self.history_back(); Vec::new() } (KeyCode::Down, _) => { self.history_forward(); Vec::new() } (KeyCode::Left, _) => { self.cursor_left(); Vec::new() } (KeyCode::Right, _) => { self.cursor_right(); Vec::new() } (KeyCode::Home, _) => { self.input_cursor = 0; Vec::new() } (KeyCode::End, _) => { self.input_cursor = self.input.len(); Vec::new() } (KeyCode::Backspace, _) => { self.cancel_history_navigation(); self.delete_before_cursor(); Vec::new() } (KeyCode::Delete, _) => { self.cancel_history_navigation(); self.delete_at_cursor(); Vec::new() } (KeyCode::PageUp, _) => { self.scroll_output_up(); Vec::new() } (KeyCode::PageDown, _) => { self.scroll_output_down(); Vec::new() } (KeyCode::Char(c), m) if !m.contains(KeyModifiers::CONTROL) => { self.cancel_history_navigation(); let was_empty = self.input.is_empty(); self.insert_at_cursor(c); // Convenience: when `:` becomes the leading character in // simple mode, auto-insert a space after it so the input // reads ": foo" rather than ":foo". The trailing space is // an ordinary character — backspace removes it normally. if c == ':' && was_empty && self.mode == Mode::Simple { self.insert_at_cursor(' '); } Vec::new() } _ => Vec::new(), } } fn cursor_left(&mut self) { let mut idx = self.input_cursor; while idx > 0 { idx -= 1; if self.input.is_char_boundary(idx) { self.input_cursor = idx; return; } } self.input_cursor = 0; } fn cursor_right(&mut self) { let mut idx = self.input_cursor; while idx < self.input.len() { idx += 1; if self.input.is_char_boundary(idx) { self.input_cursor = idx; return; } } self.input_cursor = self.input.len(); } fn insert_at_cursor(&mut self, c: char) { // Defensive clamp: callers (and tests) may mutate // `input` directly; keep the cursor inside the buffer. if self.input_cursor > self.input.len() { self.input_cursor = self.input.len(); } self.input.insert(self.input_cursor, c); self.input_cursor += c.len_utf8(); } fn delete_before_cursor(&mut self) { if self.input_cursor == 0 { return; } // Find the start of the previous character. let mut idx = self.input_cursor - 1; while !self.input.is_char_boundary(idx) { idx -= 1; } self.input.replace_range(idx..self.input_cursor, ""); self.input_cursor = idx; } fn delete_at_cursor(&mut self) { if self.input_cursor >= self.input.len() { return; } // Find the end of the character at the cursor. let mut idx = self.input_cursor + 1; while idx < self.input.len() && !self.input.is_char_boundary(idx) { idx += 1; } self.input.replace_range(self.input_cursor..idx, ""); } /// Move backwards in history (towards older entries). fn history_back(&mut self) { if self.history.is_empty() { return; } let next_index = match self.history_cursor { None => { // Starting navigation: save the current draft so the // user can return to it. self.history_draft = Some(self.input.clone()); self.history.len() - 1 } Some(0) => 0, Some(i) => i - 1, }; self.history_cursor = Some(next_index); self.input = self.history[next_index].clone(); self.input_cursor = self.input.len(); } /// Move forwards in history (towards newer entries; eventually /// returning to the user's saved draft). fn history_forward(&mut self) { let Some(i) = self.history_cursor else { return; }; if i + 1 < self.history.len() { self.history_cursor = Some(i + 1); self.input = self.history[i + 1].clone(); } else { // Past the most recent entry — restore the draft and // exit navigation mode. self.history_cursor = None; self.input = self.history_draft.take().unwrap_or_default(); } self.input_cursor = self.input.len(); } fn cancel_history_navigation(&mut self) { self.history_cursor = None; // Drop the saved draft: the user has begun editing again, // so what's in `input` *is* the new draft. self.history_draft = None; } fn push_history(&mut self, line: &str) { // Skip empties and consecutive duplicates — the same // trick most shells use to keep navigation pleasant. if line.is_empty() { return; } if self.history.last().map(String::as_str) == Some(line) { return; } self.history.push(line.to_string()); while self.history.len() > HISTORY_CAPACITY { self.history.remove(0); } self.history_cursor = None; self.history_draft = None; } fn submit(&mut self) -> Vec { let raw = std::mem::take(&mut self.input); self.input_cursor = 0; let trimmed = raw.trim(); if trimmed.is_empty() { return Vec::new(); } // Record the original (trimmed) line in history regardless // of whether it parses, so users can recall and edit // typo'd commands. self.push_history(trimmed); // `:` one-shot escape: in simple mode, a leading `:` means // treat *this single submission* as advanced. The persistent // mode is unchanged. let (effective_mode, effective_input) = if self.mode == Mode::Simple && trimmed.starts_with(':') { (Mode::Advanced, trimmed[1..].trim().to_string()) } else { (self.mode, trimmed.to_string()) }; if effective_input.is_empty() { return Vec::new(); } // Canonical app-level commands recognised in both modes. // The current iteration implements `quit` and `mode`; // the rest of the canonical list lands in later iterations. match effective_input.as_str() { "quit" | "q" => return vec![Action::Quit], other if other.starts_with("mode") => { self.handle_mode_command(other); return Vec::new(); } _ => {} } // For everything else: dispatch by effective mode. match effective_mode { Mode::Simple => self.dispatch_dsl(&effective_input, effective_mode), Mode::Advanced => { // SQL handling is not implemented yet; show a placeholder // until the advanced-mode SQL path lands. Once it does, // this branch parses with sqlparser-rs and dispatches // analogously to the DSL path below. self.note_system(format!( "advanced mode SQL not implemented yet — echo: {effective_input}" )); self.push_output(OutputLine { text: effective_input, kind: OutputKind::Echo, mode_at_submission: effective_mode, }); Vec::new() } } } fn dispatch_dsl(&mut self, input: &str, submission_mode: Mode) -> Vec { match parse_command(input) { Ok(cmd) => { self.push_output(OutputLine { text: format!("running: {input}"), kind: OutputKind::Echo, mode_at_submission: submission_mode, }); vec![Action::ExecuteDsl(cmd)] } Err(ParseError::Empty) => Vec::new(), Err(err) => { self.note_error(format!("parse error: {}", parse_error_message(&err))); Vec::new() } } } fn handle_dsl_success(&mut self, command: &Command, description: Option) { let summary = format!("[ok] {} {}", command.verb(), command.display_subject()); self.note_system(summary); if let Some(desc) = description.as_ref() { self.note_system(format!(" {}", desc.name)); for col in &desc.columns { let pk = if col.primary_key { " [PK]" } else { "" }; let nn = if col.notnull { " NOT NULL" } else { "" }; // Prefer the user-facing type recovered from our // metadata table; fall back to the SQLite type only // if metadata is missing (only happens for tables we // didn't create — not in the current flow). let type_display = col .user_type .map_or_else(|| col.sqlite_type.to_lowercase(), |t| t.keyword().to_string()); self.note_system(format!( " {} {}{}{}", col.name, type_display, pk, nn )); } if !desc.outbound_relationships.is_empty() { self.note_system(" References:"); for r in &desc.outbound_relationships { self.note_system(format!( " {} → {}.{} ({}, on delete {}, on update {})", r.local_column, r.other_table, r.other_column, r.name, r.on_delete, r.on_update, )); } } if !desc.inbound_relationships.is_empty() { self.note_system(" Referenced by:"); for r in &desc.inbound_relationships { self.note_system(format!( " {}.{} → {} ({}, on delete {}, on update {})", r.other_table, r.other_column, r.local_column, r.name, r.on_delete, r.on_update, )); } } } self.current_table = description; } fn handle_dsl_query_success(&mut self, command: &Command, data: &DataResult) { let summary = format!("[ok] {} {}", command.verb(), command.display_subject()); self.note_system(summary); for line in render_data_view(data) { self.note_system(line); } } fn handle_dsl_insert_success(&mut self, command: &Command, result: &InsertResult) { self.note_system(format!( "[ok] {} {}", command.verb(), command.display_subject() )); self.note_system(format!(" {} row(s) inserted", result.rows_affected)); for line in render_data_view(&result.data) { self.note_system(line); } } fn handle_dsl_update_success(&mut self, command: &Command, result: &UpdateResult) { self.note_system(format!( "[ok] {} {}", command.verb(), command.display_subject() )); self.note_system(format!(" {} row(s) updated", result.rows_affected)); for line in render_data_view(&result.data) { self.note_system(line); } } fn handle_dsl_delete_success(&mut self, command: &Command, result: &DeleteResult) { self.note_system(format!( "[ok] {} {}", command.verb(), command.display_subject() )); self.note_system(format!(" {} row(s) deleted", result.rows_affected)); for effect in &result.cascade { self.note_system(render_cascade_effect(effect)); } } fn handle_dsl_failure(&mut self, command: &Command, error: &str) { warn!(verb = command.verb(), error, "dsl command failed"); // Wrap the command portion in quotes so the message // reads cleanly: "...failed: " rather than the // command running into "failed: ..." with no break. self.note_error(format!( "\"{} {}\" failed: {error}", command.verb(), command.display_subject() )); } fn handle_mode_command(&mut self, raw: &str) { let arg = raw.strip_prefix("mode").unwrap_or(raw).trim(); match arg { "simple" => { self.mode = Mode::Simple; self.note_system("mode: simple"); } "advanced" => { self.mode = Mode::Advanced; self.note_system("mode: advanced"); } "" => self.note_error("usage: mode simple | mode advanced"), other => self.note_error(format!( "unknown mode '{other}' (expected 'simple' or 'advanced')" )), } } fn note_system(&mut self, text: impl Into) { self.push_multiline(text.into(), OutputKind::System); } fn note_error(&mut self, text: impl Into) { self.push_multiline(text.into(), OutputKind::Error); } /// Push possibly-multi-line `text` as a sequence of single-line /// `OutputLine`s. Keeping one display row per `OutputLine` is /// what makes the scroll-position math (line count = display /// rows) accurate; the renderer therefore truncates rather /// than wraps long lines. fn push_multiline(&mut self, text: String, kind: OutputKind) { if text.is_empty() { self.push_output(OutputLine { text, kind, mode_at_submission: self.mode, }); return; } for line in text.split('\n') { self.push_output(OutputLine { text: line.to_string(), kind, mode_at_submission: self.mode, }); } } fn push_output(&mut self, line: OutputLine) { self.output.push_back(line); while self.output.len() > OUTPUT_CAPACITY { self.output.pop_front(); } // Any new line resets the scroll so freshly-arrived // output is always visible. The user can PageUp again // to inspect history. self.output_scroll = 0; } fn scroll_output_up(&mut self) { // Cap at `total_wrapped - visible` (display rows, not // logical lines) so the topmost visible chunk is the // first `visible` rendered rows; going past that would // shrink the view by sliding the window off the top. let max = self .last_output_total_wrapped .saturating_sub(self.last_output_visible.max(1)); self.output_scroll = (self.output_scroll + PAGE_SCROLL_LINES).min(max); } const fn scroll_output_down(&mut self) { self.output_scroll = self.output_scroll.saturating_sub(PAGE_SCROLL_LINES); } } fn parse_error_message(err: &ParseError) -> String { match err { ParseError::Invalid { message, .. } => message.clone(), ParseError::Empty => "empty input".to_string(), } } fn render_cascade_effect(effect: &CascadeEffect) -> String { use crate::dsl::ReferentialAction; let what = match effect.action { ReferentialAction::Cascade => "deleted", ReferentialAction::SetNull => "had FK set to null", ReferentialAction::Restrict | ReferentialAction::NoAction => "blocked", }; format!( " related: {} row(s) {} in `{}` for relationship `{}` (on delete {})", effect.rows_changed, what, effect.child_table, effect.relationship_name, effect.action, ) } /// Render a data result as a sequence of aligned-column text /// lines suitable for the output panel. Pretty box-drawing /// rendering is V4 territory; this version uses simple /// pipe-and-dash separators. fn render_data_view(data: &DataResult) -> Vec { let header = data.columns.clone(); let body: Vec> = data .rows .iter() .map(|row| { row.iter() .map(|cell| { cell.as_ref() .map_or_else(|| "(null)".to_string(), Clone::clone) }) .collect() }) .collect(); // Column widths = max(header, all cells) per column. let mut widths: Vec = header.iter().map(String::len).collect(); for row in &body { for (i, cell) in row.iter().enumerate() { if i < widths.len() && cell.chars().count() > widths[i] { widths[i] = cell.chars().count(); } } } let mut out: Vec = Vec::with_capacity(body.len() + 3); out.push(format!(" {}", join_padded(&header, &widths))); out.push(format!(" {}", separator_row(&widths))); if body.is_empty() { out.push(" (no rows)".to_string()); } else { for row in &body { out.push(format!(" {}", join_padded(row, &widths))); } } out } fn join_padded(cells: &[String], widths: &[usize]) -> String { let mut s = String::new(); for (i, cell) in cells.iter().enumerate() { if i > 0 { s.push_str(" | "); } let w = widths.get(i).copied().unwrap_or(0); s.push_str(cell); let pad = w.saturating_sub(cell.chars().count()); for _ in 0..pad { s.push(' '); } } s } fn separator_row(widths: &[usize]) -> String { let mut s = String::new(); for (i, w) in widths.iter().enumerate() { if i > 0 { s.push_str("-+-"); } for _ in 0..*w { s.push('-'); } } s } #[cfg(test)] mod tests { use super::*; use crate::db::ColumnDescription; use crate::dsl::Type; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use pretty_assertions::assert_eq; fn key(code: KeyCode) -> AppEvent { AppEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)) } fn key_mod(code: KeyCode, mods: KeyModifiers) -> AppEvent { AppEvent::Key(KeyEvent::new(code, mods)) } fn type_str(app: &mut App, s: &str) { for c in s.chars() { app.update(key(KeyCode::Char(c))); } } fn submit(app: &mut App) -> Vec { app.update(key(KeyCode::Enter)) } fn sample_description(name: &str) -> TableDescription { TableDescription { name: name.to_string(), columns: vec![ColumnDescription { name: "id".to_string(), user_type: Some(Type::Serial), sqlite_type: "INTEGER".to_string(), notnull: false, primary_key: true, }], outbound_relationships: Vec::new(), inbound_relationships: Vec::new(), } } #[test] fn typing_accumulates_in_input_buffer() { let mut app = App::new(); type_str(&mut app, "hello"); assert_eq!(app.input, "hello"); assert!(app.output.is_empty()); } #[test] fn backspace_removes_last_char() { let mut app = App::new(); type_str(&mut app, "abc"); app.update(key(KeyCode::Backspace)); assert_eq!(app.input, "ab"); } #[test] fn valid_dsl_in_simple_mode_emits_execute_action() { let mut app = App::new(); type_str(&mut app, "create table Customers with pk"); let actions = submit(&mut app); assert_eq!( actions, vec![Action::ExecuteDsl(Command::CreateTable { name: "Customers".to_string(), columns: vec![crate::dsl::ColumnSpec { name: "id".to_string(), ty: Type::Serial, }], primary_key: vec!["id".to_string()], })] ); // The input is echoed back as a "running:" notice so the // user sees something happened while the DB worker runs. assert!(!app.output.is_empty()); } #[test] fn bare_create_table_emits_friendly_parse_error() { let mut app = App::new(); type_str(&mut app, "create table Customers"); let actions = submit(&mut app); assert!(actions.is_empty()); let last = app.output.back().unwrap(); assert_eq!(last.kind, OutputKind::Error); assert!( last.text.contains("with pk"), "error should mention `with pk`: {}", last.text ); } #[test] fn invalid_dsl_in_simple_mode_produces_parse_error_in_output() { let mut app = App::new(); type_str(&mut app, "frobulate widgets"); let actions = submit(&mut app); assert!(actions.is_empty()); let last = app.output.back().unwrap(); assert_eq!(last.kind, OutputKind::Error); assert!(last.text.starts_with("parse error")); } #[test] fn enter_in_advanced_mode_echoes_with_advanced_tag() { let mut app = App::new(); app.mode = Mode::Advanced; type_str(&mut app, "select 1"); submit(&mut app); // We expect a placeholder system line plus the echoed line. let echoed = app .output .iter() .rfind(|l| l.kind == OutputKind::Echo) .unwrap(); assert_eq!(echoed.mode_at_submission, Mode::Advanced); assert_eq!(echoed.text, "select 1"); } #[test] fn mode_command_switches_persistently() { let mut app = App::new(); type_str(&mut app, "mode advanced"); submit(&mut app); assert_eq!(app.mode, Mode::Advanced); type_str(&mut app, "mode simple"); submit(&mut app); assert_eq!(app.mode, Mode::Simple); } #[test] fn mode_command_with_unknown_arg_errors() { let mut app = App::new(); type_str(&mut app, "mode sideways"); submit(&mut app); assert_eq!(app.mode, Mode::Simple); let last = app.output.back().unwrap(); assert_eq!(last.kind, OutputKind::Error); assert!(last.text.contains("unknown mode")); } #[test] fn colon_prefix_in_simple_mode_evaluates_as_advanced_one_shot() { let mut app = App::new(); type_str(&mut app, ":select 1"); submit(&mut app); // The persistent mode is unchanged. assert_eq!(app.mode, Mode::Simple); // The advanced echo line is present. let echoed = app .output .iter() .rfind(|l| l.kind == OutputKind::Echo) .unwrap(); assert_eq!(echoed.mode_at_submission, Mode::Advanced); assert_eq!(echoed.text, "select 1"); } #[test] fn quit_command_returns_quit_action() { let mut app = App::new(); type_str(&mut app, "quit"); let actions = submit(&mut app); assert_eq!(actions, vec![Action::Quit]); } #[test] fn ctrl_c_returns_quit_action() { let mut app = App::new(); let actions = app.update(key_mod(KeyCode::Char('c'), KeyModifiers::CONTROL)); assert_eq!(actions, vec![Action::Quit]); } #[test] fn empty_submission_is_a_noop() { let mut app = App::new(); let actions = submit(&mut app); assert!(actions.is_empty()); assert!(app.output.is_empty()); } #[test] fn output_buffer_is_capped() { let mut app = App::new(); for i in 0..(OUTPUT_CAPACITY + 50) { app.note_system(format!("line{i}")); } assert_eq!(app.output.len(), OUTPUT_CAPACITY); // Oldest entries were dropped. assert!(app.output.front().unwrap().text.starts_with("line50")); } #[test] fn effective_mode_reflects_persistent_mode_when_no_input() { let mut app = App::new(); assert_eq!(app.effective_mode(), EffectiveMode::Simple); app.mode = Mode::Advanced; assert_eq!(app.effective_mode(), EffectiveMode::AdvancedPersistent); } #[test] fn effective_mode_flips_to_one_shot_when_colon_typed_in_simple_mode() { let mut app = App::new(); type_str(&mut app, ":sel"); assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot); while !app.input.is_empty() { app.update(key(KeyCode::Backspace)); } assert_eq!(app.effective_mode(), EffectiveMode::Simple); } #[test] fn typing_colon_first_in_simple_mode_auto_inserts_a_space() { let mut app = App::new(); type_str(&mut app, ":"); assert_eq!(app.input, ": "); assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot); } #[test] fn typing_colon_after_other_chars_does_not_auto_insert_space() { let mut app = App::new(); type_str(&mut app, "ab:"); assert_eq!(app.input, "ab:"); } #[test] fn typing_colon_in_advanced_mode_does_not_auto_insert_space() { let mut app = App::new(); app.mode = Mode::Advanced; type_str(&mut app, ":"); assert_eq!(app.input, ":"); } #[test] fn auto_inserted_space_can_be_removed_with_backspace() { let mut app = App::new(); type_str(&mut app, ":"); assert_eq!(app.input, ": "); app.update(key(KeyCode::Backspace)); assert_eq!(app.input, ":"); assert_eq!(app.effective_mode(), EffectiveMode::AdvancedOneShot); } #[test] fn dsl_success_event_records_table_view_and_appends_summary() { let mut app = App::new(); let cmd = Command::CreateTable { name: "Customers".to_string(), columns: vec![crate::dsl::ColumnSpec { name: "id".to_string(), ty: Type::Serial, }], primary_key: vec!["id".to_string()], }; let desc = sample_description("Customers"); app.update(AppEvent::DslSucceeded { command: cmd, description: Some(desc.clone()), }); assert_eq!(app.current_table, Some(desc)); let last = app.output.back().unwrap(); // Last line is the column row of the structure summary. assert!(last.text.contains("id")); // Earlier line is the [ok] header. assert!(app.output.iter().any(|l| l.text.starts_with("[ok]"))); } #[test] fn dsl_failure_event_writes_error_with_friendly_message() { let mut app = App::new(); let cmd = Command::DropTable { name: "Ghost".to_string(), }; app.update(AppEvent::DslFailed { command: cmd, error: "no such table: Ghost".to_string(), }); let last = app.output.back().unwrap(); assert_eq!(last.kind, OutputKind::Error); assert!(last.text.contains("Ghost")); assert!(last.text.contains("no such table")); } #[test] fn tables_refreshed_event_replaces_cached_list() { let mut app = App::new(); app.update(AppEvent::TablesRefreshed(vec![ "A".to_string(), "B".to_string(), ])); assert_eq!(app.tables, vec!["A".to_string(), "B".to_string()]); app.update(AppEvent::TablesRefreshed(vec!["C".to_string()])); assert_eq!(app.tables, vec!["C".to_string()]); } #[test] fn add_column_command_with_unknown_type_reports_parse_error() { let mut app = App::new(); type_str(&mut app, "add column to table T: c (varchar)"); let actions = submit(&mut app); assert!(actions.is_empty()); let last = app.output.back().unwrap(); assert_eq!(last.kind, OutputKind::Error); assert!(last.text.contains("varchar")); } #[test] fn drop_table_command_emits_execute_action() { let mut app = App::new(); type_str(&mut app, "drop table T"); let actions = submit(&mut app); assert_eq!( actions, vec![Action::ExecuteDsl(Command::DropTable { name: "T".to_string() })] ); } #[test] fn typing_moves_cursor_to_end_of_input() { let mut app = App::new(); type_str(&mut app, "hello"); assert_eq!(app.input, "hello"); assert_eq!(app.input_cursor, 5); } #[test] fn left_arrow_moves_cursor_back_one_char() { let mut app = App::new(); type_str(&mut app, "hello"); app.update(key(KeyCode::Left)); assert_eq!(app.input_cursor, 4); app.update(key(KeyCode::Left)); assert_eq!(app.input_cursor, 3); } #[test] fn left_arrow_at_zero_does_not_underflow() { let mut app = App::new(); app.update(key(KeyCode::Left)); assert_eq!(app.input_cursor, 0); } #[test] fn right_arrow_moves_cursor_forward() { let mut app = App::new(); type_str(&mut app, "hello"); app.input_cursor = 0; app.update(key(KeyCode::Right)); assert_eq!(app.input_cursor, 1); } #[test] fn home_and_end_jump_to_extremes() { let mut app = App::new(); type_str(&mut app, "hello"); app.update(key(KeyCode::Home)); assert_eq!(app.input_cursor, 0); app.update(key(KeyCode::End)); assert_eq!(app.input_cursor, 5); } #[test] fn typing_inserts_at_cursor_position() { let mut app = App::new(); type_str(&mut app, "hello"); // Cursor between 'h' and 'e'. app.input_cursor = 1; type_str(&mut app, "X"); assert_eq!(app.input, "hXello"); assert_eq!(app.input_cursor, 2); } #[test] fn backspace_removes_char_before_cursor() { let mut app = App::new(); type_str(&mut app, "hello"); // Cursor at end. app.update(key(KeyCode::Backspace)); assert_eq!(app.input, "hell"); assert_eq!(app.input_cursor, 4); // Cursor in the middle. app.input_cursor = 2; // between 'e' and 'l' app.update(key(KeyCode::Backspace)); assert_eq!(app.input, "hll"); assert_eq!(app.input_cursor, 1); } #[test] fn backspace_at_start_is_a_noop() { let mut app = App::new(); type_str(&mut app, "hello"); app.input_cursor = 0; app.update(key(KeyCode::Backspace)); assert_eq!(app.input, "hello"); assert_eq!(app.input_cursor, 0); } #[test] fn delete_removes_char_at_cursor() { let mut app = App::new(); type_str(&mut app, "hello"); app.input_cursor = 1; // between 'h' and 'e' app.update(key(KeyCode::Delete)); assert_eq!(app.input, "hllo"); assert_eq!(app.input_cursor, 1); } #[test] fn delete_at_end_is_a_noop() { let mut app = App::new(); type_str(&mut app, "hello"); app.update(key(KeyCode::Delete)); assert_eq!(app.input, "hello"); assert_eq!(app.input_cursor, 5); } #[test] fn cursor_handles_multibyte_chars() { let mut app = App::new(); type_str(&mut app, "héllo"); // 'é' is 2 bytes // input length is 6 bytes, 5 chars assert_eq!(app.input.len(), 6); assert_eq!(app.input_cursor, 6); // Move left across the 2-byte char. app.update(key(KeyCode::Left)); assert_eq!(app.input_cursor, 5); app.update(key(KeyCode::Left)); assert_eq!(app.input_cursor, 4); app.update(key(KeyCode::Left)); assert_eq!(app.input_cursor, 3); app.update(key(KeyCode::Left)); // Now at the byte before 'é' — must skip the multi-byte char. assert_eq!(app.input_cursor, 1); } #[test] fn submit_resets_cursor_to_zero() { let mut app = App::new(); type_str(&mut app, "drop table T"); submit(&mut app); assert_eq!(app.input_cursor, 0); } #[test] fn page_up_scrolls_output_back() { let mut app = App::new(); for i in 0..30 { app.note_system(format!("line{i}")); } // Simulate a render establishing 10 visible / 30 wrapped. app.note_output_viewport(10, 30); assert_eq!(app.output_scroll, 0); app.update(key(KeyCode::PageUp)); assert_eq!(app.output_scroll, super::PAGE_SCROLL_LINES); } #[test] fn page_down_scrolls_output_back_to_bottom() { let mut app = App::new(); for i in 0..30 { app.note_system(format!("line{i}")); } app.note_output_viewport(10, 30); for _ in 0..3 { app.update(key(KeyCode::PageUp)); } assert!(app.output_scroll > 0); for _ in 0..10 { app.update(key(KeyCode::PageDown)); } assert_eq!(app.output_scroll, 0); } #[test] fn new_output_resets_scroll_to_zero() { let mut app = App::new(); for i in 0..30 { app.note_system(format!("line{i}")); } app.note_output_viewport(10, 30); app.update(key(KeyCode::PageUp)); assert!(app.output_scroll > 0); // Any new output line snaps the scroll back to bottom so // the user always sees the latest result after a command. app.note_system("fresh"); assert_eq!(app.output_scroll, 0); } #[test] fn page_up_caps_at_top_of_buffer() { let mut app = App::new(); app.note_system("only line"); // Many PageUps in a row should not push past the buffer. for _ in 0..50 { app.update(key(KeyCode::PageUp)); } // With 1 line in the buffer, the maximum scroll is 0 // (since there's nothing older to reveal). assert_eq!(app.output_scroll, 0); } #[test] fn page_up_at_top_of_buffer_does_not_shrink_visible_window() { // Regression: extra PageUps past the top used to drift // `output_scroll` higher than `len - visible`, which // then made the rendered window slide off the top and // appeared to "eat" lines from the bottom. let mut app = App::new(); for i in 0..30 { app.note_system(format!("line{i}")); } // Simulate a render reporting 10 visible rows over a // 30-row wrapped buffer (every line fits in one row in // this test). app.note_output_viewport(10, 30); // Page up many times — past the maximum useful scroll. for _ in 0..20 { app.update(key(KeyCode::PageUp)); } // Cap should be at total_wrapped - visible = 30 - 10 = 20. assert_eq!(app.output_scroll, 20); } #[test] fn note_output_viewport_clamps_a_drifted_scroll_value() { // If the scroll value was set high while the viewport // was unknown (e.g. before the first render), the next // render's report should bring it back into range. let mut app = App::new(); for i in 0..30 { app.note_system(format!("line{i}")); } app.output_scroll = 100; app.note_output_viewport(10, 30); assert_eq!(app.output_scroll, 20); } #[test] fn history_recall_places_cursor_at_end() { let mut app = App::new(); type_str(&mut app, "drop table A"); submit(&mut app); app.update(key(KeyCode::Up)); assert_eq!(app.input, "drop table A"); assert_eq!(app.input_cursor, "drop table A".len()); } #[test] fn history_records_submitted_lines() { let mut app = App::new(); type_str(&mut app, "drop table A"); submit(&mut app); type_str(&mut app, "drop table B"); submit(&mut app); assert_eq!( app.history, vec!["drop table A".to_string(), "drop table B".to_string()] ); } #[test] fn history_skips_consecutive_duplicates() { let mut app = App::new(); type_str(&mut app, "drop table A"); submit(&mut app); type_str(&mut app, "drop table A"); submit(&mut app); type_str(&mut app, "drop table B"); submit(&mut app); type_str(&mut app, "drop table A"); submit(&mut app); assert_eq!( app.history, vec![ "drop table A".to_string(), "drop table B".to_string(), "drop table A".to_string(), ] ); } #[test] fn up_arrow_recalls_most_recent_history_entry() { let mut app = App::new(); type_str(&mut app, "drop table A"); submit(&mut app); type_str(&mut app, "drop table B"); submit(&mut app); app.update(key(KeyCode::Up)); assert_eq!(app.input, "drop table B"); } #[test] fn up_arrow_walks_backwards_through_history() { let mut app = App::new(); for line in ["drop table A", "drop table B", "drop table C"] { type_str(&mut app, line); submit(&mut app); } app.update(key(KeyCode::Up)); assert_eq!(app.input, "drop table C"); app.update(key(KeyCode::Up)); assert_eq!(app.input, "drop table B"); app.update(key(KeyCode::Up)); assert_eq!(app.input, "drop table A"); // Going past the oldest holds at the oldest. app.update(key(KeyCode::Up)); assert_eq!(app.input, "drop table A"); } #[test] fn down_arrow_returns_through_history_to_the_draft() { let mut app = App::new(); for line in ["drop table A", "drop table B"] { type_str(&mut app, line); submit(&mut app); } // Type a draft, then start navigating. type_str(&mut app, "in progress"); app.update(key(KeyCode::Up)); assert_eq!(app.input, "drop table B"); app.update(key(KeyCode::Up)); assert_eq!(app.input, "drop table A"); app.update(key(KeyCode::Down)); assert_eq!(app.input, "drop table B"); app.update(key(KeyCode::Down)); // Past the newest, restore the draft. assert_eq!(app.input, "in progress"); } #[test] fn down_arrow_with_no_history_navigation_is_a_noop() { let mut app = App::new(); type_str(&mut app, "draft"); app.update(key(KeyCode::Down)); assert_eq!(app.input, "draft"); } #[test] fn editing_during_history_navigation_cancels_it() { let mut app = App::new(); type_str(&mut app, "drop table A"); submit(&mut app); app.update(key(KeyCode::Up)); assert_eq!(app.input, "drop table A"); // Editing the recalled line cancels navigation: another // Up press should re-enter navigation from the new draft. type_str(&mut app, "X"); assert_eq!(app.input, "drop table AX"); app.update(key(KeyCode::Up)); // Up brings the most recent history back, saving the // edited draft. assert_eq!(app.input, "drop table A"); app.update(key(KeyCode::Down)); assert_eq!(app.input, "drop table AX"); } #[test] fn add_column_with_text_type_emits_execute_action() { let mut app = App::new(); type_str(&mut app, "add column to table T: Name (text)"); let actions = submit(&mut app); assert_eq!( actions, vec![Action::ExecuteDsl(Command::AddColumn { table: "T".to_string(), column: "Name".to_string(), ty: Type::Text, })] ); } }