diff --git a/src/action.rs b/src/action.rs index a699ff4..6004000 100644 --- a/src/action.rs +++ b/src/action.rs @@ -82,4 +82,20 @@ pub enum Action { as_target: Option, 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, + }, } diff --git a/src/app.rs b/src/app.rs index 68fdc85..a9beaab 100644 --- a/src/app.rs +++ b/src/app.rs @@ -405,6 +405,36 @@ impl App { self.note_error(format!("export failed: {error}")); 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 { 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) => { self.push_output(OutputLine { text: format!("running: {input}"), @@ -1224,6 +1276,10 @@ impl App { " delete from where = | --all-rows", " show table ", " show data ", + " replay — run each non-blank, non-`#`-comment line", + " of 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", "Auto-generated types (serial, shortid):", " 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]"))); } + #[test] + fn replay_command_dispatches_replay_action_not_execute_dsl() { + // Submitting `replay ` 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 ` > ` 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] fn dsl_failure_event_writes_error_with_friendly_message() { let mut app = App::new(); diff --git a/src/dsl/command.rs b/src/dsl/command.rs index b88abb3..8de9dae 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -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, } } diff --git a/src/dsl/parser.rs b/src/dsl/parser.rs index ea5505c..763076e 100644 --- a/src/dsl/parser.rs +++ b/src/dsl/parser.rs @@ -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>> + Clone { + let quoted = just('\'') + .ignore_then( + choice(( + just("''").to('\''), + any().filter(|c: &char| *c != '\''), + )) + .repeated() + .collect::(), + ) + .then_ignore(just('\'')); + let bare = any() + .filter(|c: &char| !c.is_whitespace() && !matches!(*c, '\'' | '(' | ')' | ';')) + .repeated() + .at_least(1) + .collect::(); + 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 [] from

. to . /// [on delete ] [on update ] [--create-fk]`. fn add_relationship_parser<'a>() @@ -1668,4 +1710,78 @@ mod tests { } ); } + + // --- replay --- + + #[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:?}"); + } } \ No newline at end of file diff --git a/src/event.rs b/src/event.rs index 60f49c4..345470d 100644 --- a/src/event.rs +++ b/src/event.rs @@ -120,4 +120,21 @@ pub enum AppEvent { ExportFailed { error: String, }, + /// A `replay ` 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 ` 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, + }, } diff --git a/src/runtime.rs b/src/runtime.rs index 91cdc2d..4d56ab6 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -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, +) { + 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 { + let mut events: Vec = 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 ` 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" + ), } } diff --git a/tests/replay_command.rs b/tests/replay_command.rs new file mode 100644 index 0000000..f06616e --- /dev/null +++ b/tests/replay_command.rs @@ -0,0 +1,312 @@ +//! Integration tests for the `replay ` 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}" + ); +}