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:
+41
@@ -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 <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) {
|
||||
let arg = raw.strip_prefix("mode").unwrap_or(raw).trim();
|
||||
match arg {
|
||||
|
||||
+54
@@ -20,8 +20,45 @@ pub struct Args {
|
||||
/// this path (L1, ADR-0015 §1). Mutually exclusive with
|
||||
/// `--resume` once that lands.
|
||||
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)]
|
||||
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<PathBuf> = None;
|
||||
let mut project_path: Option<PathBuf> = 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:
|
||||
|
||||
+7
-1
@@ -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;
|
||||
|
||||
@@ -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
|
||||
/// `<project>/playground.db`.
|
||||
#[must_use]
|
||||
|
||||
+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