From ba93d3c7d86e8fcfba401c55b1f452c77d59f0fc Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Thu, 7 May 2026 22:27:37 +0000 Subject: [PATCH] Iteration 4a: rebuild command with confirmation modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the explicit `rebuild` app-level command (ADR-0015 §7, §11) and a modal UI infrastructure to host its confirmation dialog. Typing `rebuild` emits Action::PrepareRebuild; the runtime reads project.yaml + data/ to compute a summary ("3 tables and 47 rows will be reconstructed; the existing playground.db will be replaced") and posts AppEvent::RebuildPrepared, which opens the modal. Y confirms, N/Esc cancels. While the modal is open, normal input is gated. The worker's do_rebuild_from_text now wipes existing user tables and metadata before reloading from text, so it works on both fresh and populated databases. Source text is plumbed through rebuild_from_text so the explicit rebuild logs to history.log while the silent on-load rebuild from Iteration 3 stays silent. Modal infrastructure (App.modal field + key routing + centered overlay rendering + word-wrap) is reused by Iteration 4b's save / save as / load / new flows. Tests: 314 passing (268 lib + 9 + 5 + 6 new + 9 + 17), 0 failing, 0 skipped. Clippy clean. --- src/action.rs | 12 ++ src/app.rs | 82 +++++++- src/db.rs | 78 +++++++- src/event.rs | 17 ++ src/runtime.rs | 118 ++++++++++- ...ui__tests__rebuild_confirm_modal_dark.snap | 29 +++ src/ui.rs | 125 ++++++++++++ tests/iteration3_rebuild.rs | 10 +- tests/iteration4a_rebuild_command.rs | 187 ++++++++++++++++++ 9 files changed, 638 insertions(+), 20 deletions(-) create mode 100644 src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap create mode 100644 tests/iteration4a_rebuild_command.rs diff --git a/src/action.rs b/src/action.rs index a7ddede..6e002d9 100644 --- a/src/action.rs +++ b/src/action.rs @@ -24,4 +24,16 @@ pub enum Action { command: Command, source: String, }, + /// User issued the `rebuild` app-level command (ADR-0015 + /// §7, §11). Runtime computes a summary from + /// `project.yaml` + `data/` and posts back as + /// `AppEvent::RebuildPrepared`, which opens the + /// confirmation modal. + PrepareRebuild, + /// User confirmed the rebuild from inside the modal. + /// Runtime wipes the current schema/data and reconstructs + /// from text sources. + Rebuild { + source: String, + }, } diff --git a/src/app.rs b/src/app.rs index ad9debd..fc86964 100644 --- a/src/app.rs +++ b/src/app.rs @@ -111,6 +111,32 @@ pub struct App { /// loop exits and prints it to stderr post-teardown so the /// banner remains above the shell prompt. pub fatal_message: Option, + /// Active modal dialog (rebuild confirmation, save-as path + /// prompt, load picker, …). While `Some`, `update` + /// dispatches keys to the modal instead of the input + /// field. + pub modal: Option, +} + +/// 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), +} + +#[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, } const PAGE_SCROLL_LINES: usize = 5; @@ -142,6 +168,7 @@ impl App { last_output_total_wrapped: 0, project_name: None, fatal_message: None, + modal: None, } } @@ -228,6 +255,20 @@ impl App { 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(format!("[ok] rebuild — {summary}")); + Vec::new() + } + AppEvent::RebuildFailed { error } => { + self.modal = None; + self.note_error(format!("rebuild failed: {error}")); + Vec::new() + } } } @@ -239,6 +280,13 @@ impl App { 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); + } match (key.code, key.modifiers) { (KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit], (KeyCode::Enter, _) => self.submit(), @@ -449,10 +497,12 @@ impl App { } // 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. + // Track-2's full lifecycle command set (`save`, `load`, + // `new`, `export`, `import`) lands across Iterations 4 + // and 5; this iteration adds `rebuild`. match effective_input.as_str() { "quit" | "q" => return vec![Action::Quit], + "rebuild" => return vec![Action::PrepareRebuild], other if other.starts_with("mode") => { self.handle_mode_command(other); return Vec::new(); @@ -610,6 +660,34 @@ impl App { )); } + /// Route a keypress through whichever modal is active. + /// + /// Each modal owns its own tiny state machine. On + /// confirmation, the modal yields one or more `Action`s + /// for the runtime to enact. On dismissal it simply + /// closes itself. + fn handle_modal_key(&mut self, key: KeyEvent) -> Vec { + let Some(modal) = self.modal.as_ref() else { + return Vec::new(); + }; + match modal { + Modal::RebuildConfirm(_) => 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("rebuild cancelled"); + Vec::new() + } + _ => Vec::new(), + }, + } + } + fn handle_mode_command(&mut self, raw: &str) { let arg = raw.strip_prefix("mode").unwrap_or(raw).trim(); match arg { diff --git a/src/db.rs b/src/db.rs index 2faed94..ee3d779 100644 --- a/src/db.rs +++ b/src/db.rs @@ -322,10 +322,11 @@ enum Request { }, /// Rebuild the database from `project.yaml` + `data/` /// (ADR-0015 §7). Used by the runtime when the `.db` file - /// is missing on project open. Iteration 4's `rebuild` - /// app-level command will reuse the same request. + /// is missing on project open and by the explicit + /// `rebuild` app-level command (Iteration 4). RebuildFromText { project_path: std::path::PathBuf, + source: Option, reply: oneshot::Sender>, }, } @@ -543,16 +544,22 @@ impl Database { } /// Rebuild the database from `project.yaml` + `data/` - /// (ADR-0015 §7). Called by the runtime on a missing `.db` - /// at startup; Iteration 4 will also expose this via the - /// `rebuild` app-level command. + /// (ADR-0015 §7). + /// + /// Called by the runtime on a missing `.db` at startup + /// (with `source = None`, no history entry) and by the + /// explicit `rebuild` app-level command (with + /// `source = Some("rebuild")`, which appends to + /// `history.log` on success). pub async fn rebuild_from_text( &self, project_path: std::path::PathBuf, + source: Option, ) -> Result<(), DbError> { let (reply, recv) = oneshot::channel(); self.send(Request::RebuildFromText { project_path, + source, reply, }) .await?; @@ -821,8 +828,17 @@ fn handle_request(conn: &Connection, persistence: Option<&Persistence>, req: Req &table, )); } - Request::RebuildFromText { project_path, reply } => { - let _ = reply.send(do_rebuild_from_text(conn, &project_path)); + Request::RebuildFromText { + project_path, + source, + reply, + } => { + let _ = reply.send(do_rebuild_from_text( + conn, + persistence, + source.as_deref(), + &project_path, + )); } } } @@ -2437,14 +2453,29 @@ fn read_relationships_inbound( /// /// The on-disk text is the authoritative source: this function /// recreates schema, metadata, and rows so the resulting `.db` -/// reflects them exactly. Persistence callbacks are NOT invoked; -/// we're loading, not changing user-visible state. +/// reflects them exactly. Persistence callbacks are NOT invoked +/// for the schema/data writes; we're loading, not changing +/// user-visible state. The exception is `history.log`: when +/// `source` is `Some`, the rebuild was user-initiated (via the +/// `rebuild` app-level command) and is appended as a successful +/// command per ADR-0015 §5. +/// +/// Existing user tables and metadata rows are wiped at the +/// start of the rebuild so this function works on both fresh +/// and populated databases — the silent on-load case (empty +/// db) sees a no-op wipe; the explicit `rebuild` command +/// replaces whatever was there. /// /// FK enforcement is disabled for the load and re-enabled at /// the end (regardless of success). A `foreign_key_check` /// before commit verifies the loaded data is consistent — any /// violation aborts with a fatal error. -fn do_rebuild_from_text(conn: &Connection, project_path: &Path) -> Result<(), DbError> { +fn do_rebuild_from_text( + conn: &Connection, + persistence: Option<&Persistence>, + source: Option<&str>, + project_path: &Path, +) -> Result<(), DbError> { let yaml_path = project_path.join(PROJECT_YAML); let data_dir = project_path.join(DATA_DIR); @@ -2468,6 +2499,25 @@ fn do_rebuild_from_text(conn: &Connection, project_path: &Path) -> Result<(), Db .unchecked_transaction() .map_err(DbError::from_rusqlite)?; + // 0. Wipe any existing user tables + metadata so the + // rebuild can start from a clean slate. This step + // is a no-op on a freshly-created database (the + // silent rebuild on missing-`.db`); on the explicit + // `rebuild` command it replaces the live state with + // the text-source state per ADR-0015 §7. + let existing_tables = do_list_tables(&tx)?; + for name in &existing_tables { + tx.execute_batch(&format!( + "DROP TABLE {ident};", + ident = quote_ident(name) + )) + .map_err(DbError::from_rusqlite)?; + } + tx.execute_batch(&format!( + "DELETE FROM {META_TABLE}; DELETE FROM {REL_TABLE};" + )) + .map_err(DbError::from_rusqlite)?; + // 1. Recreate user tables with FK constraints inline. for table in &snapshot.tables { let read_schema = build_read_schema(table, &snapshot.relationships); @@ -2554,6 +2604,14 @@ fn do_rebuild_from_text(conn: &Connection, project_path: &Path) -> Result<(), Db } } + // 7. Append `history.log` if this rebuild was + // user-initiated (the silent on-load case has + // `source = None`). + if let (Some(p), Some(text)) = (persistence, source) { + p.append_history(text) + .map_err(DbError::from_persistence)?; + } + tx.commit().map_err(DbError::from_rusqlite)?; Ok(()) })(); diff --git a/src/event.rs b/src/event.rs index 75f1daf..f0d7f65 100644 --- a/src/event.rs +++ b/src/event.rs @@ -55,4 +55,21 @@ pub enum AppEvent { path: std::path::PathBuf, message: String, }, + /// Runtime has computed the rebuild summary from + /// `project.yaml` + `data/` and is ready for the user to + /// confirm. App opens the confirmation modal. + RebuildPrepared { + summary: String, + }, + /// Rebuild completed successfully. App closes the modal, + /// surfaces a friendly outcome message, and refreshes the + /// table list. + RebuildSucceeded { + summary: String, + }, + /// Rebuild failed in a non-fatal way (e.g., user-visible + /// constraint problem) — surfaced like other DSL failures. + RebuildFailed { + error: String, + }, } diff --git a/src/runtime.rs b/src/runtime.rs index e298118..67b38a6 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -46,7 +46,8 @@ pub async fn run(args: Args) -> Result<()> { .context("open or create project")?; let db_path = project.db_path(); let display_name = project.display_name().to_string(); - let persistence = crate::persistence::Persistence::new(project.path().to_path_buf()); + let project_path = project.path().to_path_buf(); + let persistence = crate::persistence::Persistence::new(project_path.clone()); // Capture whether the .db file existed BEFORE we open it — // sqlite creates it on connect, so this is the only honest // signal that we need to rebuild from text (ADR-0015 §7). @@ -54,7 +55,9 @@ pub async fn run(args: Args) -> Result<()> { let database = Database::open_with_persistence(db_path.as_path(), persistence) .context("open database")?; if !db_existed - && let Err(e) = database.rebuild_from_text(project.path().to_path_buf()).await + && let Err(e) = database + .rebuild_from_text(project_path.clone(), None) + .await { // The terminal is still in cooked mode here (we haven't // entered the alternate screen yet), so writing to @@ -72,7 +75,14 @@ pub async fn run(args: Args) -> Result<()> { } let mut terminal = setup_terminal().context("setup terminal")?; - let result = run_loop(&mut terminal, args.theme, database, display_name).await; + let result = run_loop( + &mut terminal, + args.theme, + database, + display_name, + project_path, + ) + .await; if let Err(e) = teardown_terminal(&mut terminal) { // Teardown failures should not mask the primary error. warn!(error = %e, "terminal teardown failed"); @@ -95,6 +105,7 @@ async fn run_loop( theme: Theme, database: Database, project_display_name: String, + project_path: std::path::PathBuf, ) -> Result> { let (event_tx, mut event_rx) = mpsc::channel::(EVENT_CHANNEL_CAPACITY); let reader_handle = spawn_event_reader(event_tx.clone()); @@ -125,6 +136,17 @@ async fn run_loop( Action::ExecuteDsl { command, source } => { spawn_dsl_dispatch(database.clone(), event_tx.clone(), command, source); } + Action::PrepareRebuild => { + spawn_prepare_rebuild(project_path.clone(), event_tx.clone()); + } + Action::Rebuild { source } => { + spawn_rebuild( + database.clone(), + project_path.clone(), + event_tx.clone(), + source, + ); + } } } terminal @@ -152,6 +174,96 @@ async fn seed_initial_tables(database: &Database, event_tx: &mpsc::Sender, +) { + tokio::spawn(async move { + let summary = match summarize_project(&project_path) { + Ok(s) => s, + Err(e) => format!("(could not read project sources: {e})"), + }; + let _ = event_tx.send(AppEvent::RebuildPrepared { summary }).await; + }); +} + +fn summarize_project(project_path: &std::path::Path) -> Result { + let yaml_path = project_path.join(crate::project::PROJECT_YAML); + let yaml = std::fs::read_to_string(&yaml_path).map_err(|e| e.to_string())?; + let snapshot = crate::persistence::parse_schema(&yaml).map_err(|e| e.to_string())?; + let table_count = snapshot.tables.len(); + let data_dir = project_path.join(crate::project::DATA_DIR); + let mut row_count: usize = 0; + for table in &snapshot.tables { + let csv_path = data_dir.join(format!("{}.csv", table.name)); + let Ok(body) = std::fs::read_to_string(&csv_path) else { + continue; + }; + // Header line + one line per row (per Iteration 2's + // "no CSV when empty" rule, this is exact). + row_count += body.lines().count().saturating_sub(1); + } + Ok(format!( + "{table_count} table{} and {row_count} row{} will be reconstructed; \ + the existing playground.db will be replaced", + if table_count == 1 { "" } else { "s" }, + if row_count == 1 { "" } else { "s" }, + )) +} + +/// Spawn the actual rebuild and forward the typed outcome +/// back as an `AppEvent`. +fn spawn_rebuild( + database: Database, + project_path: std::path::PathBuf, + event_tx: mpsc::Sender, + source: String, +) { + tokio::spawn(async move { + match database + .rebuild_from_text(project_path.clone(), Some(source)) + .await + { + Ok(()) => { + let summary = summarize_project(&project_path) + .unwrap_or_else(|_| "rebuild complete".to_string()); + let _ = event_tx + .send(AppEvent::RebuildSucceeded { summary }) + .await; + // Refresh the table list so the items panel + // reflects whatever the rebuild produced. + if let Ok(tables) = database.list_tables().await { + let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await; + } + } + Err(DbError::PersistenceFatal { + operation, + path, + message, + }) => { + let _ = event_tx + .send(AppEvent::PersistenceFatal { + operation: operation.to_string(), + path, + message, + }) + .await; + } + Err(other) => { + let _ = event_tx + .send(AppEvent::RebuildFailed { + error: other.friendly_message(), + }) + .await; + } + } + }); +} + /// Spawn a task that runs a DSL command against the database /// and forwards the result back as an `AppEvent`. fn spawn_dsl_dispatch( diff --git a/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap b/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap new file mode 100644 index 0000000..8dcd22d --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap @@ -0,0 +1,29 @@ +--- +source: src/ui.rs +assertion_line: 561 +expression: snapshot +--- +╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ +│(none yet) ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ╭ Rebuild project ─────────────────────────────────────────╮ │ +│ │ │ │ +│ │3 tables and 47 rows will be reconstructed; the existing │ │ +│ │playground.db will be replaced │ │ +│ │ │ │ +│ │Continue? │ │ +│ │ │ │ +│ │[Y] yes [N] no Esc cancel │ │ +│ ╰──────────────────────────────────────────────────────────╯─────────╯ +│ │╭ SIMPLE ──────────────────────────────────────────╮ +│ ││ │ +│ │╰──────────────────────────────────────────────────╯ +│ │╭ Hint ────────────────────────────────────────────╮ +│ ││(no active hint) │ +╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +Project: Term Planner +Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/ui.rs b/src/ui.rs index d478114..f0466d5 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -48,6 +48,117 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) { render_right_column(app, theme, frame, columns[1]); render_project_label(app, theme, frame, outer[1]); render_status_bar(app, theme, frame, outer[2]); + + // Modal dialogs (rebuild confirm, save-as prompt, load + // picker, …) are drawn last so they overlay the rest of + // the frame. + if let Some(modal) = app.modal.as_ref() { + render_modal(modal, theme, frame, area); + } +} + +fn render_modal(modal: &crate::app::Modal, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { + use crate::app::Modal; + match modal { + Modal::RebuildConfirm(m) => render_rebuild_confirm(&m.summary, theme, frame, area), + } +} + +/// Centred dialog with a one-paragraph body and a [Y]es/[N]o +/// hint at the bottom. Sized at min(60 cols, area.width-4) +/// wide and tall enough to fit the wrapped body plus 4 rows +/// of chrome. +fn render_rebuild_confirm(summary: &str, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { + let dialog_w = area.width.min(60).saturating_sub(0); + let dialog_w = dialog_w.max(20); + let inner_w = dialog_w.saturating_sub(4) as usize; + + let body_lines: Vec = wrap_lines(summary, inner_w); + let body_height = body_lines.len() as u16; + // Title row + blank + body + blank + prompt + blank + keys + borders (2). + let dialog_h = body_height.saturating_add(7).min(area.height); + + let x = area.x + (area.width.saturating_sub(dialog_w)) / 2; + let y = area.y + (area.height.saturating_sub(dialog_h)) / 2; + let dialog_area = Rect { + x, + y, + width: dialog_w, + height: dialog_h, + }; + + // Solid background panel so we cover whatever was beneath. + let bg = ratatui::widgets::Clear; + frame.render_widget(bg, dialog_area); + + let title_style = Style::default() + .fg(theme.fg) + .add_modifier(Modifier::BOLD); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(theme.fg)) + .title(Line::from(vec![Span::styled(" Rebuild project ", title_style)])) + .style(Style::default().bg(theme.bg).fg(theme.fg)); + + let mut text_lines: Vec> = Vec::new(); + text_lines.push(Line::from("")); + for line in body_lines { + text_lines.push(Line::from(line)); + } + text_lines.push(Line::from("")); + text_lines.push(Line::from("Continue?")); + text_lines.push(Line::from("")); + text_lines.push(Line::from(vec![ + Span::styled( + "[Y]", + Style::default() + .fg(theme.fg) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" yes "), + Span::styled( + "[N]", + Style::default() + .fg(theme.fg) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" no "), + Span::styled("Esc", Style::default().fg(theme.muted)), + Span::styled(" cancel", Style::default().fg(theme.muted)), + ])); + + let paragraph = Paragraph::new(text_lines) + .block(block) + .wrap(Wrap { trim: false }); + frame.render_widget(paragraph, dialog_area); +} + +/// Greedy word-wrap to `width` columns. Sufficient for the +/// short prose modals carry; we don't try to be Unicode-aware +/// (display-width-wise) since the strings we generate are +/// ASCII-friendly. +fn wrap_lines(s: &str, width: usize) -> Vec { + if width == 0 { + return vec![s.to_string()]; + } + let mut lines: Vec = Vec::new(); + let mut current = String::new(); + for word in s.split_whitespace() { + if !current.is_empty() && current.len() + 1 + word.len() <= width { + current.push(' '); + } else if !current.is_empty() { + lines.push(std::mem::take(&mut current)); + } + current.push_str(word); + } + if !current.is_empty() { + lines.push(current); + } + if lines.is_empty() { + lines.push(String::new()); + } + lines } fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { @@ -433,6 +544,20 @@ mod tests { insta::assert_snapshot!("one_shot_advanced_dark", snapshot); } + #[test] + fn rebuild_confirm_modal_snapshot() { + use crate::app::{Modal, RebuildConfirmModal}; + let mut app = App::new(); + app.modal = Some(Modal::RebuildConfirm(RebuildConfirmModal { + summary: "3 tables and 47 rows will be reconstructed; \ + the existing playground.db will be replaced" + .to_string(), + })); + let theme = Theme::dark(); + let snapshot = render_to_string(&mut app, &theme, 80, 24); + insta::assert_snapshot!("rebuild_confirm_modal_dark", snapshot); + } + #[test] fn populated_with_table_snapshot() { // Items panel lists tables; output panel shows the diff --git a/tests/iteration3_rebuild.rs b/tests/iteration3_rebuild.rs index 20fd228..4bc8a84 100644 --- a/tests/iteration3_rebuild.rs +++ b/tests/iteration3_rebuild.rs @@ -69,7 +69,7 @@ fn rebuild_restores_schema_only_project() { ) .unwrap(); rt().block_on(async { - db.rebuild_from_text(project.path().to_path_buf()) + db.rebuild_from_text(project.path().to_path_buf(), None) .await .expect("rebuild"); }); @@ -137,7 +137,7 @@ fn rebuild_restores_rows_from_csv() { ) .unwrap(); rt().block_on(async { - db.rebuild_from_text(project.path().to_path_buf()) + db.rebuild_from_text(project.path().to_path_buf(), None) .await .expect("rebuild"); }); @@ -226,7 +226,7 @@ fn rebuild_restores_relationships_and_cascade_behaviour() { ) .unwrap(); rt().block_on(async { - db.rebuild_from_text(project.path().to_path_buf()) + db.rebuild_from_text(project.path().to_path_buf(), None) .await .expect("rebuild"); }); @@ -303,7 +303,7 @@ fn rebuild_reports_fatal_error_on_bad_csv_row() { .unwrap(); let err = rt() .block_on(async { - db.rebuild_from_text(project.path().to_path_buf()).await + db.rebuild_from_text(project.path().to_path_buf(), None).await }) .expect_err("must fail with row-level error"); let msg = format!("{err}"); @@ -363,7 +363,7 @@ fn rebuild_preserves_created_at_from_yaml() { ) .unwrap(); rt().block_on(async { - db.rebuild_from_text(project.path().to_path_buf()) + db.rebuild_from_text(project.path().to_path_buf(), None) .await .expect("rebuild"); }); diff --git a/tests/iteration4a_rebuild_command.rs b/tests/iteration4a_rebuild_command.rs new file mode 100644 index 0000000..094d363 --- /dev/null +++ b/tests/iteration4a_rebuild_command.rs @@ -0,0 +1,187 @@ +//! Iteration-4a integration tests: the explicit `rebuild` +//! app-level command (ADR-0015 §7, §11). +//! +//! Covers the App-level dispatch (typing `rebuild` opens the +//! confirmation modal) and the worker-level wipe-and-rebuild +//! against a populated database. The runtime's spawn glue +//! is exercised manually here since we don't boot a Tokio +//! event loop in tests; we drive `Database::rebuild_from_text` +//! directly to verify it works on a populated db. + +use std::fs; + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + +use rdbms_playground::action::Action; +use rdbms_playground::app::{App, Modal, RebuildConfirmModal}; +use rdbms_playground::db::Database; +use rdbms_playground::dsl::{ColumnSpec, Type, Value}; +use rdbms_playground::event::AppEvent; +use rdbms_playground::persistence::Persistence; +use rdbms_playground::project; + +const fn key(code: KeyCode) -> AppEvent { + AppEvent::Key(KeyEvent { + code, + modifiers: KeyModifiers::NONE, + kind: KeyEventKind::Press, + state: crossterm::event::KeyEventState::NONE, + }) +} + +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 tempdir() -> tempfile::TempDir { + tempfile::tempdir().expect("create tempdir") +} + +fn rt() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio rt") +} + +#[test] +fn typing_rebuild_emits_prepare_action() { + let mut app = App::new(); + type_str(&mut app, "rebuild"); + let actions = submit(&mut app); + assert_eq!(actions, vec![Action::PrepareRebuild]); + // No modal yet — the runtime still has to compute the + // summary and post `RebuildPrepared` back. + assert!(app.modal.is_none()); +} + +#[test] +fn rebuild_prepared_event_opens_modal_with_summary() { + let mut app = App::new(); + app.update(AppEvent::RebuildPrepared { + summary: "3 tables and 47 rows will be reconstructed".to_string(), + }); + match app.modal.as_ref() { + Some(Modal::RebuildConfirm(RebuildConfirmModal { summary })) => { + assert!(summary.contains("3 tables")); + } + other => panic!("expected RebuildConfirm modal, got {other:?}"), + } +} + +#[test] +fn modal_y_emits_rebuild_action_and_closes() { + let mut app = App::new(); + app.update(AppEvent::RebuildPrepared { + summary: "summary".to_string(), + }); + let actions = app.update(key(KeyCode::Char('Y'))); + assert_eq!(actions.len(), 1); + let Action::Rebuild { source } = &actions[0] else { + panic!("expected Rebuild action, got {:?}", actions[0]); + }; + assert_eq!(source, "rebuild"); + assert!(app.modal.is_none(), "modal should close on confirm"); +} + +#[test] +fn modal_n_or_esc_dismisses_without_action() { + for code in [KeyCode::Char('N'), KeyCode::Esc] { + let mut app = App::new(); + app.update(AppEvent::RebuildPrepared { + summary: "summary".to_string(), + }); + let actions = app.update(key(code)); + assert!(actions.is_empty(), "no actions emitted on dismiss"); + assert!(app.modal.is_none(), "modal should close on dismiss"); + } +} + +#[test] +fn modal_swallows_unrelated_keys() { + let mut app = App::new(); + app.update(AppEvent::RebuildPrepared { + summary: "summary".to_string(), + }); + // A regular character key should not type into the input + // field while the modal is up. + app.update(key(KeyCode::Char('x'))); + assert!(app.input.is_empty(), "modal should swallow key input"); + assert!(app.modal.is_some(), "modal still active after unrelated key"); +} + +#[test] +fn rebuild_against_populated_db_wipes_and_reloads() { + let data = tempdir(); + let project_path = { + let project = project::open_or_create(None, Some(data.path())).unwrap(); + let path = project.path().to_path_buf(); + let db = Database::open_with_persistence( + project.db_path(), + Persistence::new(path.clone()), + ) + .unwrap(); + rt().block_on(async { + db.create_table( + "Customers".to_string(), + vec![ + ColumnSpec { name: "id".to_string(), ty: Type::Serial }, + ColumnSpec { name: "Name".to_string(), ty: Type::Text }, + ], + vec!["id".to_string()], + Some("create".to_string()), + ) + .await + .unwrap(); + db.insert( + "Customers".to_string(), + None, + vec![Value::Text("Alice".to_string())], + Some("insert".to_string()), + ) + .await + .unwrap(); + }); + drop(db); + drop(project); + path + }; + + // Hand-edit the CSV to introduce a different row content. + // Rebuild should pick up the edited content. + let csv_path = project_path.join("data").join("Customers.csv"); + let edited = fs::read_to_string(&csv_path).unwrap().replace("Alice", "Edna"); + fs::write(&csv_path, edited).unwrap(); + + // Reopen with persistence (the .db still exists but has + // "Alice"). Run rebuild — it should wipe and reload. + let project = project::Project::open(&project_path).unwrap(); + let db = Database::open_with_persistence( + project.db_path(), + Persistence::new(project.path().to_path_buf()), + ) + .unwrap(); + rt().block_on(async { + db.rebuild_from_text(project.path().to_path_buf(), Some("rebuild".to_string())) + .await + .expect("rebuild"); + }); + let rows = rt() + .block_on(async { db.query_data("Customers".to_string(), None).await }) + .unwrap(); + assert_eq!(rows.rows.len(), 1); + assert_eq!(rows.rows[0][1].as_deref(), Some("Edna")); + + // history.log should contain the rebuild entry. + let history = fs::read_to_string(project_path.join("history.log")).unwrap(); + assert!( + history.lines().any(|l| l.ends_with("|ok|rebuild")), + "history.log missing rebuild entry:\n{history}", + ); +}