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:
@@ -82,4 +82,20 @@ pub enum Action {
|
|||||||
as_target: Option<String>,
|
as_target: Option<String>,
|
||||||
source: String,
|
source: String,
|
||||||
},
|
},
|
||||||
|
/// Replay a script of DSL commands from a file. The runtime
|
||||||
|
/// reads the file, iterates non-blank/non-comment lines, and
|
||||||
|
/// dispatches each through the same path as interactive
|
||||||
|
/// input. On per-line failure the runtime reports the line
|
||||||
|
/// number and stops (no rollback). On success it reports the
|
||||||
|
/// number of commands run.
|
||||||
|
///
|
||||||
|
/// `path` is the literal user-typed path; the runtime
|
||||||
|
/// resolves relative paths against the active project's root
|
||||||
|
/// so `replay history.log` works inside any project. Replay
|
||||||
|
/// itself is NOT written to `history.log` — only the
|
||||||
|
/// individual commands it dispatches are, since they are
|
||||||
|
/// what mutate state.
|
||||||
|
Replay {
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+131
@@ -405,6 +405,36 @@ impl App {
|
|||||||
self.note_error(format!("export failed: {error}"));
|
self.note_error(format!("export failed: {error}"));
|
||||||
Vec::new()
|
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> {
|
fn dispatch_dsl(&mut self, input: &str, submission_mode: Mode) -> Vec<Action> {
|
||||||
match parse_command(input) {
|
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) => {
|
Ok(cmd) => {
|
||||||
self.push_output(OutputLine {
|
self.push_output(OutputLine {
|
||||||
text: format!("running: {input}"),
|
text: format!("running: {input}"),
|
||||||
@@ -1224,6 +1276,10 @@ impl App {
|
|||||||
" delete from <T> where <c>=<v> | --all-rows",
|
" delete from <T> where <c>=<v> | --all-rows",
|
||||||
" show table <T>",
|
" show table <T>",
|
||||||
" show data <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",
|
"Types: text, int, real, decimal, bool, date, datetime, blob, serial, shortid",
|
||||||
"Auto-generated types (serial, shortid):",
|
"Auto-generated types (serial, shortid):",
|
||||||
" serial — integer that auto-fills with the next sequence value",
|
" 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]")));
|
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]
|
#[test]
|
||||||
fn dsl_failure_event_writes_error_with_friendly_message() {
|
fn dsl_failure_event_writes_error_with_friendly_message() {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
|||||||
@@ -125,6 +125,23 @@ pub enum Command {
|
|||||||
ShowData {
|
ShowData {
|
||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
|
/// Replay a sequence of DSL commands from a file. Each line
|
||||||
|
/// is parsed and dispatched through the same pipeline as
|
||||||
|
/// interactive input. Blank lines and lines whose first
|
||||||
|
/// non-whitespace character is `#` are skipped. Execution
|
||||||
|
/// stops at the first failure (parse or runtime); previously
|
||||||
|
/// applied commands are NOT rolled back — the partial state
|
||||||
|
/// is left in place because that matches the "I'm replaying
|
||||||
|
/// my history" mental model where a partial replay is a
|
||||||
|
/// recoverable state.
|
||||||
|
///
|
||||||
|
/// `path` is the literal user-typed path. The runtime
|
||||||
|
/// resolves relative paths against the active project's root
|
||||||
|
/// so that `replay history.log` works without ceremony from
|
||||||
|
/// inside a project.
|
||||||
|
Replay {
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Conversion mode for `change column …` (ADR-0017 §5).
|
/// Conversion mode for `change column …` (ADR-0017 §5).
|
||||||
@@ -200,6 +217,7 @@ impl Command {
|
|||||||
Self::Update { .. } => "update",
|
Self::Update { .. } => "update",
|
||||||
Self::Delete { .. } => "delete from",
|
Self::Delete { .. } => "delete from",
|
||||||
Self::ShowData { .. } => "show data",
|
Self::ShowData { .. } => "show data",
|
||||||
|
Self::Replay { .. } => "replay",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +251,9 @@ impl Command {
|
|||||||
// is a sensible fallback for logging.
|
// is a sensible fallback for logging.
|
||||||
RelationshipSelector::Named { name } => name,
|
RelationshipSelector::Named { name } => name,
|
||||||
},
|
},
|
||||||
|
// Replay isn't tied to a single table; the path is
|
||||||
|
// the most identifying thing for log output.
|
||||||
|
Self::Replay { path } => path,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -312,6 +312,10 @@ fn command_parser<'a>()
|
|||||||
let update_cmd = update_parser();
|
let update_cmd = update_parser();
|
||||||
let delete_cmd = delete_parser();
|
let delete_cmd = delete_parser();
|
||||||
|
|
||||||
|
let replay = keyword_ci("replay")
|
||||||
|
.ignore_then(path_literal())
|
||||||
|
.map(|path| Command::Replay { path });
|
||||||
|
|
||||||
choice((
|
choice((
|
||||||
create_table,
|
create_table,
|
||||||
// `drop column` and `drop relationship` come before
|
// `drop column` and `drop relationship` come before
|
||||||
@@ -332,6 +336,7 @@ fn command_parser<'a>()
|
|||||||
insert_cmd,
|
insert_cmd,
|
||||||
update_cmd,
|
update_cmd,
|
||||||
delete_cmd,
|
delete_cmd,
|
||||||
|
replay,
|
||||||
))
|
))
|
||||||
.padded()
|
.padded()
|
||||||
.then_ignore(end())
|
.then_ignore(end())
|
||||||
@@ -509,6 +514,43 @@ fn string_literal<'a>()
|
|||||||
body.map(Value::Text)
|
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>
|
/// `add 1:n relationship [<name>] from <P>.<col> to <C>.<col>
|
||||||
/// [on delete <action>] [on update <action>] [--create-fk]`.
|
/// [on delete <action>] [on update <action>] [--create-fk]`.
|
||||||
fn add_relationship_parser<'a>()
|
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:?}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -120,4 +120,21 @@ pub enum AppEvent {
|
|||||||
ExportFailed {
|
ExportFailed {
|
||||||
error: String,
|
error: String,
|
||||||
},
|
},
|
||||||
|
/// A `replay <path>` finished without error, after running
|
||||||
|
/// `count` non-blank, non-comment commands from the file.
|
||||||
|
/// Surfaced as `[ok] replay — N command(s)` in the output.
|
||||||
|
ReplayCompleted {
|
||||||
|
path: String,
|
||||||
|
count: usize,
|
||||||
|
},
|
||||||
|
/// A `replay <path>` aborted at line `line_number`. `command`
|
||||||
|
/// is the line text as it appeared in the file (for the
|
||||||
|
/// user's eyeline so they can locate the failing entry);
|
||||||
|
/// `error` is the rendered parse or runtime error.
|
||||||
|
ReplayFailed {
|
||||||
|
path: String,
|
||||||
|
line_number: usize,
|
||||||
|
command: String,
|
||||||
|
error: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+182
@@ -365,6 +365,14 @@ async fn run_loop(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
Action::Replay { path } => {
|
||||||
|
spawn_replay(
|
||||||
|
session.database().clone(),
|
||||||
|
session.project().path().to_path_buf(),
|
||||||
|
path,
|
||||||
|
event_tx.clone(),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
terminal
|
terminal
|
||||||
@@ -995,6 +1003,171 @@ enum CommandOutcome {
|
|||||||
AddColumn(AddColumnResult),
|
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
|
/// Execute a parsed user command and return either a typed
|
||||||
/// `CommandOutcome` or the raw `DbError`. Keeping the typed
|
/// `CommandOutcome` or the raw `DbError`. Keeping the typed
|
||||||
/// error here lets us distinguish persistence-fatal failures
|
/// error here lets us distinguish persistence-fatal failures
|
||||||
@@ -1094,6 +1267,15 @@ async fn execute_command_typed(
|
|||||||
.query_data(name, src)
|
.query_data(name, src)
|
||||||
.await
|
.await
|
||||||
.map(CommandOutcome::Query),
|
.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"
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
//! Integration tests for the `replay <path>` command (U4).
|
||||||
|
//!
|
||||||
|
//! Exercises the runtime's `run_replay` directly rather than
|
||||||
|
//! booting a Tokio event loop — the inner replay logic is the
|
||||||
|
//! interesting unit, and `spawn_replay` is just the mpsc shim
|
||||||
|
//! around it.
|
||||||
|
//!
|
||||||
|
//! Covers (per handoff §A3):
|
||||||
|
//! - Happy path: 3-line file dispatches 3 commands, project
|
||||||
|
//! state reflects the dispatched DDL/DML.
|
||||||
|
//! - Blank lines and `# comments` are skipped silently.
|
||||||
|
//! - Per-line failure: the runtime reports the line number of
|
||||||
|
//! the offending entry and stops without dispatching the rest.
|
||||||
|
//! Earlier successful commands are NOT rolled back.
|
||||||
|
//! - Empty file → ReplayCompleted with count 0.
|
||||||
|
//! - Missing file → ReplayFailed with line_number 0.
|
||||||
|
//! - Nested replay (`replay foo` inside the file being replayed)
|
||||||
|
//! is refused with a clear message.
|
||||||
|
//! - history.log invariant: replaying a file produces the same
|
||||||
|
//! per-command history entries as if the user had typed each
|
||||||
|
//! line interactively.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use tokio::runtime::Runtime;
|
||||||
|
|
||||||
|
use rdbms_playground::db::Database;
|
||||||
|
use rdbms_playground::event::AppEvent;
|
||||||
|
use rdbms_playground::persistence::Persistence;
|
||||||
|
use rdbms_playground::project;
|
||||||
|
use rdbms_playground::runtime::run_replay;
|
||||||
|
|
||||||
|
fn rt() -> Runtime {
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.expect("tokio rt")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tempdir() -> tempfile::TempDir {
|
||||||
|
tempfile::tempdir().expect("create tempdir")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a fresh project + persistence-wired database under
|
||||||
|
/// `data_root`, returning both. Used as the canonical test
|
||||||
|
/// harness — most tests only need to write a script file and
|
||||||
|
/// call `run_replay`.
|
||||||
|
fn open_project_db(data_root: &Path) -> (project::Project, Database) {
|
||||||
|
let project = project::open_or_create(None, Some(data_root))
|
||||||
|
.expect("open_or_create");
|
||||||
|
let db = Database::open_with_persistence(
|
||||||
|
project.db_path(),
|
||||||
|
Persistence::new(project.path().to_path_buf()),
|
||||||
|
)
|
||||||
|
.expect("open db");
|
||||||
|
(project, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_script(project_path: &Path, name: &str, body: &str) {
|
||||||
|
fs::write(project_path.join(name), body).expect("write script");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_completed(events: &[AppEvent], expected_count: usize) {
|
||||||
|
let last = events.last().expect("at least one event");
|
||||||
|
match last {
|
||||||
|
AppEvent::ReplayCompleted { count, .. } => {
|
||||||
|
assert_eq!(
|
||||||
|
*count, expected_count,
|
||||||
|
"ReplayCompleted count mismatch (events: {events:?})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected ReplayCompleted, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_failed_at(events: &[AppEvent], expected_line: usize) -> &AppEvent {
|
||||||
|
let last = events.last().expect("at least one event");
|
||||||
|
match last {
|
||||||
|
AppEvent::ReplayFailed { line_number, .. } => {
|
||||||
|
assert_eq!(
|
||||||
|
*line_number, expected_line,
|
||||||
|
"ReplayFailed line_number mismatch (events: {events:?})"
|
||||||
|
);
|
||||||
|
last
|
||||||
|
}
|
||||||
|
other => panic!("expected ReplayFailed, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_three_lines_dispatches_three_commands() {
|
||||||
|
let data = tempdir();
|
||||||
|
let (project, db) = open_project_db(data.path());
|
||||||
|
write_script(
|
||||||
|
project.path(),
|
||||||
|
"seed.commands",
|
||||||
|
"create table T with pk id:int\n\
|
||||||
|
add column T: name (text)\n\
|
||||||
|
insert into T (1, 'Alice')\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let events = rt().block_on(async {
|
||||||
|
run_replay(&db, project.path(), "seed.commands").await
|
||||||
|
});
|
||||||
|
assert_completed(&events, 3);
|
||||||
|
|
||||||
|
// The dispatched commands actually mutated state.
|
||||||
|
let data_result = rt()
|
||||||
|
.block_on(async { db.query_data("T".to_string(), None).await })
|
||||||
|
.expect("query_data");
|
||||||
|
assert_eq!(data_result.rows.len(), 1, "row inserted");
|
||||||
|
assert_eq!(data_result.rows[0][1].as_deref(), Some("Alice"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_skips_blank_lines_and_comments() {
|
||||||
|
let data = tempdir();
|
||||||
|
let (project, db) = open_project_db(data.path());
|
||||||
|
write_script(
|
||||||
|
project.path(),
|
||||||
|
"seed.commands",
|
||||||
|
"# this is a comment\n\
|
||||||
|
\n\
|
||||||
|
create table T with pk id:int\n\
|
||||||
|
\n\
|
||||||
|
# another comment\n\
|
||||||
|
# comment with leading whitespace\n\
|
||||||
|
add column T: name (text)\n\
|
||||||
|
\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let events = rt().block_on(async {
|
||||||
|
run_replay(&db, project.path(), "seed.commands").await
|
||||||
|
});
|
||||||
|
// Only two non-blank, non-comment lines.
|
||||||
|
assert_completed(&events, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_empty_file_completes_with_zero_commands() {
|
||||||
|
let data = tempdir();
|
||||||
|
let (project, db) = open_project_db(data.path());
|
||||||
|
write_script(project.path(), "empty.commands", "");
|
||||||
|
|
||||||
|
let events = rt().block_on(async {
|
||||||
|
run_replay(&db, project.path(), "empty.commands").await
|
||||||
|
});
|
||||||
|
assert_completed(&events, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_only_comments_completes_with_zero_commands() {
|
||||||
|
let data = tempdir();
|
||||||
|
let (project, db) = open_project_db(data.path());
|
||||||
|
write_script(
|
||||||
|
project.path(),
|
||||||
|
"comments.commands",
|
||||||
|
"# just\n# comments\n\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let events = rt().block_on(async {
|
||||||
|
run_replay(&db, project.path(), "comments.commands").await
|
||||||
|
});
|
||||||
|
assert_completed(&events, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_missing_file_fails_with_line_number_zero() {
|
||||||
|
let data = tempdir();
|
||||||
|
let (project, db) = open_project_db(data.path());
|
||||||
|
|
||||||
|
let events = rt().block_on(async {
|
||||||
|
run_replay(&db, project.path(), "no-such-file.commands").await
|
||||||
|
});
|
||||||
|
let failed = assert_failed_at(&events, 0);
|
||||||
|
let AppEvent::ReplayFailed { error, .. } = failed else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
error.contains("could not open"),
|
||||||
|
"expected `could not open` in error: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_aborts_on_first_parse_failure_and_reports_line() {
|
||||||
|
let data = tempdir();
|
||||||
|
let (project, db) = open_project_db(data.path());
|
||||||
|
write_script(
|
||||||
|
project.path(),
|
||||||
|
"bad.commands",
|
||||||
|
// Line 1: ok. Line 2: ok. Line 3: parse error
|
||||||
|
// (`broken keyword X` — not a recognised command).
|
||||||
|
"create table T with pk id:int\n\
|
||||||
|
add column T: name (text)\n\
|
||||||
|
this is not a command\n\
|
||||||
|
insert into T (1, 'should not happen')\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let events = rt().block_on(async {
|
||||||
|
run_replay(&db, project.path(), "bad.commands").await
|
||||||
|
});
|
||||||
|
let failed = assert_failed_at(&events, 3);
|
||||||
|
let AppEvent::ReplayFailed { error, command, .. } = failed else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
assert!(error.contains("parse error"), "got: {error}");
|
||||||
|
assert_eq!(command, "this is not a command");
|
||||||
|
|
||||||
|
// The failing line stops dispatch — no row was inserted —
|
||||||
|
// but earlier commands stayed applied (table T exists with
|
||||||
|
// the `name` column).
|
||||||
|
let desc = rt()
|
||||||
|
.block_on(async { db.describe_table("T".to_string(), None).await })
|
||||||
|
.expect("describe_table");
|
||||||
|
assert!(
|
||||||
|
desc.columns.iter().any(|c| c.name == "name"),
|
||||||
|
"earlier add column should have stayed applied"
|
||||||
|
);
|
||||||
|
let data_result = rt()
|
||||||
|
.block_on(async { db.query_data("T".to_string(), None).await })
|
||||||
|
.expect("query_data");
|
||||||
|
assert!(
|
||||||
|
data_result.rows.is_empty(),
|
||||||
|
"post-failure insert should not have run"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_aborts_on_first_runtime_failure_and_reports_line() {
|
||||||
|
let data = tempdir();
|
||||||
|
let (project, db) = open_project_db(data.path());
|
||||||
|
write_script(
|
||||||
|
project.path(),
|
||||||
|
"bad.commands",
|
||||||
|
// Line 2 references a table that doesn't exist; the
|
||||||
|
// engine refuses, replay stops and reports line 2.
|
||||||
|
"create table T with pk id:int\n\
|
||||||
|
add column NotATable: x (text)\n\
|
||||||
|
insert into T (1)\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let events = rt().block_on(async {
|
||||||
|
run_replay(&db, project.path(), "bad.commands").await
|
||||||
|
});
|
||||||
|
let _ = assert_failed_at(&events, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_refuses_nested_replay() {
|
||||||
|
let data = tempdir();
|
||||||
|
let (project, db) = open_project_db(data.path());
|
||||||
|
write_script(project.path(), "inner.commands", "create table T with pk id:int\n");
|
||||||
|
write_script(
|
||||||
|
project.path(),
|
||||||
|
"outer.commands",
|
||||||
|
"create table U with pk id:int\nreplay inner.commands\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let events = rt().block_on(async {
|
||||||
|
run_replay(&db, project.path(), "outer.commands").await
|
||||||
|
});
|
||||||
|
let failed = assert_failed_at(&events, 2);
|
||||||
|
let AppEvent::ReplayFailed { error, .. } = failed else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
error.contains("nested `replay`"),
|
||||||
|
"expected nested-replay refusal: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replay_history_log_records_subcommands_only() {
|
||||||
|
// Per handoff §A3: replaying produces the same per-command
|
||||||
|
// history.log entries as if each line had been typed
|
||||||
|
// interactively. The replay invocation itself MUST NOT
|
||||||
|
// appear in history.log (otherwise `replay history.log`
|
||||||
|
// would re-trigger itself recursively).
|
||||||
|
let data = tempdir();
|
||||||
|
let (project, db) = open_project_db(data.path());
|
||||||
|
write_script(
|
||||||
|
project.path(),
|
||||||
|
"seed.commands",
|
||||||
|
"create table T with pk id:int\nadd column T: name (text)\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let events = rt().block_on(async {
|
||||||
|
run_replay(&db, project.path(), "seed.commands").await
|
||||||
|
});
|
||||||
|
assert_completed(&events, 2);
|
||||||
|
|
||||||
|
let history = fs::read_to_string(project.path().join("history.log"))
|
||||||
|
.expect("history.log exists");
|
||||||
|
// Per-command entries landed.
|
||||||
|
assert!(
|
||||||
|
history.lines().any(|l| l.contains("create table T with pk id:int")),
|
||||||
|
"history.log missing create line:\n{history}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
history.lines().any(|l| l.contains("add column T: name (text)")),
|
||||||
|
"history.log missing add column line:\n{history}"
|
||||||
|
);
|
||||||
|
// The replay invocation itself did NOT land — that's
|
||||||
|
// the App layer's responsibility (Action::Replay never
|
||||||
|
// reaches the per-command persistence path).
|
||||||
|
assert!(
|
||||||
|
!history.lines().any(|l| l.contains("replay seed.commands")),
|
||||||
|
"history.log unexpectedly contains the replay invocation:\n{history}"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user