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:
@@ -125,6 +125,23 @@ pub enum Command {
|
||||
ShowData {
|
||||
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).
|
||||
@@ -200,6 +217,7 @@ impl Command {
|
||||
Self::Update { .. } => "update",
|
||||
Self::Delete { .. } => "delete from",
|
||||
Self::ShowData { .. } => "show data",
|
||||
Self::Replay { .. } => "replay",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +251,9 @@ impl Command {
|
||||
// is a sensible fallback for logging.
|
||||
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 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:?}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user