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
+182
View File
@@ -365,6 +365,14 @@ async fn run_loop(
)
.await;
}
Action::Replay { path } => {
spawn_replay(
session.database().clone(),
session.project().path().to_path_buf(),
path,
event_tx.clone(),
);
}
}
}
terminal
@@ -995,6 +1003,171 @@ enum CommandOutcome {
AddColumn(AddColumnResult),
}
/// Spawn a task that reads a script file and dispatches each
/// non-blank, non-comment line through the same `execute_command_typed`
/// pipeline as interactive input.
///
/// `path` is the literal user-typed argument; relative paths are
/// resolved against `project_root`. On the first per-line failure
/// the task posts `AppEvent::ReplayFailed` carrying the line
/// number and stops — previously dispatched commands stay applied
/// (no rollback). On clean completion the task posts
/// `AppEvent::ReplayCompleted` with the count of commands that
/// were actually run (blanks/comments excluded).
///
/// The replay invocation itself is NOT written to `history.log`
/// (that happens at the App level, where Replay is dispatched as
/// `Action::Replay` rather than `Action::ExecuteDsl`); only the
/// individual sub-commands land there, since they are what
/// actually mutate state.
fn spawn_replay(
database: Database,
project_root: PathBuf,
path: String,
event_tx: mpsc::Sender<AppEvent>,
) {
tokio::spawn(async move {
let events = run_replay(&database, &project_root, &path).await;
for event in events {
if event_tx.send(event).await.is_err() {
return;
}
}
// Refresh the table list once at the end — every command
// dispatched through `execute_command_typed` may have
// altered the schema, but we don't want to flicker the
// panel mid-replay (and the table list is a derived view
// anyway).
match database.list_tables().await {
Ok(tables) => {
let _ = event_tx.send(AppEvent::TablesRefreshed(tables)).await;
}
Err(e) => warn!(error = %e, "post-replay list_tables failed"),
}
});
}
/// Inner replay loop, separated from the spawn wrapper so tests
/// can exercise the file-iteration / parse / dispatch logic
/// without an mpsc channel and a spawned task.
///
/// Returns the `AppEvent`s that the spawn wrapper would emit, in
/// order. Always returns at least one terminal event
/// (`ReplayCompleted`, `ReplayFailed`, or `PersistenceFatal`).
pub async fn run_replay(
database: &Database,
project_root: &std::path::Path,
path: &str,
) -> Vec<AppEvent> {
let mut events: Vec<AppEvent> = Vec::new();
let resolved = resolve_replay_path(project_root, path);
let body = match tokio::fs::read_to_string(&resolved).await {
Ok(b) => b,
Err(e) => {
events.push(AppEvent::ReplayFailed {
path: path.to_string(),
line_number: 0,
command: String::new(),
error: format!("could not open `{}`: {e}", resolved.display()),
});
return events;
}
};
let mut count: usize = 0;
for (idx, raw) in body.lines().enumerate() {
let line_number = idx + 1;
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
// Parse the line through the same DSL parser the
// interactive path uses. A failure here is structural
// (bad syntax) — report and stop without dispatching.
let command = match crate::dsl::parse_command(trimmed) {
Ok(c) => c,
Err(e) => {
events.push(AppEvent::ReplayFailed {
path: path.to_string(),
line_number,
command: trimmed.to_string(),
error: format!("parse error: {e}"),
});
return events;
}
};
// Nested replay is intentionally refused. Allowing it
// would invite easy infinite-loop footguns (a script
// replaying itself, two scripts replaying each other);
// the DSL is small enough that we'd rather close that
// door than design fences around it. A real
// composition story lands later if the need is proven.
if matches!(command, Command::Replay { .. }) {
events.push(AppEvent::ReplayFailed {
path: path.to_string(),
line_number,
command: trimmed.to_string(),
error: "nested `replay` is not allowed inside a replay file".to_string(),
});
return events;
}
// Dispatch through the same path as interactive input so
// per-command persistence (history.log, project.yaml,
// CSVs) fires as if the user had typed each line.
let outcome =
execute_command_typed(database, command, trimmed.to_string()).await;
match outcome {
Ok(_) => {
count += 1;
}
Err(DbError::PersistenceFatal {
operation,
path: pf_path,
message,
}) => {
// Persistence-fatal escalates through the existing
// fatal-banner channel; the runtime tears down on
// it, so further replay progress is moot.
events.push(AppEvent::PersistenceFatal {
operation: operation.to_string(),
path: pf_path,
message,
});
return events;
}
Err(e) => {
events.push(AppEvent::ReplayFailed {
path: path.to_string(),
line_number,
command: trimmed.to_string(),
error: e.friendly_message(),
});
return events;
}
}
}
events.push(AppEvent::ReplayCompleted {
path: path.to_string(),
count,
});
events
}
/// Resolve a `replay <path>` argument: absolute paths pass
/// through unchanged; relative paths are joined under the active
/// project's root so `replay history.log` works without ceremony
/// from inside any project.
fn resolve_replay_path(project_root: &std::path::Path, path: &str) -> PathBuf {
let p = PathBuf::from(path);
if p.is_absolute() {
p
} else {
project_root.join(p)
}
}
/// Execute a parsed user command and return either a typed
/// `CommandOutcome` or the raw `DbError`. Keeping the typed
/// error here lets us distinguish persistence-fatal failures
@@ -1094,6 +1267,15 @@ async fn execute_command_typed(
.query_data(name, src)
.await
.map(CommandOutcome::Query),
// `replay` is parsed as a DSL command but routed by
// App::dispatch_dsl as `Action::Replay` rather than
// `Action::ExecuteDsl`; it never reaches the worker
// thread. Hitting this arm would mean the dispatch
// routing was bypassed.
Command::Replay { .. } => unreachable!(
"Command::Replay is dispatched as Action::Replay; \
reaching execute_command_typed indicates a routing bug"
),
}
}