Files
rdbms-playground/src/runtime.rs
T
claude@clouddev1 ba93d3c7d8 Iteration 4a: rebuild command with confirmation modal
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.
2026-05-07 22:27:37 +00:00

472 lines
16 KiB
Rust

//! Tokio-based event loop.
//!
//! A blocking task reads crossterm events and forwards them onto
//! an `mpsc` channel as `AppEvent`s. The main loop awaits events,
//! feeds them to `App::update`, enacts any returned `Action`s,
//! and redraws the terminal. DSL execution is dispatched onto
//! the database worker (see `db::Database`), and its result is
//! posted back as a new `AppEvent`. Future async work (snapshot
//! capture, auto-save) joins the same event channel as
//! additional producers.
use std::io;
use std::time::Duration;
use anyhow::{Context, Result};
use crossterm::event::{Event as CtEvent, EventStream};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use futures_util::StreamExt;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use crate::action::Action;
use crate::app::App;
use crate::cli::Args;
use crate::db::{
DataResult, Database, DbError, DeleteResult, InsertResult, TableDescription, UpdateResult,
};
use crate::dsl::Command;
use crate::event::AppEvent;
use crate::project::open_or_create;
use crate::theme::Theme;
use crate::ui;
const EVENT_CHANNEL_CAPACITY: usize = 64;
const SHUTDOWN_GRACE: Duration = Duration::from_millis(100);
/// Run the application until a `Quit` action is enacted or the
/// terminal closes.
pub async fn run(args: Args) -> Result<()> {
let project = open_or_create(args.project_path.as_deref(), args.data_dir.as_deref())
.context("open or create project")?;
let db_path = project.db_path();
let display_name = project.display_name().to_string();
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).
let db_existed = db_path.exists();
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.clone(), None)
.await
{
// The terminal is still in cooked mode here (we haven't
// entered the alternate screen yet), so writing to
// stderr lands directly in the user's shell. Drop the
// project to release the lock first.
drop(project);
if matches!(
e,
DbError::PersistenceFatal { .. } | DbError::RebuildRowFailed { .. }
) {
eprintln!("rdbms-playground: {}", e.friendly_message());
return Ok(());
}
return Err(anyhow::anyhow!(e.friendly_message())).context("rebuild from text");
}
let mut terminal = setup_terminal().context("setup terminal")?;
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");
}
// `project` (and the lock it holds) is dropped here, releasing
// the lock file *after* the terminal has been restored.
drop(project);
// ADR-0015 §8: a fatal persistence failure makes its
// banner visible above the shell prompt by writing to
// stderr after the alternate screen has been left.
if let Ok(Some(banner)) = &result {
eprintln!("{banner}");
}
result.map(|_| ())
}
async fn run_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
theme: Theme,
database: Database,
project_display_name: String,
project_path: std::path::PathBuf,
) -> Result<Option<String>> {
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
let reader_handle = spawn_event_reader(event_tx.clone());
let mut app = App::new();
app.project_name = Some(project_display_name);
// Seed the table list with whatever the database currently
// shows. For a fresh in-memory DB this is empty, but doing
// it explicitly means file-backed databases (track 2) will
// show their tables on launch without changes here.
seed_initial_tables(&database, &event_tx).await;
terminal
.draw(|f| ui::render(&mut app, &theme, f))
.context("initial draw")?;
info!("entering main event loop");
while let Some(event) = event_rx.recv().await {
let actions = app.update(event);
let mut should_quit = false;
for action in actions {
match action {
Action::Quit => {
debug!("quit action received");
should_quit = true;
}
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
.draw(|f| ui::render(&mut app, &theme, f))
.context("redraw")?;
if should_quit {
break;
}
}
let _ = tokio::time::timeout(SHUTDOWN_GRACE, reader_handle).await;
info!("event loop exited");
Ok(app.fatal_message.clone())
}
async fn seed_initial_tables(database: &Database, event_tx: &mpsc::Sender<AppEvent>) {
match database.list_tables().await {
Ok(tables) => {
let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
}
Err(e) => {
error!(error = %e, "failed to seed initial table list");
}
}
}
/// Read `project.yaml` + `data/` to compute the rebuild
/// summary that the confirmation modal shows. Runs off the
/// event loop so the brief I/O doesn't stall input handling
/// even on slow filesystems.
fn spawn_prepare_rebuild(
project_path: std::path::PathBuf,
event_tx: mpsc::Sender<AppEvent>,
) {
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<String, String> {
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<AppEvent>,
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(
database: Database,
event_tx: mpsc::Sender<AppEvent>,
command: Command,
source: String,
) {
tokio::spawn(async move {
let outcome = execute_command_typed(&database, command.clone(), source).await;
let event = match outcome {
Ok(CommandOutcome::Schema(description)) => AppEvent::DslSucceeded {
command: command.clone(),
description,
},
Ok(CommandOutcome::Query(data)) => AppEvent::DslDataSucceeded {
command: command.clone(),
data,
},
Ok(CommandOutcome::Insert(result)) => AppEvent::DslInsertSucceeded {
command: command.clone(),
result,
},
Ok(CommandOutcome::Update(result)) => AppEvent::DslUpdateSucceeded {
command: command.clone(),
result,
},
Ok(CommandOutcome::Delete(result)) => AppEvent::DslDeleteSucceeded {
command: command.clone(),
result,
},
Err(DbError::PersistenceFatal {
operation,
path,
message,
}) => AppEvent::PersistenceFatal {
operation: operation.to_string(),
path,
message,
},
Err(error) => AppEvent::DslFailed {
command: command.clone(),
error: error.friendly_message(),
},
};
if event_tx.send(event).await.is_err() {
return;
}
// Refresh the table list after every DDL operation so
// the items panel reflects reality. A failed list_tables
// here is logged but not surfaced to the user — they
// already saw the primary outcome.
match database.list_tables().await {
Ok(tables) => {
let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
}
Err(e) => warn!(error = %e, "post-list_tables failed"),
}
});
}
enum CommandOutcome {
Schema(Option<TableDescription>),
Query(DataResult),
Insert(InsertResult),
Update(UpdateResult),
Delete(DeleteResult),
}
/// Execute a parsed user command and return either a typed
/// `CommandOutcome` or the raw `DbError`. Keeping the typed
/// error here lets us distinguish persistence-fatal failures
/// from ordinary user errors at dispatch time (ADR-0015 §8).
async fn execute_command_typed(
database: &Database,
command: Command,
source: String,
) -> Result<CommandOutcome, DbError> {
let src = Some(source);
match command {
Command::CreateTable {
name,
columns,
primary_key,
} => database
.create_table(name, columns, primary_key, src)
.await
.map(|d| CommandOutcome::Schema(Some(d))),
Command::DropTable { name } => database
.drop_table(name, src)
.await
.map(|()| CommandOutcome::Schema(None)),
Command::AddColumn { table, column, ty } => database
.add_column(table, column, ty, src)
.await
.map(|d| CommandOutcome::Schema(Some(d))),
Command::AddRelationship {
name,
parent_table,
parent_column,
child_table,
child_column,
on_delete,
on_update,
create_fk,
} => database
.add_relationship(
name,
parent_table,
parent_column,
child_table,
child_column,
on_delete,
on_update,
create_fk,
src,
)
.await
.map(|d| CommandOutcome::Schema(Some(d))),
Command::DropRelationship { selector } => database
.drop_relationship(selector, src)
.await
.map(CommandOutcome::Schema),
Command::ShowTable { name } => database
.describe_table(name, src)
.await
.map(|d| CommandOutcome::Schema(Some(d))),
Command::Insert {
table,
columns,
values,
} => database
.insert(table, columns, values, src)
.await
.map(CommandOutcome::Insert),
Command::Update {
table,
assignments,
filter,
} => database
.update(table, assignments, filter, src)
.await
.map(CommandOutcome::Update),
Command::Delete { table, filter } => database
.delete(table, filter, src)
.await
.map(CommandOutcome::Delete),
Command::ShowData { name } => database
.query_data(name, src)
.await
.map(CommandOutcome::Query),
}
}
fn spawn_event_reader(tx: mpsc::Sender<AppEvent>) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut stream = EventStream::new();
while let Some(maybe_event) = stream.next().await {
match maybe_event {
Ok(CtEvent::Key(key)) => {
if tx.send(AppEvent::Key(key)).await.is_err() {
break;
}
}
Ok(CtEvent::Resize(cols, rows)) => {
if tx.send(AppEvent::Resize { cols, rows }).await.is_err() {
break;
}
}
Ok(_) => {
// Ignore other event kinds (paste, focus, mouse) for now.
}
Err(e) => {
error!(error = %e, "crossterm event stream error");
break;
}
}
}
debug!("event reader exiting");
})
}
fn setup_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>> {
enable_raw_mode().context("enable raw mode")?;
let mut stdout = io::stdout();
// Mouse capture is intentionally NOT enabled: it would prevent the
// host terminal's native text selection (the cost of capturing every
// mouse event), which we don't currently use for anything in-app.
// If we ever want click-to-select panes or scroll wheel handling,
// we'll need a different strategy than blanket capture.
execute!(stdout, EnterAlternateScreen).context("enter alternate screen")?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend).context("construct terminal")?;
Ok(terminal)
}
fn teardown_terminal(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> Result<()> {
disable_raw_mode().context("disable raw mode")?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)
.context("leave alternate screen")?;
terminal.show_cursor().context("show cursor")?;
Ok(())
}