From b7addd6161abfdc1c56523fe0541c4db32bb16cc Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 8 May 2026 06:43:49 +0000 Subject: [PATCH] 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. --- src/app.rs | 41 +++++++++ src/cli.rs | 54 ++++++++++++ src/main.rs | 8 +- src/project/mod.rs | 31 +++++++ src/runtime.rs | 107 ++++++++++++++++++++---- tests/iteration4b_lifecycle_commands.rs | 79 +++++++++++++++++ 6 files changed, 302 insertions(+), 18 deletions(-) diff --git a/src/app.rs b/src/app.rs index 3501068..cc83b54 100644 --- a/src/app.rs +++ b/src/app.rs @@ -598,6 +598,10 @@ impl App { // 5 (export, import). match effective_input.as_str() { "quit" | "q" => return vec![Action::Quit], + "help" => { + self.note_help(); + return Vec::new(); + } "rebuild" => return vec![Action::PrepareRebuild], "save" => { 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 with pk [:...]", + " drop table ", + " add column [to table] : ()", + " add 1:n relationship [as ] from

. to .", + " [on delete ] [on update ] [--create-fk]", + " drop relationship ", + " insert into [(cols)] [values] (vals)", + " update set =... where = | --all-rows", + " delete from where = | --all-rows", + " show table ", + " show data ", + "Types: text, int, real, decimal, bool, date, datetime, blob, serial, shortid", + ] { + self.note_system(line); + } + } + fn handle_mode_command(&mut self, raw: &str) { let arg = raw.strip_prefix("mode").unwrap_or(raw).trim(); match arg { diff --git a/src/cli.rs b/src/cli.rs index a935c12..0e668c2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -20,8 +20,45 @@ pub struct Args { /// this path (L1, ADR-0015 §1). Mutually exclusive with /// `--resume` once that lands. pub project_path: Option, + /// `--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] [] + +Arguments: + 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 Override theme (default: auto-detect). + --data-dir Use PATH as the data root instead of + the OS-standard location for this run. + --log-file 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)] pub enum ArgsError { #[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 data_dir: Option = None; let mut project_path: Option = None; + let mut help = false; let mut iter = iter.into_iter().map(Into::into); while let Some(arg) = iter.next() { match arg.as_str() { + "--help" | "-h" => { + help = true; + } "--theme" => { let value = iter.next().ok_or(ArgsError::MissingValue("theme"))?; theme = match value.as_str() { @@ -98,6 +139,7 @@ impl Args { log_path, data_dir, project_path, + help, }) } } @@ -203,6 +245,18 @@ mod tests { 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] fn unknown_double_dash_flag_errors_even_with_positional() { // Make sure the path-vs-flag distinction is robust: diff --git a/src/main.rs b/src/main.rs index b71ea8b..5e43087 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use std::process::ExitCode; -use rdbms_playground::cli::Args; +use rdbms_playground::cli::{Args, HELP_TEXT}; use rdbms_playground::{logging, runtime}; fn main() -> ExitCode { @@ -8,10 +8,16 @@ fn main() -> ExitCode { Ok(args) => args, Err(e) => { eprintln!("rdbms-playground: {e}"); + eprintln!("\n{HELP_TEXT}"); return ExitCode::from(2); } }; + if args.help { + print!("{HELP_TEXT}"); + return ExitCode::SUCCESS; + } + if let Err(e) = logging::init(args.log_path.as_deref()) { eprintln!("rdbms-playground: failed to initialise logging: {e:#}"); return ExitCode::FAILURE; diff --git a/src/project/mod.rs b/src/project/mod.rs index 5515d6d..ba6adb8 100644 --- a/src/project/mod.rs +++ b/src/project/mod.rs @@ -346,6 +346,37 @@ impl Project { 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 /// `/playground.db`. #[must_use] diff --git a/src/runtime.rs b/src/runtime.rs index ee0cdc4..d613b7a 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -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 = 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, ) -> Result> { let (event_tx, mut event_rx) = mpsc::channel::(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 = 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 = + 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 { .. } => { diff --git a/tests/iteration4b_lifecycle_commands.rs b/tests/iteration4b_lifecycle_commands.rs index 63ce345..d330828 100644 --- a/tests/iteration4b_lifecycle_commands.rs +++ b/tests/iteration4b_lifecycle_commands.rs @@ -17,6 +17,9 @@ use rdbms_playground::app::{ PathEntryPurpose, }; 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}; const fn key(code: KeyCode) -> AppEvent { @@ -42,6 +45,26 @@ fn tempdir() -> tempfile::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::>() + .join("\n"); + for keyword in ["quit", "rebuild", "save", "load", "new", "create table"] { + assert!( + body.contains(keyword), + "help output missing `{keyword}`:\n{body}", + ); + } +} + #[test] fn save_on_temp_opens_path_entry_modal() { 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"); } +#[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] fn list_projects_sorts_by_mtime() { let data = tempdir();