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:
+182
@@ -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"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user