//! 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::path::PathBuf; 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::{ Project, ProjectKind, copy_project, list_projects, open_or_create, projects_dir, read_last_project, resolve_data_root, safely_delete_temp_project, write_last_project, }; 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<()> { // Resolve data root explicitly so run_loop can refer back // to it for `new` (creates a temp) and `load` (lists // projects). We can't easily recover this from the // Project alone, so we keep it ourselves. let data_root = resolve_data_root(args.data_dir.as_deref()) .context("resolve data root")?; // Resolve the initial project path: --resume reads it from // /last_project; otherwise an explicit positional // arg, falling back to a fresh auto-named temp. // // ADR-0015 §7: --resume errors out cleanly when the path is // missing or the recorded project no longer exists. We // surface those failures to stderr before booting the // terminal so the message lands directly in the user's // shell. let initial_path: Option = if args.resume { match read_last_project(&data_root) .context("read last_project")? { Some(p) if p.exists() => Some(p), Some(p) => { eprintln!( "rdbms-playground: --resume: recorded project `{}` no longer exists", p.display(), ); return Ok(()); } None => { eprintln!( "rdbms-playground: --resume: no previous project recorded under `{}`", data_root.display(), ); return Ok(()); } } } else { args.project_path.clone() }; let project = open_or_create(initial_path.as_deref(), Some(data_root.as_path())) .context("open or create project")?; // Run any pending project.yaml migrations before the // database opens (so the rebuild path only ever sees the // latest schema). The registry is empty in v1; future // versions register their migrators here. A migration // that runs is recorded in tracing and leaves a // `project.yaml.v.bak` breadcrumb on disk; that's // sufficient v1 UX and lets us defer dedicated event // plumbing until a real migrator demands it. let migrate_registry = crate::persistence::migrations::MigratorRegistry::production(); let migration_outcome = crate::persistence::migrations::ensure_project_yaml_migrated( project.path(), &migrate_registry, ) .context("migrate project.yaml")?; if let Some(from) = migration_outcome.migrated_from { info!( from_version = from, to_version = migrate_registry.latest_version(), "migrated project.yaml", ); } // Record the just-opened project as the new resume target. // Write failures here are non-fatal: --resume on the next // launch will report the missing/stale state, which is the // safer default than refusing to launch. if let Err(e) = write_last_project(&data_root, project.path()) { warn!(error = %e, "could not update last_project"); } let db_path = project.db_path(); let display_name = project.display_name().to_string(); let project_path = project.path().to_path_buf(); let project_is_temp = matches!(project.kind(), ProjectKind::Temp); 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")?; let mut initial_events: Vec = Vec::new(); if !db_existed { match database.rebuild_from_text(project_path.clone(), None).await { Ok(()) => { // Surface the silent rebuild as a system note // *only* when there was something material to // reconstruct. A fresh-launch temp project // hits this branch with zero tables and zero // rows — the rebuild ran, but nothing // reconstructable existed, so there's nothing // worth telling the user. The explicit // `rebuild` command still always reports its // summary (see spawn_rebuild) since the user // asked. if project_has_content(&project_path) { let summary = summarize_project(&project_path).unwrap_or_else(|_| { "rebuilt playground.db from project.yaml + data/".to_string() }); initial_events.push(AppEvent::RebuildSucceeded { summary }); } } Err(e) => { // 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, Session { project: Some(project), database: Some(database), data_root, }, display_name, project_is_temp, initial_events, ) .await; if let Err(e) = teardown_terminal(&mut terminal) { // Teardown failures should not mask the primary error. warn!(error = %e, "terminal teardown failed"); } // 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(|_| ()) } /// Mutable state owned by `run_loop` that survives project /// switches: the live `Project` (with its lock), the live /// `Database` (with its worker), and the active data root /// for resolving relative paths in `save as` / `new` / load /// picker listings. /// /// `project` and `database` are wrapped in `Option` so a /// project switch can `take()` the old (dropping its lock /// and worker) before opening the new — required for the /// "switch to my own current project" case where the new /// open would otherwise see a self-held lock. struct Session { project: Option, database: Option, data_root: std::path::PathBuf, } impl Session { const fn project(&self) -> &Project { match self.project.as_ref() { Some(p) => p, None => panic!("project always set during run_loop"), } } const fn database(&self) -> &Database { match self.database.as_ref() { Some(d) => d, None => panic!("database always set during run_loop"), } } } async fn run_loop( terminal: &mut Terminal>, theme: Theme, mut session: Session, project_display_name: String, project_is_temp: bool, initial_events: Vec, ) -> Result> { let (event_tx, mut event_rx) = mpsc::channel::(EVENT_CHANNEL_CAPACITY); let reader_handle = spawn_event_reader(event_tx.clone()); let mut app = App::new(); app.project_name = Some(project_display_name); app.project_is_temp = project_is_temp; // Seed the in-memory navigable history from the // initial project's history.log (I2-persist, ADR-0015 // §12). Subsequent project switches re-seed via the // `ProjectSwitched` event payload. app.seed_history(read_history_seed(session.project().path())); // Send any startup events (e.g., the system-message form // of "rebuilt from text on missing .db") so they're // dispatched through the normal event path and end up in // the output panel before the user types anything. for event in initial_events { let _ = event_tx.send(event).await; } // 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(session.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( session.database().clone(), event_tx.clone(), command, source, ); } Action::PrepareRebuild => { spawn_prepare_rebuild( session.project().path().to_path_buf(), event_tx.clone(), ); } Action::Rebuild { source } => { spawn_rebuild( session.database().clone(), session.project().path().to_path_buf(), event_tx.clone(), source, ); } Action::OpenLoadPicker => { let entries: Vec = list_projects(&session.data_root) .into_iter() .map(|p| crate::app::LoadPickerEntry { display_name: p.display_name, modified: p.modified, path: p.path, is_temp: matches!(p.kind, ProjectKind::Temp), }) .collect(); let _ = event_tx.send(AppEvent::LoadPickerReady { entries }).await; } Action::LoadProject { path, source } => { handle_project_switch( &mut session, SwitchRequest::Load { path }, source, &event_tx, ) .await; } Action::SaveAs { target, source } => { handle_project_switch( &mut session, SwitchRequest::SaveAs { target }, source, &event_tx, ) .await; } Action::NewProject { source } => { handle_project_switch( &mut session, SwitchRequest::NewTemp, source, &event_tx, ) .await; } Action::Export { target, source } => { spawn_export( session.project().path().to_path_buf(), directory_basename(session.project().path()), session.data_root.clone(), target, source, event_tx.clone(), ); } Action::Import { zip_path, as_target, source, } => { handle_project_switch( &mut session, SwitchRequest::Import { zip_path: std::path::PathBuf::from(&zip_path), as_target, }, source, &event_tx, ) .await; } } } terminal .draw(|f| ui::render(&mut app, &theme, f)) .context("redraw")?; if should_quit { break; } } let _ = tokio::time::timeout(SHUTDOWN_GRACE, reader_handle).await; // Auto-delete the active project on quit if it's an // unmodified temp — same rule as on project switch (see // perform_switch). Captures the path first, drops the // project (releasing the lock), then asks // safely_delete_temp_project to verify the directory // before removing it. let cleanup_on_quit: Option = session .project .as_ref() .and_then(|p| p.is_unmodified_temp().then(|| p.path().to_path_buf())); let _ = session.database.take(); let _ = session.project.take(); if let Some(stale) = cleanup_on_quit { match safely_delete_temp_project(&stale, &session.data_root) { Ok(()) => tracing::info!( path = %stale.display(), "cleaned up unmodified temp project on quit", ), Err(e) => tracing::warn!( path = %stale.display(), error = %e, "did not clean up unmodified temp project on quit", ), } } info!("event loop exited"); Ok(app.fatal_message.clone()) } /// What kind of project switch the user requested. enum SwitchRequest { /// `load` (picker or browse-to-path) — open an existing /// project at `path`. Load { path: std::path::PathBuf }, /// `save as` — copy the current project to a new /// location, then open that copy. `target` is a name /// (resolved under `/projects/`) or an /// absolute path. SaveAs { target: String }, /// `new` — close current, create a fresh auto-named temp. NewTemp, /// `import` — extract a zip into a new project under the /// data root's projects dir, then switch to it. The /// destination basename is taken from the zip's /// top-level folder by default (`as_target` is `None`), /// or from the user-supplied override; collisions /// auto-suffix `-NN` (ADR-0015 §11 amendment). Import { zip_path: std::path::PathBuf, as_target: Option, }, } /// Common project-switch path. Drops the current project + /// database (releasing the lock and stopping the worker), /// opens the new one, runs a rebuild if the .db is missing, /// appends history.log, and sends a `ProjectSwitched` event /// so App refreshes its display. /// /// Errors are surfaced as `ProjectSwitchFailed` (non-fatal): /// the current project remains active. async fn handle_project_switch( session: &mut Session, req: SwitchRequest, source: String, event_tx: &mpsc::Sender, ) { match perform_switch(session, req, source).await { Ok((display_name, is_temp)) => { let history_entries = read_history_seed(session.project().path()); let _ = event_tx .send(AppEvent::ProjectSwitched { display_name, is_temp, history_entries, }) .await; if let Ok(tables) = session.database().list_tables().await { let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await; } } Err(e) => { let _ = event_tx .send(AppEvent::ProjectSwitchFailed { error: e }) .await; } } } /// Read the most-recent `HISTORY_HYDRATION_CAP` source lines /// out of the project's `history.log` for input-history /// seeding. Failures are logged and swallowed — an empty /// hydration is the right fallback when the file is unreadable. fn read_history_seed(project_path: &std::path::Path) -> Vec { let p = crate::persistence::Persistence::new(project_path.to_path_buf()); match p.read_recent_history(HISTORY_HYDRATION_CAP) { Ok(entries) => entries, Err(e) => { tracing::warn!(error = %e, "history hydration failed; starting empty"); Vec::new() } } } /// Maximum number of `history.log` entries to seed the /// in-memory navigable history with on project open. Matches /// the in-memory cap (`app::HISTORY_CAPACITY`) per ADR-0015 /// §12: "latest N entries, where N is the same in-memory /// cap as today." const HISTORY_HYDRATION_CAP: usize = 1000; async fn perform_switch( session: &mut Session, req: SwitchRequest, source: String, ) -> Result<(String, bool), String> { use crate::persistence::Persistence; // For SaveAs we need a resolved target path up front // (so the existence check happens before we drop the // current project). For NewTemp we'll let create_temp // pick the path. For Load it's the user-supplied path. // For Import we inspect the zip and resolve the target // (auto-suffixing on collision per ADR-0015 §11 // amendment) before touching anything else. let resolved_target: Option = match &req { SwitchRequest::Load { path } => { if !path.exists() { return Err(format!("path `{}` does not exist", path.display())); } Some(path.clone()) } SwitchRequest::SaveAs { target } => { let p = resolve_save_target(target, &session.data_root); if p.exists() { return Err(format!( "`{}` already exists; pick a different name or remove it first", p.display(), )); } Some(p) } SwitchRequest::NewTemp => None, SwitchRequest::Import { zip_path, as_target } => { if !zip_path.exists() { return Err(format!("zip `{}` does not exist", zip_path.display())); } // Validate the zip up front so we don't drop the // current project for an unimportable file. let inspection = crate::archive::inspect_zip(zip_path) .map_err(|e| e.to_string())?; let resolved = resolve_import_destination( as_target.as_deref(), &inspection.top_folder, &session.data_root, )?; Some(resolved) } }; // For SaveAs: copy current project to the target while // the source is still on disk (auto-save guarantees its // state matches the in-memory db). if let SwitchRequest::SaveAs { .. } = &req { let src = session.project().path().to_path_buf(); let dst = resolved_target.as_ref().expect("SaveAs has resolved target"); copy_project(&src, dst).map_err(|e| e.to_string())?; } // For Import: extract the zip into the resolved target. // We do this *before* dropping the current project so // a failure here leaves the user where they were. if let SwitchRequest::Import { zip_path, .. } = &req { let dst = resolved_target.as_ref().expect("Import has resolved target"); let inspection = crate::archive::inspect_zip(zip_path) .map_err(|e| e.to_string())?; crate::archive::extract_into(zip_path, dst, &inspection.top_folder) .map_err(|e| e.to_string())?; } // Capture cleanup info from the OUTGOING project before // we drop it: if it was an unmodified empty temp, we // delete its directory after the switch so the data dir // doesn't accumulate empty scratch projects. let outgoing_cleanup_path: Option = session.project.as_ref().and_then(|p| { p.is_unmodified_temp().then(|| p.path().to_path_buf()) }); // Drop current project + database BEFORE opening the new // ones, releasing the old lock and stopping the old // worker. Required for the "load my own current project" // case (otherwise the new open would see a self-held // lock on this PID). let _ = session.database.take(); let _ = session.project.take(); // The outgoing project's lock is now released; it's // safe to remove its directory if it was unmodified. // The safely_delete_temp_project helper layers multiple // guards (containment under data root, [temp] marker, // contents allowlist, no symlinks) so a bug elsewhere // can't escalate into deleting the wrong directory. if let Some(stale) = outgoing_cleanup_path { match safely_delete_temp_project(&stale, &session.data_root) { Ok(()) => tracing::info!( path = %stale.display(), "cleaned up unmodified temp project on switch", ), Err(e) => tracing::warn!( path = %stale.display(), error = %e, "did not clean up unmodified temp project on switch", ), } } // Open the destination project. Load / SaveAs / Import // all open a path that already has the on-disk skeleton // (either because it pre-existed or because we just put // it there); NewTemp asks the project module for a fresh // auto-named one. let new_project = match &req { SwitchRequest::Load { .. } | SwitchRequest::SaveAs { .. } | SwitchRequest::Import { .. } => { let path = resolved_target.expect("Load/SaveAs/Import have resolved target"); Project::open(&path).map_err(|e| e.to_string())? } SwitchRequest::NewTemp => { Project::create_temp(&session.data_root).map_err(|e| e.to_string())? } }; let new_path = new_project.path().to_path_buf(); // Run any pending project.yaml migrations before the // database opens. Same registry as `run()`. A failed // migration aborts the switch (the old project has // already been dropped — user lands in a "no project" // state momentarily, but the next user action will // surface the error and they can retry). let migrate_registry = crate::persistence::migrations::MigratorRegistry::production(); crate::persistence::migrations::ensure_project_yaml_migrated( new_project.path(), &migrate_registry, ) .map_err(|e| e.to_string())?; // Open the new database (rebuild from text if .db is // missing — applies to NewTemp's just-created project, // and to Load when the user opened a project whose .db // had been deleted). let db_path = new_project.db_path(); let db_existed = db_path.exists(); let persistence = Persistence::new(new_path.clone()); let new_database = Database::open_with_persistence(&db_path, persistence).map_err(|e| e.to_string())?; if !db_existed && let Err(e) = new_database.rebuild_from_text(new_path.clone(), None).await { return Err(e.friendly_message()); } let display_name = new_project.display_name().to_string(); let is_temp = matches!(new_project.kind(), ProjectKind::Temp); session.project = Some(new_project); session.database = Some(new_database); // Append the user-issued command to the destination's // history.log. The worker's persistence is wired but not // directly addressable from here, so we use a fresh // Persistence handle for this single line. let _ = Persistence::new(new_path.clone()).append_history(&source); // Update the resume pointer so the next `--resume` // launch reopens the project we just switched to. Write // failures are non-fatal — see the same rationale at // `run()` startup. if let Err(e) = write_last_project(&session.data_root, &new_path) { tracing::warn!(error = %e, "could not update last_project after switch"); } Ok((display_name, is_temp)) } /// Resolve the destination directory for an `import`: /// /// - **No `as `:** use the zip's top-level folder name /// under `/projects/`. Auto-suffix `-NN` on /// collision (ADR-0015 §11 amendment). /// - **`as `:** under `/projects/`, /// auto-suffix on collision. /// - **`as `:** use the path verbatim. Refuse /// if it already exists (no auto-suffix on absolute paths — /// we don't second-guess what the user typed). fn resolve_import_destination( as_target: Option<&str>, zip_top_folder: &str, data_root: &std::path::Path, ) -> Result { if let Some(t) = as_target { let p = std::path::Path::new(t); if p.is_absolute() { if p.exists() { return Err(format!( "`{}` already exists; pick a different target", p.display(), )); } return Ok(p.to_path_buf()); } } let basename: &str = as_target.unwrap_or(zip_top_folder); let parent = projects_dir(data_root); std::fs::create_dir_all(&parent).map_err(|e| e.to_string())?; let (resolved, _) = crate::archive::resolve_import_target(&parent, basename).map_err(|e| e.to_string())?; Ok(resolved) } /// Spawn a blocking task to write an export zip and forward /// the outcome via the event channel. /// /// The current project's auto-save semantics mean /// `` already reflects every successful command, /// so the export reads from disk without coordinating with the /// db worker. The `history.log` entry for this command is /// appended directly here (we already hold the project path /// and don't need to wait for the export to finish before /// recording the user-issued command). fn spawn_export( project_path: std::path::PathBuf, project_name: String, data_root: std::path::PathBuf, target: Option, source: String, event_tx: mpsc::Sender, ) { let _ = crate::persistence::Persistence::new(project_path.clone()).append_history(&source); tokio::spawn(async move { let outcome = tokio::task::spawn_blocking(move || { do_export(&project_path, &project_name, &data_root, target.as_deref()) }) .await; let event = match outcome { Ok(Ok(path)) => AppEvent::ExportSucceeded { path }, Ok(Err(e)) => AppEvent::ExportFailed { error: e }, Err(join_err) => AppEvent::ExportFailed { error: join_err.to_string(), }, }; let _ = event_tx.send(event).await; }); } /// Synchronous body of the export pipeline. fn do_export( project_path: &std::path::Path, project_name: &str, data_root: &std::path::Path, target: Option<&str>, ) -> Result { let final_path: std::path::PathBuf = match target { Some(t) => { let p = std::path::Path::new(t); if p.is_absolute() { p.to_path_buf() } else { data_root.join(t) } } None => { std::fs::create_dir_all(data_root).map_err(|e| e.to_string())?; let (filename, _) = crate::archive::next_export_sequence(data_root, project_name) .map_err(|e| e.to_string())?; data_root.join(filename) } }; if final_path.exists() { return Err(format!( "`{}` already exists; pick a different name or remove it first", final_path.display(), )); } crate::archive::export_project(project_path, project_name, &final_path) .map_err(|e| e.to_string())?; Ok(final_path) } /// The basename of `path` as a `String`. Falls back to the /// full display string when the path has no terminal /// component (e.g. `/`). fn directory_basename(path: &std::path::Path) -> String { path.file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| path.display().to_string()) } /// Resolve a `save as` target path against the data root. /// /// Absolute paths pass through; relative paths join under /// `/projects/` per the user's stated preference /// in ADR-0015 §1 ("named projects right alongside the temp /// ones is the easiest workflow"). fn resolve_save_target(target: &str, data_root: &std::path::Path) -> std::path::PathBuf { let p = std::path::Path::new(target); if p.is_absolute() { p.to_path_buf() } else { projects_dir(data_root).join(p) } } async fn seed_initial_tables(database: &Database, event_tx: &mpsc::Sender) { 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, ) { 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; }); } /// Does the project at `project_path` actually have any /// schema or data? /// /// "Has content" means at least one table is declared in /// `project.yaml` OR at least one CSV row exists under /// `data/`. A brand-new auto-named temp project, having /// neither, returns `false`. Errors reading the project /// (corrupt YAML, missing dir) also return `false` — /// suppressing a misleading "0 tables reconstructed" /// message for a project we can't read is the right default. fn project_has_content(project_path: &std::path::Path) -> bool { let yaml_path = project_path.join(crate::project::PROJECT_YAML); let Ok(yaml) = std::fs::read_to_string(&yaml_path) else { return false; }; let Ok(snapshot) = crate::persistence::parse_schema(&yaml) else { return false; }; !snapshot.tables.is_empty() } 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( database: Database, event_tx: mpsc::Sender, 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), 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 { 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) -> 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>> { 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>, ) -> Result<()> { disable_raw_mode().context("disable raw mode")?; execute!(terminal.backend_mut(), LeaveAlternateScreen) .context("leave alternate screen")?; terminal.show_cursor().context("show cursor")?; Ok(()) }