c4ee264636
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).
102 lines
3.9 KiB
Rust
102 lines
3.9 KiB
Rust
//! Actions returned by the application's update function.
|
|
//!
|
|
//! `update` is pure with respect to the runtime: it mutates
|
|
//! state in place and returns a list of `Action`s for the
|
|
//! runtime to enact (quit, dispatch a DSL command to the
|
|
//! database, etc.). Side effects belong here, not in update
|
|
//! itself, which keeps update directly testable without a Tokio
|
|
//! runtime, a real terminal, or a database.
|
|
|
|
use crate::dsl::Command;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum Action {
|
|
/// Stop the event loop and exit cleanly.
|
|
Quit,
|
|
/// Hand a parsed DSL command to the database worker.
|
|
///
|
|
/// `command` is the parsed AST that the worker executes;
|
|
/// `source` is the original user-typed text, retained for
|
|
/// `history.log` per ADR-0015 §5. The runtime feeds the
|
|
/// result back as `AppEvent::DslSucceeded` /
|
|
/// `AppEvent::DslFailed`.
|
|
ExecuteDsl {
|
|
command: Command,
|
|
source: String,
|
|
},
|
|
/// User issued the `rebuild` app-level command (ADR-0015
|
|
/// §7, §11). Runtime computes a summary from
|
|
/// `project.yaml` + `data/` and posts back as
|
|
/// `AppEvent::RebuildPrepared`, which opens the
|
|
/// confirmation modal.
|
|
PrepareRebuild,
|
|
/// User confirmed the rebuild from inside the modal.
|
|
/// Runtime wipes the current schema/data and reconstructs
|
|
/// from text sources.
|
|
Rebuild {
|
|
source: String,
|
|
},
|
|
/// Open the load-picker modal. Runtime lists projects in
|
|
/// the active data root and posts back as
|
|
/// `AppEvent::LoadPickerReady`.
|
|
OpenLoadPicker,
|
|
/// Switch to the project at `path` (absolute or relative
|
|
/// to the active data root). Runtime drops the current
|
|
/// project, opens the new one, refreshes app state.
|
|
LoadProject {
|
|
path: std::path::PathBuf,
|
|
source: String,
|
|
},
|
|
/// Save the current project to `target` and switch to it.
|
|
/// `target` is a name or absolute path; relative names
|
|
/// resolve against `<data-root>/projects/` per ADR-0015 §1.
|
|
SaveAs {
|
|
target: String,
|
|
source: String,
|
|
},
|
|
/// Close the current project (auto-save guarantees state
|
|
/// is on disk) and create a fresh auto-named temp.
|
|
NewProject {
|
|
source: String,
|
|
},
|
|
/// Export the current project to a zip file. `target` is
|
|
/// `None` for the default filename
|
|
/// (`YYYYMMDD-<projectname>-export-NN.zip`) under the
|
|
/// active data root, or `Some(path)` for an explicit
|
|
/// target. Relative paths resolve under
|
|
/// `<data-root>/`. Per ADR-0015 §11 the zip excludes
|
|
/// `playground.db` and `history.log`.
|
|
Export {
|
|
target: Option<String>,
|
|
source: String,
|
|
},
|
|
/// Import a previously-exported zip and switch to the
|
|
/// resulting project. `zip_path` is the user-typed path to
|
|
/// the source archive (relative to CWD or absolute).
|
|
/// `as_target` is the optional user-supplied destination
|
|
/// name; when `None`, the destination is derived from the
|
|
/// zip's top-level folder. Collisions auto-suffix `-NN`
|
|
/// (ADR-0015 §11 amendment).
|
|
Import {
|
|
zip_path: String,
|
|
as_target: Option<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,
|
|
},
|
|
}
|