1b27a0c9b1
A fresh-launch temp project enters run() with no .db, calls rebuild_from_text on an empty schema, and used to surface a "[ok] rebuild — 0 tables and 0 rows will be reconstructed" note that conveyed no information. Now we only emit the note when the project actually has tables. The explicit `rebuild` command still always reports its summary — the user asked.
1123 lines
42 KiB
Rust
1123 lines
42 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::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
|
|
// <data-root>/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<PathBuf> = 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<N>.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<AppEvent> = 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<Project>,
|
|
database: Option<Database>,
|
|
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<CrosstermBackend<io::Stdout>>,
|
|
theme: Theme,
|
|
mut session: Session,
|
|
project_display_name: String,
|
|
project_is_temp: bool,
|
|
initial_events: Vec<AppEvent>,
|
|
) -> 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);
|
|
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<crate::app::LoadPickerEntry> =
|
|
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<std::path::PathBuf> = 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 `<data-root>/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<String>,
|
|
},
|
|
}
|
|
|
|
/// 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<AppEvent>,
|
|
) {
|
|
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<String> {
|
|
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<std::path::PathBuf> = 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<std::path::PathBuf> =
|
|
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 <target>`:** use the zip's top-level folder name
|
|
/// under `<data-root>/projects/`. Auto-suffix `-NN` on
|
|
/// collision (ADR-0015 §11 amendment).
|
|
/// - **`as <relative-name>`:** under `<data-root>/projects/`,
|
|
/// auto-suffix on collision.
|
|
/// - **`as <absolute-path>`:** 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<std::path::PathBuf, String> {
|
|
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
|
|
/// `<project_path>` 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<String>,
|
|
source: String,
|
|
event_tx: mpsc::Sender<AppEvent>,
|
|
) {
|
|
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<std::path::PathBuf, String> {
|
|
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
|
|
/// `<data-root>/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<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;
|
|
});
|
|
}
|
|
|
|
/// 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<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(())
|
|
}
|