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.
This commit is contained in:
claude@clouddev1
2026-05-07 22:27:37 +00:00
parent f0fc063756
commit ba93d3c7d8
9 changed files with 638 additions and 20 deletions
+115 -3
View File
@@ -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<Option<String>> {
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(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<AppEve
}
}
/// 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(