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:
+90
-17
@@ -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 { .. } => {
|
||||
|
||||
Reference in New Issue
Block a user