ADR-0024 Phase E: replay end-to-end

Migrate `replay <path>` to the walker. Shape is
Choice(StringLit, BarePath); the StringLit branch handles the
quoted form (with the existing `''` escape), and BarePath
handles the unquoted form.

Per ADR-0024's path-bearing UX change (already shipped for
import / export in Phase A), bare `replay` paths terminate at
the first whitespace byte. Paths with spaces require the
quoted form. The legacy `try_parse_replay_with_bare_path`
source-slice helper in dsl/parser.rs is removed; the
chumsky-side replay branch in command_parser stays declared
but unreachable until Phase F sweeps the chumsky path.

Tests:
- 7 new walker-specific tests for replay: bare relative path,
  bare absolute path, quoted with whitespace, quoted with
  escaped quote, case-insensitive keyword, missing-path
  error, empty-quoted-path parses to empty (runtime layer
  rejects).
- Total: 844 passed, 0 failed, 1 ignored (was 838 / 1).
- cargo clippy --all-targets -- -D warnings clean.
This commit is contained in:
claude@clouddev1
2026-05-15 07:23:51 +00:00
parent c2accc2385
commit dca472f8a5
4 changed files with 115 additions and 51 deletions
+39
View File
@@ -490,6 +490,36 @@ fn build_delete(path: &MatchedPath) -> Result<Command, ValidationError> {
Ok(Command::Delete { table, filter })
}
// =================================================================
// replay — `replay <bare-path>` | `replay '<path>'`
// =================================================================
//
// Phase E (ADR-0024 §migration). The chumsky-side
// `try_parse_replay_with_bare_path` source-slice helper is
// retired here: walker BarePath consumes the unquoted form
// (terminating at whitespace per the path-bearing UX change),
// and StringLit consumes the quoted form. Paths with spaces
// must use the quoted form — same UX that `import` / `export`
// adopted in Phase A.
const REPLAY_PATH_CHOICES: &[Node] = &[Node::StringLit, Node::BarePath];
const REPLAY_PATH: Node = Node::Choice(REPLAY_PATH_CHOICES);
fn build_replay(path: &MatchedPath) -> Result<Command, ValidationError> {
let payload = path
.items
.iter()
.find_map(|i| match &i.kind {
MatchedKind::StringLit | MatchedKind::BarePath => Some(i.text.clone()),
_ => None,
})
.ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "missing path".to_string())],
})?;
Ok(Command::Replay { path: payload })
}
// =================================================================
// CommandNodes
// =================================================================
@@ -529,3 +559,12 @@ pub static DELETE: CommandNode = CommandNode {
usage_id: Some("parse.usage.delete"),
hint_mode: None,
};
pub static REPLAY: CommandNode = CommandNode {
entry: Word::keyword("replay"),
shape: REPLAY_PATH,
ast_builder: build_replay,
help_id: Some("data.replay"),
usage_id: Some("parse.usage.replay"),
hint_mode: None,
};
+1
View File
@@ -259,6 +259,7 @@ pub static REGISTRY: &[&CommandNode] = &[
&data::INSERT,
&data::UPDATE,
&data::DELETE,
&data::REPLAY,
];
/// Look up a `CommandNode` by entry word, case-insensitively.
+5 -45
View File
@@ -120,9 +120,6 @@ pub fn parse_tokens(tokens: &[Token], source: &str) -> Result<Command, ParseErro
if let Some(result) = try_walker_route(source) {
return result;
}
if let Some(result) = try_parse_replay_with_bare_path(tokens, source) {
return result;
}
match command_parser().parse(tokens).into_result() {
Ok(cmd) => Ok(cmd),
Err(errs) => Err(into_parse_error(&errs, tokens, source)),
@@ -272,48 +269,11 @@ fn oxford_join(items: &[String]) -> String {
}
}
/// `replay` source-slice special case (ADR-0020 §6).
///
/// `replay <bare-path>` lets the user write paths containing
/// `/`, `.`, `~`, etc. — characters that the lexer would either
/// classify as `Punct` or as `Error(UnknownChar)`. To keep the
/// existing UX working, we detect `replay` followed by anything
/// other than a `StringLiteral` and source-slice the rest of
/// the input as the path. The quoted form (`replay '<path>'`)
/// goes through the regular chumsky path.
fn try_parse_replay_with_bare_path(
tokens: &[Token],
source: &str,
) -> Option<Result<Command, ParseError>> {
let first = tokens.first()?;
if !matches!(first.kind, TokenKind::Keyword(Keyword::Replay)) {
return None;
}
if matches!(
tokens.get(1).map(|t| &t.kind),
Some(TokenKind::StringLiteral(_))
) {
// Quoted form — chumsky handles it (and rejects any
// trailing garbage).
return None;
}
let after_replay = first.span.1;
let rest = source[after_replay..].trim();
if rest.is_empty() {
// `replay` with nothing after — produce the same shape
// of error chumsky would (positioned where the path
// should have started).
return Some(Err(ParseError::Invalid {
message: crate::t!("parse.custom.replay_path_expected"),
position: after_replay,
at_eof: true,
expected: vec!["path".to_string()],
}));
}
Some(Ok(Command::Replay {
path: rest.to_string(),
}))
}
// ADR-0024 Phase E removed `try_parse_replay_with_bare_path`:
// the walker now owns `replay` end-to-end via
// `Choice(StringLit, BarePath)`. The chumsky-side replay
// branch in `command_parser` is unreachable until Phase F
// sweeps the chumsky path.
// ADR-0024 Phase A removed `try_parse_app_path_command`: the
// walker (`crate::dsl::walker`) now owns export / import end-to-
+70 -6
View File
@@ -907,13 +907,77 @@ mod tests {
assert!(parse("update Customers set Email='x'").is_err());
}
// =========================================================
// Phase E — replay.
// =========================================================
#[test]
fn walker_does_not_engage_for_replay() {
// `replay` isn't migrated yet (Phase E); router falls
// through to chumsky.
assert!(matches!(
fn walker_parses_replay_with_bare_relative_path() {
assert_eq!(
parse("replay history.log").unwrap(),
Command::Replay { .. }
));
Command::Replay {
path: "history.log".to_string(),
}
);
}
#[test]
fn walker_parses_replay_with_bare_absolute_path() {
assert_eq!(
parse("replay /tmp/seed.commands").unwrap(),
Command::Replay {
path: "/tmp/seed.commands".to_string(),
}
);
}
#[test]
fn walker_parses_replay_with_quoted_path_supports_whitespace() {
// Phase A's path-bearing UX change: paths with spaces use
// the quoted form.
assert_eq!(
parse("replay 'my project/seed.commands'").unwrap(),
Command::Replay {
path: "my project/seed.commands".to_string(),
}
);
}
#[test]
fn walker_parses_replay_with_quoted_path_supports_escaped_quote() {
assert_eq!(
parse("replay 'O''Brien.commands'").unwrap(),
Command::Replay {
path: "O'Brien.commands".to_string(),
}
);
}
#[test]
fn walker_replay_keyword_case_insensitive() {
assert_eq!(
parse("REPLAY foo.txt").unwrap(),
Command::Replay {
path: "foo.txt".to_string(),
}
);
}
#[test]
fn walker_replay_without_path_errors() {
assert!(parse("replay").is_err());
}
#[test]
fn walker_replay_with_empty_quoted_path_parses_as_empty() {
// Parser layer accepts; runtime rejects empty paths
// before any I/O. Mirrors the chumsky-side contract
// (parser.rs `replay_with_empty_quoted_path_errors`).
assert_eq!(
parse("replay ''").unwrap(),
Command::Replay {
path: String::new(),
}
);
}
}