replay: new replay <path> command (A3, U4)
Implements the U4 replay command per handoff §A3:
replay <path>
Reads <path> and dispatches each non-blank, non-`#`-comment
line through the same DSL pipeline as interactive input.
Aborts at the first per-line failure (parse or runtime),
reporting the line number; previously dispatched commands
stay applied (no rollback) — matches the "I'm replaying my
history" mental model where partial replay is a recoverable
state.
Architecture choices and why:
- **Parsed by the DSL parser** (Command::Replay), not as an
app-level command alongside `import` / `export`. The
handoff's implementation sketch was explicit and the
parsed-AST shape gives us a clean test surface for the
path-lexing rules. A new `path_literal` parser terminal
accepts either a single-quoted string (escape rules
mirror `string_literal` — `''` for a literal quote) or a
bare run of non-whitespace, with explicit refusal of `'`,
`(`, `)`, `;` in bare form. Empty paths fail at parse
time so file-system-layer errors aren't shadowed by
silly inputs.
- **Routed away from the worker thread.** Command::Replay
is intercepted in `App::dispatch_dsl` and emitted as
`Action::Replay` rather than `Action::ExecuteDsl`. Two
reasons: (1) the worker has no filesystem context, and
(2) the replay invocation must NOT land in
`history.log` — otherwise `replay history.log` would
re-trigger itself recursively. Only the individual
sub-commands write to history.log via the normal
per-command persistence path.
- **Inner loop separated from spawn.** `runtime::spawn_replay`
is a thin tokio::spawn wrapper around `runtime::run_replay`,
which is `pub` and returns a Vec<AppEvent>. The inner
function is what tests exercise, sidestepping mpsc plumbing.
- **Relative paths resolve under the project root** so
`replay history.log` works without ceremony from inside
any project. Absolute paths pass through unchanged.
- **Nested `replay` is refused.** Allowing `replay foo` from
inside a replay file invites infinite-loop footguns and
opens design questions (transitive composition, ordering)
we'd rather not answer right now. Refusal is explicit.
New plumbing:
- `Command::Replay { path }` AST variant + verb/target_table.
- `Action::Replay { path }` runtime action.
- `AppEvent::ReplayCompleted { path, count }` and
`AppEvent::ReplayFailed { path, line_number, command, error }`.
- `runtime::run_replay` (public) and `runtime::spawn_replay`.
- App handlers render success as
`[ok] replay <path> — N command(s) run` and failures as
`replay <path> failed at line N: <error>` with a
` > <command>` echo line for line context. Line 0 is the
"file open failed" signal — header reads
`replay <path> failed: <error>` and the echo line is
suppressed.
- In-app `help` lists the new command with a continuation
describing comment/blank handling and the relative-path
rule.
Tests (+20):
- 7 parser tests covering bare/quoted/escaped paths,
case-insensitive keyword, and refusal cases (no path,
empty quoted path).
- 9 integration tests in `tests/replay_command.rs`:
- happy 3-line replay → 3 commands run, state mutated;
- blank lines + `#` comments skipped;
- empty file + only-comments file → count 0;
- missing file → ReplayFailed line_number 0;
- parse failure mid-replay → reports correct line +
leaves earlier commands applied + does NOT run later
lines;
- runtime failure mid-replay (refers to nonexistent
table) → reports correct line;
- nested replay refused;
- history.log contains per-command entries but NOT the
`replay …` invocation itself.
- 4 App-level tests: Action::Replay dispatch (not
ExecuteDsl); ReplayCompleted rendering; ReplayFailed
rendering with and without line-number context.
541 -> 561 passing, clippy clean with nursery lints,
release build successful.
A future ADR on the parser-as-source-of-truth direction
(handoff §"Pending §3") would bring richer error reporting
for replay parse failures (currently uses the same
single-line wording as interactive parse failures, which is
adequate but not great when a script has many lines around
the failing one).
This commit is contained in:
+131
@@ -405,6 +405,36 @@ impl App {
|
||||
self.note_error(format!("export failed: {error}"));
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::ReplayCompleted { path, count } => {
|
||||
self.note_system(format!(
|
||||
"[ok] replay {path} — {count} command(s) run"
|
||||
));
|
||||
Vec::new()
|
||||
}
|
||||
AppEvent::ReplayFailed {
|
||||
path,
|
||||
line_number,
|
||||
command,
|
||||
error,
|
||||
} => {
|
||||
// line_number == 0 is the runtime's signal that
|
||||
// file-open itself failed (no per-line context to
|
||||
// surface). Otherwise we lead with the line-number
|
||||
// header and echo the offending command beneath
|
||||
// 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}"));
|
||||
} else {
|
||||
self.note_error(format!(
|
||||
"replay {path} failed at line {line_number}: {error}"
|
||||
));
|
||||
if !command.is_empty() {
|
||||
self.note_error(format!(" > {command}"));
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -708,6 +738,28 @@ impl App {
|
||||
|
||||
fn dispatch_dsl(&mut self, input: &str, submission_mode: Mode) -> Vec<Action> {
|
||||
match parse_command(input) {
|
||||
Ok(Command::Replay { path }) => {
|
||||
// `replay` is parsed as a DSL command for the
|
||||
// sake of grammar uniformity, but its execution
|
||||
// model is fundamentally different from every
|
||||
// other command — it loops over file content and
|
||||
// re-enters the dispatch pipeline once per line.
|
||||
// Sending it down the ExecuteDsl path would push
|
||||
// the recursion through the database worker
|
||||
// thread, which is wrong: the worker has no
|
||||
// filesystem context, and replay would also land
|
||||
// in `history.log` (where it would re-trigger
|
||||
// itself on the next replay-of-history). So we
|
||||
// hand it off as a dedicated `Action::Replay`,
|
||||
// keeping the worker out of the loop and the
|
||||
// history.log clean.
|
||||
self.push_output(OutputLine {
|
||||
text: format!("running: {input}"),
|
||||
kind: OutputKind::Echo,
|
||||
mode_at_submission: submission_mode,
|
||||
});
|
||||
vec![Action::Replay { path }]
|
||||
}
|
||||
Ok(cmd) => {
|
||||
self.push_output(OutputLine {
|
||||
text: format!("running: {input}"),
|
||||
@@ -1224,6 +1276,10 @@ impl App {
|
||||
" delete from <T> where <c>=<v> | --all-rows",
|
||||
" show table <T>",
|
||||
" show data <T>",
|
||||
" replay <path> — run each non-blank, non-`#`-comment line",
|
||||
" of <path> 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",
|
||||
@@ -1623,6 +1679,81 @@ mod tests {
|
||||
assert!(app.output.iter().any(|l| l.text.starts_with("[ok]")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_command_dispatches_replay_action_not_execute_dsl() {
|
||||
// Submitting `replay <path>` must NOT produce an
|
||||
// `Action::ExecuteDsl` (otherwise the worker thread
|
||||
// would try to execute Replay, which has no semantics
|
||||
// there, and history.log would record the replay
|
||||
// invocation itself — see ADR-related runtime comments).
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "replay history.log");
|
||||
let actions = submit(&mut app);
|
||||
assert_eq!(actions.len(), 1);
|
||||
match &actions[0] {
|
||||
Action::Replay { path } => assert_eq!(path, "history.log"),
|
||||
other => panic!("expected Action::Replay, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_completed_event_writes_ok_summary() {
|
||||
let mut app = App::new();
|
||||
app.update(AppEvent::ReplayCompleted {
|
||||
path: "seed.commands".to_string(),
|
||||
count: 4,
|
||||
});
|
||||
let last = app.output.back().unwrap();
|
||||
assert_eq!(last.kind, OutputKind::System);
|
||||
assert!(last.text.starts_with("[ok] replay"), "{}", last.text);
|
||||
assert!(last.text.contains("4 command(s)"), "{}", last.text);
|
||||
assert!(last.text.contains("seed.commands"), "{}", last.text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_failed_event_renders_line_number_and_command_echo() {
|
||||
let mut app = App::new();
|
||||
app.update(AppEvent::ReplayFailed {
|
||||
path: "seed.commands".to_string(),
|
||||
line_number: 3,
|
||||
command: "this is not a command".to_string(),
|
||||
error: "parse error: …".to_string(),
|
||||
});
|
||||
// Two error lines emitted: header with line number,
|
||||
// then ` > <command>` echo for context.
|
||||
let lines: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
|
||||
assert!(
|
||||
lines.iter().any(|l| l.contains("at line 3")),
|
||||
"missing line-number header in {lines:?}"
|
||||
);
|
||||
assert!(
|
||||
lines.iter().any(|l| l.contains("> this is not a command")),
|
||||
"missing command echo in {lines:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_failed_with_line_zero_skips_command_echo() {
|
||||
// Line-number 0 is the runtime's signal that file-open
|
||||
// itself failed; there's no per-line command to echo.
|
||||
let mut app = App::new();
|
||||
app.update(AppEvent::ReplayFailed {
|
||||
path: "missing.commands".to_string(),
|
||||
line_number: 0,
|
||||
command: String::new(),
|
||||
error: "could not open `missing.commands`: not found".to_string(),
|
||||
});
|
||||
let lines: Vec<&str> = app.output.iter().map(|l| l.text.as_str()).collect();
|
||||
assert!(
|
||||
lines.iter().any(|l| l.contains("could not open")),
|
||||
"missing error in {lines:?}"
|
||||
);
|
||||
assert!(
|
||||
!lines.iter().any(|l| l.contains("at line 0")),
|
||||
"should not render `at line 0` header in {lines:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsl_failure_event_writes_error_with_friendly_message() {
|
||||
let mut app = App::new();
|
||||
|
||||
Reference in New Issue
Block a user