Cleanup pass: --help, in-app help, post-rebuild message, unmodified-temp cleanup

Four post-Iteration-4 polish items surfaced by manual testing.

1. `--help` / `-h` CLI flag prints a usage banner (options +
   app-level commands + DSL grammar reference) and exits. Parse
   errors also print the banner to stderr.

2. `help` app-level command notes the same list of supported
   commands to the output panel -- a simple stand-in for the
   richer H3 help system, kept in sync with what's actually
   wired up.

3. The silent rebuild that runs when playground.db is missing
   now surfaces a system message in the output panel ("[ok]
   rebuild -- N tables, M rows reconstructed; ...") via a new
   initial_events plumbing. The user no longer wonders whether
   the .db was magically restored or whether anything happened
   on launch.

4. Unmodified empty temp projects (kind=Temp, project.yaml has
   tables: [] and relationships: []) are now auto-deleted when
   the user switches away (load / new / save as) or quits. This
   addresses the "launch app, load existing project, quit"
   pattern that was leaving an empty temp directory behind
   every time. Modified temps (with any user-created tables or
   relationships) are never auto-deleted; corrupted projects
   are also never auto-deleted (defensive default-to-false on
   yaml read/parse errors).

Tests: 338 passing (272 lib + 9 + 5 + 6 + 20 + 9 + 17),
0 failing, 0 skipped. Clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-08 06:43:49 +00:00
parent f2198275f0
commit b7addd6161
6 changed files with 302 additions and 18 deletions
+90 -17
View File
@@ -64,24 +64,36 @@ pub async fn run(args: Args) -> Result<()> {
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(());
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
// so the user sees that the .db was
// reconstructed rather than wondering whether
// anything happened.
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");
}
}
return Err(anyhow::anyhow!(e.friendly_message())).context("rebuild from text");
}
let mut terminal = setup_terminal().context("setup terminal")?;
@@ -95,6 +107,7 @@ pub async fn run(args: Args) -> Result<()> {
},
display_name,
project_is_temp,
initial_events,
)
.await;
if let Err(e) = teardown_terminal(&mut terminal) {
@@ -149,6 +162,7 @@ async fn run_loop(
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());
@@ -157,6 +171,14 @@ async fn run_loop(
app.project_name = Some(project_display_name);
app.project_is_temp = project_is_temp;
// 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
@@ -251,6 +273,31 @@ async fn run_loop(
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 removes the dir.
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 {
if let Err(e) = std::fs::remove_dir_all(&stale) {
tracing::warn!(
path = %stale.display(),
error = %e,
"could not clean up unmodified temp project on quit",
);
} else {
tracing::info!(
path = %stale.display(),
"cleaned up unmodified temp project on quit",
);
}
}
info!("event loop exited");
Ok(app.fatal_message.clone())
}
@@ -343,6 +390,15 @@ async fn perform_switch(
copy_project(&src, dst).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"
@@ -351,6 +407,23 @@ async fn perform_switch(
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.
if let Some(stale) = outgoing_cleanup_path {
if let Err(e) = std::fs::remove_dir_all(&stale) {
tracing::warn!(
path = %stale.display(),
error = %e,
"could not clean up unmodified temp project",
);
} else {
tracing::info!(
path = %stale.display(),
"cleaned up unmodified temp project on switch",
);
}
}
// Open the destination project.
let new_project = match &req {
SwitchRequest::Load { .. } | SwitchRequest::SaveAs { .. } => {