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
+41
View File
@@ -598,6 +598,10 @@ impl App {
// 5 (export, import). // 5 (export, import).
match effective_input.as_str() { match effective_input.as_str() {
"quit" | "q" => return vec![Action::Quit], "quit" | "q" => return vec![Action::Quit],
"help" => {
self.note_help();
return Vec::new();
}
"rebuild" => return vec![Action::PrepareRebuild], "rebuild" => return vec![Action::PrepareRebuild],
"save" => { "save" => {
return self.handle_save_command(false); return self.handle_save_command(false);
@@ -1024,6 +1028,43 @@ impl App {
} }
} }
/// Note a flat list of currently-supported app-level
/// commands to the output panel.
///
/// This is the simple Iteration-4 stand-in for a richer
/// help system (H3 in the requirements doc); it gives the
/// user a quick "what can I type?" reference that's
/// always accurate against the build they're running. As
/// new commands land, append them here.
fn note_help(&mut self) {
self.note_system("Supported commands:");
for line in [
" quit / q — exit",
" help — this list",
" mode simple|advanced — switch input mode",
" rebuild — rebuild .db from project.yaml + data/ (with confirmation)",
" save — save current temp project under a name",
" save as — copy current project to a new name/path",
" new — close current, start a fresh temp project",
" load — open the project picker",
"DSL data commands (in simple mode):",
" create table <T> with pk [<col>:<type>...]",
" drop table <T>",
" add column [to table] <T>: <col> (<type>)",
" add 1:n relationship [as <name>] from <P>.<col> to <C>.<col>",
" [on delete <action>] [on update <action>] [--create-fk]",
" drop relationship <name>",
" insert into <T> [(cols)] [values] (vals)",
" update <T> set <c>=<v>... where <c>=<v> | --all-rows",
" delete from <T> where <c>=<v> | --all-rows",
" show table <T>",
" show data <T>",
"Types: text, int, real, decimal, bool, date, datetime, blob, serial, shortid",
] {
self.note_system(line);
}
}
fn handle_mode_command(&mut self, raw: &str) { fn handle_mode_command(&mut self, raw: &str) {
let arg = raw.strip_prefix("mode").unwrap_or(raw).trim(); let arg = raw.strip_prefix("mode").unwrap_or(raw).trim();
match arg { match arg {
+54
View File
@@ -20,8 +20,45 @@ pub struct Args {
/// this path (L1, ADR-0015 §1). Mutually exclusive with /// this path (L1, ADR-0015 §1). Mutually exclusive with
/// `--resume` once that lands. /// `--resume` once that lands.
pub project_path: Option<PathBuf>, pub project_path: Option<PathBuf>,
/// `--help` / `-h`: print usage to stdout and exit. The
/// runtime checks this flag before doing any other work.
pub help: bool,
} }
/// Usage banner printed by `--help`. Kept as one block so the
/// formatting is reviewable on its own.
pub const HELP_TEXT: &str = "\
rdbms-playground — a TUI playground for relational database concepts
Usage:
rdbms-playground [options] [<project-path>]
Arguments:
<project-path> Path to an existing project directory.
Without this, a fresh auto-named temp
project is created in the data dir.
Options:
-h, --help Print this help and exit.
--theme <light|dark> Override theme (default: auto-detect).
--data-dir <PATH> Use PATH as the data root instead of
the OS-standard location for this run.
--log-file <PATH> Write tracing output to PATH.
App-level commands (typed inside the app, available in both modes):
quit / q Exit cleanly.
mode simple|advanced Switch input mode.
help Show this list of commands in-app.
save Save the current temp project under a
chosen name (or `save as` to copy a
named project to a new location).
save as Always prompt for a target name/path.
new Close current, create a fresh temp.
load Open the project picker.
rebuild Rebuild playground.db from project.yaml
+ data/, with confirmation.
";
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum ArgsError { pub enum ArgsError {
#[error("missing value for --{0}")] #[error("missing value for --{0}")]
@@ -54,9 +91,13 @@ impl Args {
let mut log_path = env::var_os("RDBMS_PLAYGROUND_LOG_FILE").map(PathBuf::from); let mut log_path = env::var_os("RDBMS_PLAYGROUND_LOG_FILE").map(PathBuf::from);
let mut data_dir: Option<PathBuf> = None; let mut data_dir: Option<PathBuf> = None;
let mut project_path: Option<PathBuf> = None; let mut project_path: Option<PathBuf> = None;
let mut help = false;
let mut iter = iter.into_iter().map(Into::into); let mut iter = iter.into_iter().map(Into::into);
while let Some(arg) = iter.next() { while let Some(arg) = iter.next() {
match arg.as_str() { match arg.as_str() {
"--help" | "-h" => {
help = true;
}
"--theme" => { "--theme" => {
let value = iter.next().ok_or(ArgsError::MissingValue("theme"))?; let value = iter.next().ok_or(ArgsError::MissingValue("theme"))?;
theme = match value.as_str() { theme = match value.as_str() {
@@ -98,6 +139,7 @@ impl Args {
log_path, log_path,
data_dir, data_dir,
project_path, project_path,
help,
}) })
} }
} }
@@ -203,6 +245,18 @@ mod tests {
assert!(matches!(err, ArgsError::MultiplePaths { .. }), "got: {err:?}"); assert!(matches!(err, ArgsError::MultiplePaths { .. }), "got: {err:?}");
} }
#[test]
fn help_flag_long_form_sets_help() {
let args = Args::parse(["--help"]).unwrap();
assert!(args.help);
}
#[test]
fn help_flag_short_form_sets_help() {
let args = Args::parse(["-h"]).unwrap();
assert!(args.help);
}
#[test] #[test]
fn unknown_double_dash_flag_errors_even_with_positional() { fn unknown_double_dash_flag_errors_even_with_positional() {
// Make sure the path-vs-flag distinction is robust: // Make sure the path-vs-flag distinction is robust:
+7 -1
View File
@@ -1,6 +1,6 @@
use std::process::ExitCode; use std::process::ExitCode;
use rdbms_playground::cli::Args; use rdbms_playground::cli::{Args, HELP_TEXT};
use rdbms_playground::{logging, runtime}; use rdbms_playground::{logging, runtime};
fn main() -> ExitCode { fn main() -> ExitCode {
@@ -8,10 +8,16 @@ fn main() -> ExitCode {
Ok(args) => args, Ok(args) => args,
Err(e) => { Err(e) => {
eprintln!("rdbms-playground: {e}"); eprintln!("rdbms-playground: {e}");
eprintln!("\n{HELP_TEXT}");
return ExitCode::from(2); return ExitCode::from(2);
} }
}; };
if args.help {
print!("{HELP_TEXT}");
return ExitCode::SUCCESS;
}
if let Err(e) = logging::init(args.log_path.as_deref()) { if let Err(e) = logging::init(args.log_path.as_deref()) {
eprintln!("rdbms-playground: failed to initialise logging: {e:#}"); eprintln!("rdbms-playground: failed to initialise logging: {e:#}");
return ExitCode::FAILURE; return ExitCode::FAILURE;
+31
View File
@@ -346,6 +346,37 @@ impl Project {
self.kind self.kind
} }
/// Is this an auto-named temp project that the user has
/// not modified?
///
/// Used to clean up the inevitable accumulation of
/// auto-named temp directories left behind when the user
/// launches the app, immediately loads another project
/// (or quits without doing anything), and never returns
/// to the temp.
///
/// "Unmodified" is defined as: kind is Temp AND
/// `project.yaml` lists no tables and no relationships.
/// The user-visible schema is what counts — show queries
/// only append to history.log and don't trip this check.
/// Errors reading or parsing the YAML default to "not
/// unmodified" (false), so a corrupted project is never
/// auto-deleted.
#[must_use]
pub fn is_unmodified_temp(&self) -> bool {
if !matches!(self.kind, ProjectKind::Temp) {
return false;
}
let yaml_path = self.path.join(PROJECT_YAML);
let Ok(body) = fs::read_to_string(&yaml_path) else {
return false;
};
let Ok(snapshot) = crate::persistence::parse_schema(&body) else {
return false;
};
snapshot.tables.is_empty() && snapshot.relationships.is_empty()
}
/// Path to the SQLite database for this project. Always /// Path to the SQLite database for this project. Always
/// `<project>/playground.db`. /// `<project>/playground.db`.
#[must_use] #[must_use]
+82 -9
View File
@@ -64,15 +64,25 @@ pub async fn run(args: Args) -> Result<()> {
let db_existed = db_path.exists(); let db_existed = db_path.exists();
let database = Database::open_with_persistence(db_path.as_path(), persistence) let database = Database::open_with_persistence(db_path.as_path(), persistence)
.context("open database")?; .context("open database")?;
if !db_existed let mut initial_events: Vec<AppEvent> = Vec::new();
&& let Err(e) = database if !db_existed {
.rebuild_from_text(project_path.clone(), None) match database.rebuild_from_text(project_path.clone(), None).await {
.await Ok(()) => {
{ // Surface the silent rebuild as a system note
// The terminal is still in cooked mode here (we haven't // so the user sees that the .db was
// entered the alternate screen yet), so writing to // reconstructed rather than wondering whether
// stderr lands directly in the user's shell. Drop the // anything happened.
// project to release the lock first. 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); drop(project);
if matches!( if matches!(
e, e,
@@ -83,6 +93,8 @@ pub async fn run(args: Args) -> Result<()> {
} }
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")?; let mut terminal = setup_terminal().context("setup terminal")?;
let result = run_loop( let result = run_loop(
@@ -95,6 +107,7 @@ pub async fn run(args: Args) -> Result<()> {
}, },
display_name, display_name,
project_is_temp, project_is_temp,
initial_events,
) )
.await; .await;
if let Err(e) = teardown_terminal(&mut terminal) { if let Err(e) = teardown_terminal(&mut terminal) {
@@ -149,6 +162,7 @@ async fn run_loop(
mut session: Session, mut session: Session,
project_display_name: String, project_display_name: String,
project_is_temp: bool, project_is_temp: bool,
initial_events: Vec<AppEvent>,
) -> Result<Option<String>> { ) -> Result<Option<String>> {
let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY); let (event_tx, mut event_rx) = mpsc::channel::<AppEvent>(EVENT_CHANNEL_CAPACITY);
let reader_handle = spawn_event_reader(event_tx.clone()); 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_name = Some(project_display_name);
app.project_is_temp = project_is_temp; 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 // Seed the table list with whatever the database currently
// shows. For a fresh in-memory DB this is empty, but doing // shows. For a fresh in-memory DB this is empty, but doing
// it explicitly means file-backed databases (track 2) will // 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; 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"); info!("event loop exited");
Ok(app.fatal_message.clone()) Ok(app.fatal_message.clone())
} }
@@ -343,6 +390,15 @@ async fn perform_switch(
copy_project(&src, dst).map_err(|e| e.to_string())?; 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 // Drop current project + database BEFORE opening the new
// ones, releasing the old lock and stopping the old // ones, releasing the old lock and stopping the old
// worker. Required for the "load my own current project" // worker. Required for the "load my own current project"
@@ -351,6 +407,23 @@ async fn perform_switch(
let _ = session.database.take(); let _ = session.database.take();
let _ = session.project.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. // Open the destination project.
let new_project = match &req { let new_project = match &req {
SwitchRequest::Load { .. } | SwitchRequest::SaveAs { .. } => { SwitchRequest::Load { .. } | SwitchRequest::SaveAs { .. } => {
+79
View File
@@ -17,6 +17,9 @@ use rdbms_playground::app::{
PathEntryPurpose, PathEntryPurpose,
}; };
use rdbms_playground::event::AppEvent; use rdbms_playground::event::AppEvent;
use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, Type};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{self, Project, ProjectKind, copy_project}; use rdbms_playground::project::{self, Project, ProjectKind, copy_project};
const fn key(code: KeyCode) -> AppEvent { const fn key(code: KeyCode) -> AppEvent {
@@ -42,6 +45,26 @@ fn tempdir() -> tempfile::TempDir {
tempfile::tempdir().expect("create tempdir") tempfile::tempdir().expect("create tempdir")
} }
#[test]
fn help_command_lists_supported_commands() {
let mut app = App::new();
type_str(&mut app, "help");
let actions = submit(&mut app);
assert!(actions.is_empty());
let body = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
for keyword in ["quit", "rebuild", "save", "load", "new", "create table"] {
assert!(
body.contains(keyword),
"help output missing `{keyword}`:\n{body}",
);
}
}
#[test] #[test]
fn save_on_temp_opens_path_entry_modal() { fn save_on_temp_opens_path_entry_modal() {
let mut app = App::new(); let mut app = App::new();
@@ -308,6 +331,62 @@ fn project_kind_recovered_from_dirname_on_open() {
assert_eq!(opened_named.display_name(), "My Project"); assert_eq!(opened_named.display_name(), "My Project");
} }
#[test]
fn fresh_temp_is_unmodified() {
let data = tempdir();
let project = project::open_or_create(None, Some(data.path())).unwrap();
assert!(project.is_unmodified_temp());
}
#[test]
fn temp_with_a_table_is_no_longer_unmodified() {
let data = tempdir();
let project = project::open_or_create(None, Some(data.path())).unwrap();
let path = project.path().to_path_buf();
let db = Database::open_with_persistence(
project.db_path(),
Persistence::new(path.clone()),
)
.unwrap();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async {
db.create_table(
"T".to_string(),
vec![ColumnSpec { name: "id".to_string(), ty: Type::Serial }],
vec!["id".to_string()],
Some("create".to_string()),
)
.await
.unwrap();
});
drop(db);
drop(project);
let reopened = Project::open(&path).unwrap();
assert!(
!reopened.is_unmodified_temp(),
"a temp with a table should not be considered unmodified",
);
}
#[test]
fn named_project_is_never_unmodified_temp() {
let data = tempdir();
let temp = project::open_or_create(None, Some(data.path())).unwrap();
let temp_path = temp.path().to_path_buf();
drop(temp);
let named = data.path().join("MyOrders");
copy_project(&temp_path, &named).unwrap();
let opened = Project::open(&named).unwrap();
// Even though the schema is empty, kind is Named.
assert_eq!(opened.kind(), ProjectKind::Named);
assert!(!opened.is_unmodified_temp());
}
#[test] #[test]
fn list_projects_sorts_by_mtime() { fn list_projects_sorts_by_mtime() {
let data = tempdir(); let data = tempdir();