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:
claude@clouddev1
2026-05-08 15:06:56 +00:00
parent b8102dc063
commit c4ee264636
7 changed files with 795 additions and 0 deletions
+116
View File
@@ -312,6 +312,10 @@ fn command_parser<'a>()
let update_cmd = update_parser();
let delete_cmd = delete_parser();
let replay = keyword_ci("replay")
.ignore_then(path_literal())
.map(|path| Command::Replay { path });
choice((
create_table,
// `drop column` and `drop relationship` come before
@@ -332,6 +336,7 @@ fn command_parser<'a>()
insert_cmd,
update_cmd,
delete_cmd,
replay,
))
.padded()
.then_ignore(end())
@@ -509,6 +514,43 @@ fn string_literal<'a>()
body.map(Value::Text)
}
/// File path: either a single-quoted string (mirroring
/// `string_literal`'s escape rules — `''` for a literal quote)
/// for paths containing whitespace, or a bare run of
/// non-whitespace characters (no quotes, no parentheses, no
/// trailing semicolon — semicolons aren't part of the DSL but
/// reserving them keeps the door open for future statement
/// terminators). The empty string is rejected as a parse error.
fn path_literal<'a>()
-> impl Parser<'a, &'a str, String, extra::Err<Rich<'a, char>>> + Clone {
let quoted = just('\'')
.ignore_then(
choice((
just("''").to('\''),
any().filter(|c: &char| *c != '\''),
))
.repeated()
.collect::<String>(),
)
.then_ignore(just('\''));
let bare = any()
.filter(|c: &char| !c.is_whitespace() && !matches!(*c, '\'' | '(' | ')' | ';'))
.repeated()
.at_least(1)
.collect::<String>();
choice((quoted, bare))
.padded()
.labelled("path")
.as_context()
.try_map(|p, span| {
if p.is_empty() {
Err(Rich::custom(span, "path is empty".to_string()))
} else {
Ok(p)
}
})
}
/// `add 1:n relationship [<name>] from <P>.<col> to <C>.<col>
/// [on delete <action>] [on update <action>] [--create-fk]`.
fn add_relationship_parser<'a>()
@@ -1668,4 +1710,78 @@ mod tests {
}
);
}
// --- replay <path> ---
#[test]
fn replay_with_bare_relative_path() {
assert_eq!(
ok("replay history.log"),
Command::Replay {
path: "history.log".to_string(),
}
);
}
#[test]
fn replay_with_bare_absolute_path() {
assert_eq!(
ok("replay /tmp/seed.commands"),
Command::Replay {
path: "/tmp/seed.commands".to_string(),
}
);
}
#[test]
fn replay_with_quoted_path_supports_whitespace() {
// Single-quoted: required when the path contains a
// space, mirroring `string_literal`'s convention.
assert_eq!(
ok("replay 'my project/seed.commands'"),
Command::Replay {
path: "my project/seed.commands".to_string(),
}
);
}
#[test]
fn replay_with_quoted_path_supports_escaped_quote() {
// `''` is the escape for a literal single quote inside
// the quoted form, matching string literals.
assert_eq!(
ok("replay 'O''Brien.commands'"),
Command::Replay {
path: "O'Brien.commands".to_string(),
}
);
}
#[test]
fn replay_keyword_is_case_insensitive() {
// Like every other DSL keyword (ADR-0009), `replay`
// matches case-insensitively.
assert_eq!(
ok("REPLAY foo.txt"),
Command::Replay {
path: "foo.txt".to_string(),
}
);
}
#[test]
fn replay_without_path_errors() {
let e = err("replay");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn replay_with_empty_quoted_path_errors() {
// The path terminal explicitly rejects the empty string
// — an empty path can never resolve to a real file and
// catching it at parse time produces a sharper error
// than letting fs::read_to_string fail later.
let e = err("replay ''");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
}