ADR-0019 §9 sweep (1/2): replay/client_side/ok/mode/messages/project/parse

First half of the catalog migration sweep. Six categories of
user-visible literals moved from inline `format!` calls to the
i18n catalog via `t!()`:

- **replay.*** — `[ok] replay … N command(s) run`,
  `replay … failed at line N: …`, the `> command` echo, and
  the inner `could not open` / `parse error` / `nested replay`
  wordings the runtime constructs inside `ReplayFailed.error`.
- **client_side.*** — the four [client-side] pedagogical notes
  from ADR-0017 §6 / ADR-0018 §9 (transformed,
  transformed_lossy, auto_fill_transition,
  auto_fill_add_serial, auto_fill_add_shortid). The
  `format_auto_fill_add_note` helper in db.rs now routes via
  the catalog too.
- **ok.*** — the `[ok] {verb} {subject}` summary header
  (consolidated through a new `App::note_ok_summary` helper)
  plus the per-operation row-count footers
  (`{count} row(s) inserted/updated/deleted`).
- **mode.*** — `mode: simple/advanced` set/show banners +
  `usage: mode …` + `unknown mode '{value}' …` errors.
- **messages.*** — `messages: short/verbose` set/show + the
  `unknown messages mode` error.
- **project.*** — `[ok] rebuild — {summary}`, `[ok] now
  editing: {display_name}`, `[ok] export — wrote {path}`, plus
  matching failure variants and the `usage: export/import`
  + `import: empty target after as` argument-parsing errors.
- **parse.*** — the `parse error: {detail}` wrapper around
  chumsky's structural output, the `{padding}^` caret pointer,
  and the `empty input` fallback for `ParseError::Empty`.

Catalog total: 99 lines of YAML across the new categories,
44 new entries declared in `keys.rs::KEYS_AND_PLACEHOLDERS`.
The validator (`keys_validate_against_catalog`) walks the
expanded list and confirms placeholder coverage / no format
specifiers / no engine vocabulary across every entry.

Anchor phrases (ADR-0019 §10) preserved verbatim; existing
substring assertions in the test suite hold.

## Tally

610 tests passing (no change in count — pure refactor).
Clippy clean with nursery lints. Release builds.

## Still ahead in the sweep

- Sweep 7: HELP_TEXT (CLI banner) + in-app `note_help` —
  large multi-line blocks.
- Sweep 8: modal labels (load picker, rebuild confirm,
  save-as path entry) + any remaining strays. Final pass.

Both shipping in a follow-up commit so this checkpoint
stays reviewable.
This commit is contained in:
claude@clouddev1
2026-05-09 22:20:34 +00:00
parent 431645ae60
commit aff528aa3f
5 changed files with 240 additions and 86 deletions
+84 -73
View File
@@ -359,12 +359,12 @@ impl App {
}
AppEvent::RebuildSucceeded { summary } => {
self.modal = None;
self.note_system(format!("[ok] rebuild — {summary}"));
self.note_system(crate::t!("project.rebuild_ok", summary = summary));
Vec::new()
}
AppEvent::RebuildFailed { error } => {
self.modal = None;
self.note_error(format!("rebuild failed: {error}"));
self.note_error(crate::t!("project.rebuild_failed", error = error));
Vec::new()
}
AppEvent::LoadPickerReady { entries } => {
@@ -394,7 +394,10 @@ impl App {
is_temp,
history_entries,
} => {
self.note_system(format!("[ok] now editing: {display_name}"));
self.note_system(crate::t!(
"project.switched_ok",
display_name = display_name
));
self.project_name = Some(display_name);
self.project_is_temp = is_temp;
self.tables.clear();
@@ -403,20 +406,25 @@ impl App {
Vec::new()
}
AppEvent::ProjectSwitchFailed { error } => {
self.note_error(format!("project switch failed: {error}"));
self.note_error(crate::t!("project.switch_failed", error = error));
Vec::new()
}
AppEvent::ExportSucceeded { path } => {
self.note_system(format!("[ok] export — wrote {}", path.display()));
self.note_system(crate::t!(
"project.export_ok",
path = path.display()
));
Vec::new()
}
AppEvent::ExportFailed { error } => {
self.note_error(format!("export failed: {error}"));
self.note_error(crate::t!("project.export_failed", error = error));
Vec::new()
}
AppEvent::ReplayCompleted { path, count } => {
self.note_system(format!(
"[ok] replay {path}{count} command(s) run"
self.note_system(crate::t!(
"replay.completed",
path = path,
count = count
));
Vec::new()
}
@@ -433,13 +441,23 @@ impl App {
// it, mirroring how the interactive `running: …`
// path renders source-line context above an error.
if line_number == 0 {
self.note_error(format!("replay {path} failed: {error}"));
self.note_error(crate::t!(
"replay.failed_open",
path = path,
error = error
));
} else {
self.note_error(format!(
"replay {path} failed at line {line_number}: {error}"
self.note_error(crate::t!(
"replay.failed_at_line",
path = path,
line_number = line_number,
error = error
));
if !command.is_empty() {
self.note_error(format!(" > {command}"));
self.note_error(crate::t!(
"replay.command_echo",
command = command
));
}
}
Vec::new()
@@ -705,7 +723,7 @@ impl App {
other if other.starts_with("export ") => {
let target = other["export ".len()..].trim();
if target.is_empty() {
self.note_error("usage: export [<path>]");
self.note_error(crate::t!("project.export_usage"));
return Vec::new();
}
return vec![Action::Export {
@@ -800,17 +818,33 @@ impl App {
let prefix = "running: ";
let trimmed_offset = leading_trim_offset(input);
let pad = prefix.chars().count() + trimmed_offset + position;
self.note_error(format!("{}^", " ".repeat(pad)));
self.note_error(crate::t!(
"parse.caret",
padding = " ".repeat(pad)
));
}
self.note_error(format!("parse error: {}", parse_error_message(&err)));
self.note_error(crate::t!(
"parse.error",
detail = parse_error_message(&err)
));
Vec::new()
}
}
}
/// Emit the standard `[ok] <verb> <subject>` header used by
/// every successful DSL command. Routes through the i18n
/// catalog (ADR-0019 §9 sweep).
fn note_ok_summary(&mut self, command: &Command) {
self.note_system(crate::t!(
"ok.summary",
verb = command.verb(),
subject = command.display_subject()
));
}
fn handle_dsl_success(&mut self, command: &Command, description: Option<TableDescription>) {
let summary = format!("[ok] {} {}", command.verb(), command.display_subject());
self.note_system(summary);
self.note_ok_summary(command);
if let Some(desc) = description.as_ref() {
for line in crate::output_render::render_structure(desc) {
self.note_system(line);
@@ -820,32 +854,23 @@ impl App {
}
fn handle_dsl_query_success(&mut self, command: &Command, data: &DataResult) {
let summary = format!("[ok] {} {}", command.verb(), command.display_subject());
self.note_system(summary);
self.note_ok_summary(command);
for line in crate::output_render::render_data_table(data) {
self.note_system(line);
}
}
fn handle_dsl_insert_success(&mut self, command: &Command, result: &InsertResult) {
self.note_system(format!(
"[ok] {} {}",
command.verb(),
command.display_subject()
));
self.note_system(format!(" {} row(s) inserted", result.rows_affected));
self.note_ok_summary(command);
self.note_system(crate::t!("ok.rows_inserted", count = result.rows_affected));
for line in crate::output_render::render_data_table(&result.data) {
self.note_system(line);
}
}
fn handle_dsl_update_success(&mut self, command: &Command, result: &UpdateResult) {
self.note_system(format!(
"[ok] {} {}",
command.verb(),
command.display_subject()
));
self.note_system(format!(" {} row(s) updated", result.rows_affected));
self.note_ok_summary(command);
self.note_system(crate::t!("ok.rows_updated", count = result.rows_affected));
for line in crate::output_render::render_data_table(&result.data) {
self.note_system(line);
}
@@ -856,8 +881,7 @@ impl App {
command: &Command,
result: AddColumnResult,
) {
let summary = format!("[ok] {} {}", command.verb(), command.display_subject());
self.note_system(summary);
self.note_ok_summary(command);
// ADR-0018 §9: emit auto-fill note(s) before the
// structure render, so the pedagogical "the tool did
// this for you" line is in the user's eye-line next to
@@ -876,8 +900,7 @@ impl App {
command: &Command,
result: ChangeColumnTypeResult,
) {
let summary = format!("[ok] {} {}", command.verb(), command.display_subject());
self.note_system(summary);
self.note_ok_summary(command);
if let Some(note) = result.client_side {
// ADR-0017 §6 + ADR-0018 §9: pedagogical hook
// telling the learner "the tool did this for you;
@@ -888,19 +911,15 @@ impl App {
// emitted in order (ADR-0018 §9 explicit rule).
if note.transformed > 0 {
let line = if note.lossy > 0 {
format!(
"[client-side] {n} row(s) transformed; {l} of those discarded \
information (lossy). In raw SQL this would need an explicit \
`CAST` or application-level code.",
n = note.transformed,
l = note.lossy
crate::t!(
"client_side.transformed_lossy",
count = note.transformed,
lossy = note.lossy
)
} else {
format!(
"[client-side] {n} row(s) were transformed before being stored. \
In raw SQL this would need an explicit `CAST` or \
application-level code.",
n = note.transformed
crate::t!(
"client_side.transformed",
count = note.transformed
)
};
self.note_system(line);
@@ -911,10 +930,10 @@ impl App {
Some(crate::db::AutoFillKind::ShortId) => "shortid",
None => "auto-generated",
};
self.note_system(format!(
"[client-side] {m} null cell(s) given auto-generated {kind} values. \
In raw SQL this would need an explicit UPDATE to populate.",
m = note.auto_filled,
self.note_system(crate::t!(
"client_side.auto_fill_transition",
count = note.auto_filled,
kind = kind
));
}
}
@@ -925,12 +944,8 @@ impl App {
}
fn handle_dsl_delete_success(&mut self, command: &Command, result: &DeleteResult) {
self.note_system(format!(
"[ok] {} {}",
command.verb(),
command.display_subject()
));
self.note_system(format!(" {} row(s) deleted", result.rows_affected));
self.note_ok_summary(command);
self.note_system(crate::t!("ok.rows_deleted", count = result.rows_affected));
for effect in &result.cascade {
self.note_system(render_cascade_effect(effect));
}
@@ -1065,7 +1080,7 @@ impl App {
fn handle_import_command(&mut self, rest: &str) -> Vec<Action> {
let rest = rest.trim();
if rest.is_empty() {
self.note_error("usage: import <zip-path> [as <target>]");
self.note_error(crate::t!("project.import_usage"));
return Vec::new();
}
// `submit()` trims trailing whitespace from the raw
@@ -1074,7 +1089,7 @@ impl App {
// than silently treating "as" as part of the zip
// path.
if rest == "as" || rest.ends_with(" as") {
self.note_error("import: empty target after `as`");
self.note_error(crate::t!("project.import_empty_target"));
return Vec::new();
}
let (zip_path, as_target) = match rest.split_once(" as ") {
@@ -1082,13 +1097,13 @@ impl App {
None => (rest, None),
};
if zip_path.is_empty() {
self.note_error("usage: import <zip-path> [as <target>]");
self.note_error(crate::t!("project.import_usage"));
return Vec::new();
}
if let Some(t) = as_target.as_deref()
&& t.is_empty()
{
self.note_error("import: empty target after `as`");
self.note_error(crate::t!("project.import_empty_target"));
return Vec::new();
}
vec![Action::Import {
@@ -1419,19 +1434,17 @@ impl App {
crate::friendly::Verbosity::Short => "short",
crate::friendly::Verbosity::Verbose => "verbose",
};
self.note_system(format!("messages: {current}"));
self.note_system(crate::t!("messages.show", current = current));
}
"short" => {
self.messages_verbosity = crate::friendly::Verbosity::Short;
self.note_system("messages: short");
self.note_system(crate::t!("messages.set_short"));
}
"verbose" => {
self.messages_verbosity = crate::friendly::Verbosity::Verbose;
self.note_system("messages: verbose");
self.note_system(crate::t!("messages.set_verbose"));
}
other => self.note_error(format!(
"unknown messages mode '{other}' (expected 'short' or 'verbose')"
)),
other => self.note_error(crate::t!("messages.unknown", value = other)),
}
}
@@ -1440,16 +1453,14 @@ impl App {
match arg {
"simple" => {
self.mode = Mode::Simple;
self.note_system("mode: simple");
self.note_system(crate::t!("mode.set_simple"));
}
"advanced" => {
self.mode = Mode::Advanced;
self.note_system("mode: advanced");
self.note_system(crate::t!("mode.set_advanced"));
}
"" => self.note_error("usage: mode simple | mode advanced"),
other => self.note_error(format!(
"unknown mode '{other}' (expected 'simple' or 'advanced')"
)),
"" => self.note_error(crate::t!("mode.usage")),
other => self.note_error(crate::t!("mode.unknown", value = other)),
}
}
@@ -1514,7 +1525,7 @@ impl App {
fn parse_error_message(err: &ParseError) -> String {
match err {
ParseError::Invalid { message, .. } => message.clone(),
ParseError::Empty => "empty input".to_string(),
ParseError::Empty => crate::t!("parse.empty"),
}
}