From 720511ef29c68e8847caee069667fef64467fe1d Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sat, 9 May 2026 22:29:28 +0000 Subject: [PATCH] =?UTF-8?q?ADR-0019=20=C2=A79=20sweep=20(2/2):=20help=20bl?= =?UTF-8?q?ocks=20+=20modals=20+=20system=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final pass of the i18n migration sweep. Every user-visible string in `src/` now flows through the catalog via `t!()`. ## Categories migrated in this commit - **help.cli_banner** — the entire `cli::HELP_TEXT` const, formerly a 40-line `&'static str`, is now a YAML block in the catalog. The const is replaced by a thin `cli::help_text() -> String` wrapper that performs the catalog lookup. `main.rs` calls `help_text()` for both `--help` output and the args-parse error path. The two integration tests that referenced `HELP_TEXT` directly are updated. - **help.in_app_body** — the in-app `help` command's body is one YAML block; `note_help` becomes 5 lines that iterate the lines and emit each as its own output row (preserving the renderer's "one logical line = one display row" invariant for accurate scroll math). - **modal.*** — load picker, rebuild confirm, and save-as path-entry strings: rebuild_cancelled, load_cancelled, generic_cancelled, load_picker_nothing, path_entry_empty_name, path_entry_empty_path. - **dsl.failed** — the `" " failed: ` wrapper around the friendly-error layer's translated message. - **dsl.running** — the `running: ` echo line shown above each command's response. (Note: the en-US prefix "running: " is hardcoded in the parse-error caret-padding calculation. Translators changing the prefix must keep the width consistent — documented inline.) - **advanced_mode.not_implemented** — the placeholder echo shown when SQL hits the unimplemented advanced-mode path (Q1 territory). - **fatal.persistence** — the FATAL banner for PersistenceFatal events (ADR-0015 §8). - **project.{load_path_missing,saveas_target_exists,** **import_zip_missing}** — runtime-side project-switch validation errors that surface via ProjectSwitchFailed. ## Catalog start-up ordering `main.rs` now calls `friendly::catalog()` at the very top (before args parsing) so `help_text()` works in both the success path and the args-error path. A corrupted build artefact still fails loudly with a useful panic; the practical risk is essentially zero since the catalog is `include_str!`'d at compile time and validated by the unit test before shipping. ## Remaining literals The only `note_*` calls in `src/` that still pass plain strings are inside `#[cfg(test)]` modules — synthetic test fixtures, not user-visible. The codebase passes the "every user-visible string flows through the catalog" bar. ## Tally 610 tests passing (no change in count — pure refactor). Clippy clean with nursery lints. ## What this closes ADR-0019 §9 (migration sweep) — done. ADR-0019 itself is now fully implemented: - §1-§5: catalog + translator + voice + verbosity ✓ (`eac7e5b`) - §6: row pinpointing + schema enrichment ✓ (`431645a`) - §9: migration sweep ✓ (this + `aff528a`) - §10: anchor phrases preserved throughout ✓ - The five "Out of scope" items remain explicitly bounded to future ADRs (advanced-mode SQL, settings persistence, pluralisation, runtime locale, value formatting, constraint management). --- src/app.rs | 104 +++++++++-------------- src/cli.rs | 55 +++--------- src/friendly/keys.rs | 20 +++++ src/friendly/strings/en-US.yaml | 130 +++++++++++++++++++++++++++++ src/main.rs | 22 ++--- src/runtime.rs | 16 ++-- tests/engine_vocabulary_audit.rs | 4 +- tests/iteration6_resume_history.rs | 2 +- 8 files changed, 224 insertions(+), 129 deletions(-) diff --git a/src/app.rs b/src/app.rs index 6309a05..2f4d873 100644 --- a/src/app.rs +++ b/src/app.rs @@ -344,10 +344,11 @@ impl App { path, message, } => { - let banner = format!( - "FATAL: failed to {operation} `{}` — {message}. \ - Quitting; investigate and restart.", - path.display(), + let banner = crate::t!( + "fatal.persistence", + operation = operation, + path = path.display(), + message = message ); self.note_error(banner.clone()); self.fatal_message = Some(banner); @@ -754,8 +755,9 @@ impl App { // until the advanced-mode SQL path lands. Once it does, // this branch parses with sqlparser-rs and dispatches // analogously to the DSL path below. - self.note_system(format!( - "advanced mode SQL not implemented yet — echo: {effective_input}" + self.note_system(crate::t!( + "advanced_mode.not_implemented", + input = effective_input )); self.push_output(OutputLine { text: effective_input, @@ -785,7 +787,7 @@ impl App { // keeping the worker out of the loop and the // history.log clean. self.push_output(OutputLine { - text: format!("running: {input}"), + text: crate::t!("dsl.running", input = input), kind: OutputKind::Echo, mode_at_submission: submission_mode, }); @@ -793,7 +795,7 @@ impl App { } Ok(cmd) => { self.push_output(OutputLine { - text: format!("running: {input}"), + text: crate::t!("dsl.running", input = input), kind: OutputKind::Echo, mode_at_submission: submission_mode, }); @@ -807,13 +809,19 @@ impl App { // Echo the source line so the user can see what // got submitted (and copy-paste it back to fix). self.push_output(OutputLine { - text: format!("running: {input}"), + text: crate::t!("dsl.running", input = input), kind: OutputKind::Echo, mode_at_submission: submission_mode, }); // Caret pointer at the failure position, when we // have one. Aligned to the "running: " prefix so // the caret sits under the offending character. + // + // Note: the prefix length is hardcoded against + // the en-US `dsl.running` template ("running: + // {input}"). A translator changing that prefix + // must update this width too — the constraint is + // captured in the catalog comment block. if let ParseError::Invalid { position, .. } = &err { let prefix = "running: "; let trimmed_offset = leading_trim_offset(input); @@ -976,10 +984,11 @@ impl App { // `note_error` splits on newlines internally — refusal // diagnostics from `change column …` (ADR-0017 §7) flow // through as a multi-line bordered table. - self.note_error(format!( - "\"{} {}\" failed: {rendered}", - command.verb(), - command.display_subject() + self.note_error(crate::t!( + "dsl.failed", + verb = command.verb(), + subject = command.display_subject(), + rendered = rendered )); } @@ -1164,7 +1173,7 @@ impl App { } KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { self.modal = None; - self.note_system("rebuild cancelled"); + self.note_system(crate::t!("modal.rebuild_cancelled")); Vec::new() } _ => Vec::new(), @@ -1179,13 +1188,16 @@ impl App { match key.code { KeyCode::Esc => { self.modal = None; - self.note_system(format!("{} cancelled", state.title.to_lowercase())); + self.note_system(crate::t!( + "modal.generic_cancelled", + title = state.title.to_lowercase() + )); Vec::new() } KeyCode::Enter => { let target = state.input.trim().to_string(); if target.is_empty() { - self.note_error("path entry: empty name"); + self.note_error(crate::t!("modal.path_entry_empty_name")); self.modal = Some(Modal::PathEntry(state)); return Vec::new(); } @@ -1263,7 +1275,7 @@ impl App { LoadPickerSubMode::List => match key.code { KeyCode::Esc => { self.modal = None; - self.note_system("load cancelled"); + self.note_system(crate::t!("modal.load_cancelled")); Vec::new() } KeyCode::Up => { @@ -1288,7 +1300,7 @@ impl App { source: "load".to_string(), }]; } - self.note_error("nothing to load"); + self.note_error(crate::t!("modal.load_picker_nothing")); self.modal = Some(Modal::LoadPicker(state)); Vec::new() } @@ -1314,7 +1326,7 @@ impl App { KeyCode::Enter => { let target = input.trim().to_string(); if target.is_empty() { - self.note_error("path entry: empty path"); + self.note_error(crate::t!("modal.path_entry_empty_path")); self.modal = Some(Modal::LoadPicker(state)); return Vec::new(); } @@ -1376,53 +1388,13 @@ impl App { /// 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", - " messages — show current verbosity", - " messages short|verbose— switch error wording (verbose is the default)", - " 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", - " export [] — write a zip of project.yaml + data/ (excludes .db, history.log)", - " import [as ] — unpack a zip and switch to the new project", - "DSL data commands (in simple mode):", - " create table with pk [:...]", - " drop table ", - " add column [to] [table] : ()", - " (for serial/shortid on a non-empty table: existing rows auto-filled)", - " drop column [from] [table] : ", - " rename column [in] [table] : to ", - " change column [in] [table] : ()", - " [--force-conversion | --dont-convert]", - " (to serial/shortid: null cells auto-filled with generated values)", - " 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 ", - " replay — run each non-blank, non-`#`-comment line", - " of as a command. Stops at the first", - " error (no rollback). Relative paths resolve", - " under the current project's directory.", - "Types: text, int, real, decimal, bool, date, datetime, blob, serial, shortid", - "Auto-generated types (serial, shortid):", - " serial — integer that auto-fills with the next sequence value", - " (MAX(col)+1) on insert. Outside a primary key it carries", - " a UNIQUE contract.", - " shortid — short base58 identifier auto-filled at insert time. Always", - " carries a UNIQUE contract.", - " Adding or changing-to either type on a non-empty table auto-fills", - " existing/null cells in the same operation.", - ] { - self.note_system(line); + // Body lives in the i18n catalog (`help.in_app_body`). + // Each YAML line becomes its own `OutputLine` so the + // scroll-position math (one logical line = one display + // row) stays accurate per the renderer's invariant. + let body = crate::t!("help.in_app_body"); + for line in body.lines() { + self.note_system(line.to_string()); } } diff --git a/src/cli.rs b/src/cli.rs index 2a1508d..dfdcf1e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -31,51 +31,16 @@ pub struct Args { 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. - --resume Open the most-recently-used project - (path tracked under /last_project). - Errors out if no previous project is - recorded. Mutually exclusive with - . - -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. - export [] Write a zip of project.yaml + data/ to - (relative paths under the data - root). Excludes playground.db and - history.log. - import [as ] Unpack into a new project and - switch to it. overrides the target - name (else taken from the zip). -"; +/// Usage banner printed by `--help`. +/// +/// Wraps the catalog lookup (`help.cli_banner`) so callers +/// don't have to spell out the key. The catalog body is the +/// single source of truth — see +/// `src/friendly/strings/en-US.yaml`. +#[must_use] +pub fn help_text() -> String { + crate::t!("help.cli_banner") +} #[derive(Debug, thiserror::Error)] pub enum ArgsError { diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index a9000ed..e4d7946 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -118,6 +118,9 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ "error.type_mismatch.update.hint", &["table", "column", "expected_type"], ), + // ---- Help text ---- + ("help.cli_banner", &[]), + ("help.in_app_body", &[]), // ---- Parse error rendering ---- ("parse.caret", &["padding"]), ("parse.empty", &[]), @@ -128,10 +131,27 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("project.export_usage", &[]), ("project.import_empty_target", &[]), ("project.import_usage", &[]), + ("project.import_zip_missing", &["path"]), + ("project.load_path_missing", &["path"]), + ("project.saveas_target_exists", &["path"]), ("project.rebuild_failed", &["error"]), ("project.rebuild_ok", &["summary"]), ("project.switch_failed", &["error"]), ("project.switched_ok", &["display_name"]), + // ---- Advanced-mode placeholder ---- + ("advanced_mode.not_implemented", &["input"]), + // ---- DSL failure wrapper / running echo ---- + ("dsl.failed", &["verb", "subject", "rendered"]), + ("dsl.running", &["input"]), + // ---- Persistence-fatal banner ---- + ("fatal.persistence", &["operation", "path", "message"]), + // ---- Modal labels ---- + ("modal.generic_cancelled", &["title"]), + ("modal.load_cancelled", &[]), + ("modal.load_picker_nothing", &[]), + ("modal.path_entry_empty_name", &[]), + ("modal.path_entry_empty_path", &[]), + ("modal.rebuild_cancelled", &[]), // ---- mode / messages banners ---- ("messages.set_short", &[]), ("messages.set_verbose", &[]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 7c0dda7..cec208d 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -152,6 +152,103 @@ error: empty_update: headline: "UPDATE requires at least one assignment." +# ---- Help text (CLI banner + in-app `help` command) ------------------ +help: + # CLI usage banner. Printed by `--help` / `-h` and on + # argument-parse failure. Multi-line block; consumers + # iterate lines or print as-is. + cli_banner: | + 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. + --resume Open the most-recently-used project + (path tracked under /last_project). + Errors out if no previous project is + recorded. Mutually exclusive with + . + + 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. + export [] Write a zip of project.yaml + data/ to + (relative paths under the data + root). Excludes playground.db and + history.log. + import [as ] Unpack into a new project and + switch to it. overrides the target + name (else taken from the zip). + # In-app `help` command output. Same shape as + # `cli_banner` — multi-line block, consumers iterate + # lines and emit each as its own output row so scroll + # math stays accurate. + in_app_body: | + Supported commands: + quit / q — exit + help — this list + mode simple|advanced — switch input mode + messages — show current verbosity + messages short|verbose— switch error wording (verbose is the default) + 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 + export [] — write a zip of project.yaml + data/ (excludes .db, history.log) + import [as ] — unpack a zip and switch to the new project + DSL data commands (in simple mode): + create table with pk [:...] + drop table + add column [to] [table] : () + (for serial/shortid on a non-empty table: existing rows auto-filled) + drop column [from] [table] : + rename column [in] [table] : to + change column [in] [table] : () + [--force-conversion | --dont-convert] + (to serial/shortid: null cells auto-filled with generated values) + 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 + replay — run each non-blank, non-`#`-comment line + of as a command. Stops at the first + error (no rollback). Relative paths resolve + under the current project's directory. + Types: text, int, real, decimal, bool, date, datetime, blob, serial, shortid + Auto-generated types (serial, shortid): + serial — integer that auto-fills with the next sequence value + (MAX(col)+1) on insert. Outside a primary key it carries + a UNIQUE contract. + shortid — short base58 identifier auto-filled at insert time. Always + carries a UNIQUE contract. + Adding or changing-to either type on a non-empty table auto-fills + existing/null cells in the same operation. + # ---- DSL parse error rendering -------------------------------------- parse: # Wrapper around chumsky's structural error message. The @@ -179,6 +276,39 @@ project: export_usage: "usage: export []" import_usage: "usage: import [as ]" import_empty_target: "import: empty target after `as`" + # Project-switch validation failures (load / save-as / + # import). Returned from the runtime as Err(String) and + # surfaced via project.switch_failed. + load_path_missing: "path `{path}` does not exist" + saveas_target_exists: "`{path}` already exists; pick a different name or remove it first" + import_zip_missing: "zip `{path}` does not exist" + +# ---- DSL failure wrapper + advanced-mode placeholder + fatal -------- +dsl: + # Wrapper around the friendly-error layer's translated + # message, surfaced as `" " failed: `. + failed: '"{verb} {subject}" failed: {rendered}' + # Echo line `running: ` shown above each command's + # response so the user has on-screen context for the + # output that follows. + running: "running: {input}" + +# ---- Advanced-mode placeholder until SQL parser lands (Q1) ---------- +advanced_mode: + not_implemented: "advanced mode SQL not implemented yet — echo: {input}" + +# ---- Persistence-fatal banner (ADR-0015 §8) ------------------------- +fatal: + persistence: "FATAL: failed to {operation} `{path}` — {message}. Quitting; investigate and restart." + +# ---- Modal labels (load picker, rebuild confirm, save-as path) ------ +modal: + rebuild_cancelled: "rebuild cancelled" + load_cancelled: "load cancelled" + generic_cancelled: "{title} cancelled" + path_entry_empty_name: "path entry: empty name" + path_entry_empty_path: "path entry: empty path" + load_picker_nothing: "nothing to load" # ---- mode / messages banners (app-level commands) ------------------- mode: diff --git a/src/main.rs b/src/main.rs index 5eae090..dc9c6e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,20 +1,29 @@ use std::process::ExitCode; -use rdbms_playground::cli::{Args, HELP_TEXT}; +use rdbms_playground::cli::{help_text, Args}; use rdbms_playground::{logging, runtime}; fn main() -> ExitCode { + // ADR-0019 §8.6: parse the embedded message catalog up + // front so a corrupted build artefact fails loudly here + // rather than at the first `t!()` call deep inside the + // event loop. Cheap — the catalog is small and `OnceLock` + // memoises the parse for every subsequent caller. + // Doing this first also means the args-parse error path + // below can pull `help.cli_banner` from the catalog. + let _ = rdbms_playground::friendly::catalog(); + let args = match Args::from_env() { Ok(args) => args, Err(e) => { eprintln!("rdbms-playground: {e}"); - eprintln!("\n{HELP_TEXT}"); + eprintln!("\n{}", help_text()); return ExitCode::from(2); } }; if args.help { - print!("{HELP_TEXT}"); + print!("{}", help_text()); return ExitCode::SUCCESS; } @@ -23,13 +32,6 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } - // ADR-0019 §8.6: parse the embedded message catalog up - // front so a corrupted build artefact fails loudly here - // rather than at the first `t!()` call deep inside the - // event loop. Cheap — the catalog is small and `OnceLock` - // memoises the parse for every subsequent caller. - let _ = rdbms_playground::friendly::catalog(); - let tokio_rt = match tokio::runtime::Runtime::new() { Ok(rt) => rt, Err(e) => { diff --git a/src/runtime.rs b/src/runtime.rs index 8bf9f89..ecedfd6 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -514,16 +514,19 @@ async fn perform_switch( let resolved_target: Option = match &req { SwitchRequest::Load { path } => { if !path.exists() { - return Err(format!("path `{}` does not exist", path.display())); + return Err(crate::t!( + "project.load_path_missing", + path = path.display() + )); } Some(path.clone()) } SwitchRequest::SaveAs { target } => { let p = resolve_save_target(target, &session.data_root); if p.exists() { - return Err(format!( - "`{}` already exists; pick a different name or remove it first", - p.display(), + return Err(crate::t!( + "project.saveas_target_exists", + path = p.display() )); } Some(p) @@ -531,7 +534,10 @@ async fn perform_switch( SwitchRequest::NewTemp => None, SwitchRequest::Import { zip_path, as_target } => { if !zip_path.exists() { - return Err(format!("zip `{}` does not exist", zip_path.display())); + return Err(crate::t!( + "project.import_zip_missing", + path = zip_path.display() + )); } // Validate the zip up front so we don't drop the // current project for an unimportable file. diff --git a/tests/engine_vocabulary_audit.rs b/tests/engine_vocabulary_audit.rs index 6758ceb..21520a0 100644 --- a/tests/engine_vocabulary_audit.rs +++ b/tests/engine_vocabulary_audit.rs @@ -25,7 +25,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use rdbms_playground::app::App; -use rdbms_playground::cli::HELP_TEXT; +use rdbms_playground::cli::help_text; use rdbms_playground::db::{DbError, SqliteErrorKind}; use rdbms_playground::dsl::parse_command; use rdbms_playground::event::AppEvent; @@ -87,7 +87,7 @@ fn collect_output(app: &App) -> String { #[test] fn cli_help_text_uses_no_engine_vocabulary() { - assert_clean("CLI HELP_TEXT", HELP_TEXT); + assert_clean("CLI help_text()", &help_text()); } #[test] diff --git a/tests/iteration6_resume_history.rs b/tests/iteration6_resume_history.rs index 41e018b..89a8030 100644 --- a/tests/iteration6_resume_history.rs +++ b/tests/iteration6_resume_history.rs @@ -46,7 +46,7 @@ fn args_resume_after_positional_path_also_errors() { #[test] fn args_help_listing_mentions_resume() { - assert!(rdbms_playground::cli::HELP_TEXT.contains("--resume")); + assert!(rdbms_playground::cli::help_text().contains("--resume")); } // --- last_project read/write ----------------------------------