diff --git a/src/app.rs b/src/app.rs index ada3396..6309a05 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 []"); + 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] ` 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) { - 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 { let rest = rest.trim(); if rest.is_empty() { - self.note_error("usage: import [as ]"); + 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 [as ]"); + 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"), } } diff --git a/src/db.rs b/src/db.rs index f7d9753..e0a4a17 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1810,16 +1810,12 @@ fn generate_shortid_batch( /// on a non-empty table per ADR-0018 §9. fn format_auto_fill_add_note(ty: Type, row_count: usize) -> String { match ty { - Type::Serial => format!( - "[client-side] {row_count} row(s) given auto-generated serial \ - values 1..{row_count}. In raw SQL this would need an explicit \ - UPDATE to populate." - ), - Type::ShortId => format!( - "[client-side] {row_count} row(s) given auto-generated shortid \ - values. In raw SQL this would need an explicit UPDATE to \ - populate." - ), + Type::Serial => { + crate::t!("client_side.auto_fill_add_serial", count = row_count) + } + Type::ShortId => { + crate::t!("client_side.auto_fill_add_shortid", count = row_count) + } _ => unreachable!("called only for serial/shortid"), } } diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index e8eee79..a9000ed 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -118,6 +118,50 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ "error.type_mismatch.update.hint", &["table", "column", "expected_type"], ), + // ---- Parse error rendering ---- + ("parse.caret", &["padding"]), + ("parse.empty", &[]), + ("parse.error", &["detail"]), + // ---- Project lifecycle event notes ---- + ("project.export_failed", &["error"]), + ("project.export_ok", &["path"]), + ("project.export_usage", &[]), + ("project.import_empty_target", &[]), + ("project.import_usage", &[]), + ("project.rebuild_failed", &["error"]), + ("project.rebuild_ok", &["summary"]), + ("project.switch_failed", &["error"]), + ("project.switched_ok", &["display_name"]), + // ---- mode / messages banners ---- + ("messages.set_short", &[]), + ("messages.set_verbose", &[]), + ("messages.show", &["current"]), + ("messages.unknown", &["value"]), + ("mode.set_advanced", &[]), + ("mode.set_simple", &[]), + ("mode.show_advanced", &[]), + ("mode.show_simple", &[]), + ("mode.unknown", &["value"]), + ("mode.usage", &[]), + // ---- DSL command success summaries (ADR-0019 §9 sweep) ---- + ("ok.rows_deleted", &["count"]), + ("ok.rows_inserted", &["count"]), + ("ok.rows_updated", &["count"]), + ("ok.summary", &["verb", "subject"]), + // ---- Client-side success notes (ADR-0017 §6, ADR-0018 §9) ---- + ("client_side.auto_fill_add_serial", &["count"]), + ("client_side.auto_fill_add_shortid", &["count"]), + ("client_side.auto_fill_transition", &["count", "kind"]), + ("client_side.transformed", &["count"]), + ("client_side.transformed_lossy", &["count", "lossy"]), + // ---- Replay command surfaces (ADR-0019 §9 sweep) ---- + ("replay.command_echo", &["command"]), + ("replay.completed", &["path", "count"]), + ("replay.error_could_not_open", &["path", "detail"]), + ("replay.error_nested", &[]), + ("replay.error_parse", &["detail"]), + ("replay.failed_at_line", &["path", "line_number", "error"]), + ("replay.failed_open", &["path", "error"]), // ---- UNIQUE violations (anchor: "already has the value") ---- ( "error.unique.insert.headline", diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 111fbec..7c0dda7 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -151,3 +151,102 @@ error: headline: "INSERT requires at least one column value." empty_update: headline: "UPDATE requires at least one assignment." + +# ---- DSL parse error rendering -------------------------------------- +parse: + # Wrapper around chumsky's structural error message. The + # caret pointer (visualising the failure column) is printed + # on its own preceding line via `parse.caret`. + error: "parse error: {detail}" + # Caret pointer showing where in the input the parser + # failed. `{padding}` is the leading whitespace; the + # template appends `^` so the rendered line places the + # marker under the offending character. + caret: "{padding}^" + # Default for the `ParseError::Empty` variant — surfaces as + # `{detail}` inside the wrapper. + empty: "empty input" + +# ---- Project lifecycle event notes ----------------------------------- +project: + rebuild_ok: "[ok] rebuild — {summary}" + rebuild_failed: "rebuild failed: {error}" + switched_ok: "[ok] now editing: {display_name}" + switch_failed: "project switch failed: {error}" + export_ok: "[ok] export — wrote {path}" + export_failed: "export failed: {error}" + # Usage / argument-parsing errors for app-level commands. + export_usage: "usage: export []" + import_usage: "usage: import [as ]" + import_empty_target: "import: empty target after `as`" + +# ---- mode / messages banners (app-level commands) ------------------- +mode: + set_simple: "mode: simple" + set_advanced: "mode: advanced" + show_simple: "mode: simple" + show_advanced: "mode: advanced" + usage: "usage: mode simple | mode advanced" + unknown: "unknown mode '{value}' (expected 'simple' or 'advanced')" + +messages: + show: "messages: {current}" + set_short: "messages: short" + set_verbose: "messages: verbose" + unknown: "unknown messages mode '{value}' (expected 'short' or 'verbose')" + +# ---- DSL command success summaries (ADR-0019 §9 sweep) -------------- +ok: + # Generic `[ok] ` header used for every + # successful DSL command. Verbs come from `Command::verb()` + # (already English keywords); subjects are the table / + # relationship endpoints derived in `Command::display_subject()`. + summary: "[ok] {verb} {subject}" + # Per-operation row-count footers shown beneath the summary. + rows_inserted: " {count} row(s) inserted" + rows_updated: " {count} row(s) updated" + rows_deleted: " {count} row(s) deleted" + +# ---- Client-side success notes (ADR-0017 §6, ADR-0018 §9) ------------ +client_side: + # Per-cell transformation notice when `change column ...` rewrote + # at least one stored value (storage-class change or non-identity + # mapping). `lossy` variant fires under --force-conversion when + # information was discarded. + transformed: |- + [client-side] {count} row(s) were transformed before being stored. In raw SQL this would need an explicit `CAST` or application-level code. + transformed_lossy: |- + [client-side] {count} row(s) transformed; {lossy} of those discarded information (lossy). In raw SQL this would need an explicit `CAST` or application-level code. + # Auto-fill notice when null cells were populated by the + # serial/shortid auto-generation contract (change column path). + auto_fill_transition: |- + [client-side] {count} null cell(s) given auto-generated {kind} values. In raw SQL this would need an explicit UPDATE to populate. + # Auto-fill notice for `add column T: x (serial)` on a + # non-empty table — values run 1..N. + auto_fill_add_serial: |- + [client-side] {count} row(s) given auto-generated serial values 1..{count}. In raw SQL this would need an explicit UPDATE to populate. + # Auto-fill notice for `add column T: x (shortid)` on a + # non-empty table. + auto_fill_add_shortid: |- + [client-side] {count} row(s) given auto-generated shortid values. In raw SQL this would need an explicit UPDATE to populate. + +# ---- Replay command surfaces (ADR-0019 §9 sweep) --------------------- +replay: + # Success summary printed when `replay ` completes + # without per-line failure. + completed: "[ok] replay {path} — {count} command(s) run" + # File-open failure (line_number == 0 from the runtime). + failed_open: "replay {path} failed: {error}" + # Per-line failure header. The command echo is on a + # follow-up `command_echo` line so the renderer can format + # them as separate output rows. + failed_at_line: "replay {path} failed at line {line_number}: {error}" + # Indented echo of the offending command line, shown after + # `failed_at_line` when the runtime supplied the source. + command_echo: " > {command}" + # Errors the runtime constructs inside ReplayFailed.error + # before forwarding to App. Carried as plain text so they + # compose with `failed_at_line`'s `{error}` placeholder. + error_could_not_open: "could not open `{path}`: {detail}" + error_parse: "parse error: {detail}" + error_nested: "nested `replay` is not allowed inside a replay file" diff --git a/src/runtime.rs b/src/runtime.rs index 2bdcfa3..8bf9f89 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1355,7 +1355,11 @@ pub async fn run_replay( path: path.to_string(), line_number: 0, command: String::new(), - error: format!("could not open `{}`: {e}", resolved.display()), + error: crate::t!( + "replay.error_could_not_open", + path = resolved.display(), + detail = e + ), }); return events; } @@ -1378,7 +1382,7 @@ pub async fn run_replay( path: path.to_string(), line_number, command: trimmed.to_string(), - error: format!("parse error: {e}"), + error: crate::t!("replay.error_parse", detail = e), }); return events; } @@ -1394,7 +1398,7 @@ pub async fn run_replay( path: path.to_string(), line_number, command: trimmed.to_string(), - error: "nested `replay` is not allowed inside a replay file".to_string(), + error: crate::t!("replay.error_nested"), }); return events; }