Iteration 6: --resume + persistent input history + migration scaffold
Closes out track 2's ADR-0015 backlog. * `--resume` CLI flag (L1a, ADR-0015 §7) opens the most- recently-used project, tracked in <data-root>/last_project. Mutually exclusive with a positional <project-path>; errors cleanly to stderr (above the shell prompt) on missing file or stale recorded path. last_project is rewritten on every successful project open (startup, load, new, save as, import). * Persistent input history (I2-persist, ADR-0015 §12). On project open, the in-memory navigable history is hydrated from the tail of history.log (capped at the in-memory cap). ProjectSwitched gains a `history_entries` payload field; App::seed_history is the entry point. Pipes inside source text round-trip via splitn(3); unknown escape sequences are passed through literally. * Migration framework scaffold (F3, ADR-0015 §9). New persistence::migrations module with MigratorRegistry + migrate_to_latest + ensure_project_yaml_migrated. Empty in v1 (production registry has no migrators); the loader runs through it on every project open and is exercised by tests with a fake v1→v2 migrator. Writes project.yaml.v<N>.bak before any migrator runs; verifies each step bumps the version field. Refreshes docs/requirements.md (A1 / I2 / F3 / E1 / L1a / test baseline) and adds docs/handoff/20260508-handoff-3.md covering both Iter 5 and Iter 6. Total tests: 408 passing, 0 failing, 0 skipped (up from 345 at handoff-2). Clippy clean.
This commit is contained in:
@@ -0,0 +1,429 @@
|
|||||||
|
# Session handoff — 2026-05-08 (3)
|
||||||
|
|
||||||
|
Third handover. Continues track 2's ADR-0015 work: this
|
||||||
|
session completed Iterations 5 and 6 (export/import,
|
||||||
|
`--resume`, persistent input-history hydration, migration
|
||||||
|
framework scaffold), closing out the remaining track-2
|
||||||
|
backlog from the previous handoff.
|
||||||
|
|
||||||
|
## State at handoff
|
||||||
|
|
||||||
|
**Branch:** `main`. Working tree dirty (this handoff doc +
|
||||||
|
the iteration changes). The track-2 commits since handoff-2
|
||||||
|
are pending the user's commit approval.
|
||||||
|
|
||||||
|
**Tests:** 408 passing, 0 failing, 0 skipped (up from 345 at
|
||||||
|
the previous handoff). Breakdown:
|
||||||
|
|
||||||
|
```
|
||||||
|
unit (lib) 295 (272 + 23 new)
|
||||||
|
project_lifecycle.rs 0
|
||||||
|
walking_skeleton.rs 5
|
||||||
|
iteration2_persistence.rs 6
|
||||||
|
iteration3_rebuild.rs 9
|
||||||
|
iteration4a_rebuild_command.rs 17
|
||||||
|
iteration4b_lifecycle_commands.rs 27
|
||||||
|
iteration5_export_import.rs 14 (new)
|
||||||
|
iteration6_resume_history.rs 15 (new)
|
||||||
|
---
|
||||||
|
408 total
|
||||||
|
```
|
||||||
|
|
||||||
|
**Clippy:** clean with the nursery lint group enabled.
|
||||||
|
|
||||||
|
**Release build:** ~6.9MB single binary (up from 6.3MB at
|
||||||
|
handoff-2; the increase is the `zip` + `flate2` + `zlib-rs`
|
||||||
|
chain pulled in for export/import).
|
||||||
|
|
||||||
|
## What's implemented (delta vs. handoff-2)
|
||||||
|
|
||||||
|
### Iteration 5 — `export` / `import` (ADR-0015 §11 +
|
||||||
|
### ADR-0007 amendment 1)
|
||||||
|
|
||||||
|
**`export [<path>]` command:**
|
||||||
|
|
||||||
|
- Available in both modes per ADR-0003. Wired through
|
||||||
|
`Action::Export` and a new `spawn_export` task in the
|
||||||
|
runtime that does the zip work on a `spawn_blocking`
|
||||||
|
thread.
|
||||||
|
- Default output: `<data-root>/YYYYMMDD-<projectname>-export-NN.zip`
|
||||||
|
where `NN` is a non-clobbering two-digit sequence found by
|
||||||
|
`archive::next_export_sequence` — caps at 99 same-day
|
||||||
|
exports per project, returns
|
||||||
|
`ExportSequenceExhausted` past that.
|
||||||
|
- `export <path>` overrides the default. Relative paths
|
||||||
|
resolve under the active data root (the user's stated
|
||||||
|
preference: "use the data-dir as the target folder, so the
|
||||||
|
export file sits 'next to' the project folder, not inside
|
||||||
|
it"); absolute paths are used verbatim. Refuses if the
|
||||||
|
final zip path already exists.
|
||||||
|
- **Zip layout:** the project's directory name is preserved
|
||||||
|
as the single top-level folder inside the archive, so
|
||||||
|
`unzip foo.zip` produces `<projectname>/project.yaml` etc.
|
||||||
|
rather than scattering files. This is what makes the
|
||||||
|
round-trip `export → import` pleasant: the recipient
|
||||||
|
doesn't need to know the original name; it lives in the
|
||||||
|
zip's structure.
|
||||||
|
- **Excluded** from the zip: `playground.db`,
|
||||||
|
`history.log`, `.rdbms-playground.lock`, any `*.tmp`
|
||||||
|
files, any `project.yaml.v*.bak` files. `.gitignore` IS
|
||||||
|
included (sensible default for the recipient).
|
||||||
|
|
||||||
|
**`import <zip> [as <target>]` command:**
|
||||||
|
|
||||||
|
- Grammar: separator is the literal ` as ` (space-as-space),
|
||||||
|
so a zip path containing the substring "as" without
|
||||||
|
surrounding spaces is treated as a path, not a syntax
|
||||||
|
marker.
|
||||||
|
- Default target: the zip's single top-level folder
|
||||||
|
(verified by `archive::inspect_zip`). Relative `<target>`
|
||||||
|
resolves under `<data-root>/projects/`; absolute paths
|
||||||
|
used verbatim.
|
||||||
|
- **Collision behaviour:** if the resolved relative target
|
||||||
|
already exists, the basename auto-suffixes `-02`, `-03`, …
|
||||||
|
up to `-99`. This is a deliberate deviation from the §2
|
||||||
|
collision rule (which refuses), recorded as an amendment
|
||||||
|
to ADR-0015 §11. Rationale: round-tripping zips
|
||||||
|
(export → email → import → re-export → re-import) is a
|
||||||
|
normal workflow and forcing `as <target>` for every
|
||||||
|
collision is unnecessary friction. Absolute paths are NOT
|
||||||
|
auto-suffixed — the user's explicit `as <abs-path>` is
|
||||||
|
honored exactly or refused on collision.
|
||||||
|
- After unpack: the runtime opens the new project and runs
|
||||||
|
`rebuild_from_text` to materialize `playground.db` from
|
||||||
|
YAML+CSV. `history.log` starts empty (it was excluded
|
||||||
|
from the zip).
|
||||||
|
- Switches to operating on the new project via the existing
|
||||||
|
`perform_switch` + `SwitchRequest::Import` path, which
|
||||||
|
means the unmodified-temp cleanup machinery from
|
||||||
|
Iteration 4b also applies — the previous fresh-launch
|
||||||
|
temp gets auto-deleted via
|
||||||
|
`safely_delete_temp_project`.
|
||||||
|
|
||||||
|
**Path-traversal protection** in `archive::extract_into`:
|
||||||
|
|
||||||
|
- `entry.enclosed_name()` rejects `..` segments and
|
||||||
|
absolute paths.
|
||||||
|
- The resolved extraction path is re-validated to start
|
||||||
|
with `target_dir` (defence-in-depth).
|
||||||
|
- Top-folder match is enforced (the inspection step
|
||||||
|
recorded the single top-level folder; extraction refuses
|
||||||
|
any entry under a different top folder).
|
||||||
|
|
||||||
|
**Module:** `src/archive.rs` (new, ~480 lines + 11 unit
|
||||||
|
tests). Public API: `default_export_filename`,
|
||||||
|
`next_export_sequence`, `export_project`, `inspect_zip`,
|
||||||
|
`resolve_import_target`, `extract_into`, plus
|
||||||
|
`ZipInspection` and `ArchiveError`. Dep added: `zip = "5"`
|
||||||
|
with `default-features = false, features = ["deflate"]`.
|
||||||
|
|
||||||
|
### Iteration 6 — `--resume` + persistent input history +
|
||||||
|
### migration framework
|
||||||
|
|
||||||
|
**`--resume` CLI flag (L1a, ADR-0015 §7):**
|
||||||
|
|
||||||
|
- Reads `<data-root>/last_project` (a single-line file
|
||||||
|
containing the absolute project path).
|
||||||
|
- Mutually exclusive with a positional `<project-path>`
|
||||||
|
(`ArgsError::ResumeWithPath`).
|
||||||
|
- Errors cleanly via stderr (printed BEFORE the alternate
|
||||||
|
screen is entered, so the message lands directly above
|
||||||
|
the shell prompt) if:
|
||||||
|
- `last_project` is missing → "no previous project
|
||||||
|
recorded under …".
|
||||||
|
- `last_project` points at a path that no longer exists
|
||||||
|
→ "recorded project … no longer exists".
|
||||||
|
- No silent fallback to a fresh temp.
|
||||||
|
- `last_project` is rewritten on every successful project
|
||||||
|
open: startup (resume / positional path / fresh temp),
|
||||||
|
`load`, `new`, `save as`, `import`. Atomic write via
|
||||||
|
temp + rename.
|
||||||
|
- Helpers: `project::read_last_project`,
|
||||||
|
`project::write_last_project`. Both round-trip through
|
||||||
|
disk and handle the missing-data-root case (the runtime's
|
||||||
|
first launch).
|
||||||
|
|
||||||
|
**Persistent input history (I2-persist, ADR-0015 §12):**
|
||||||
|
|
||||||
|
- On project open (initial in `run()` and on every switch in
|
||||||
|
`handle_project_switch`), the in-memory navigable input
|
||||||
|
history is hydrated from the tail of the project's
|
||||||
|
`history.log`, capped at the same 1000-entry in-memory cap.
|
||||||
|
- `App::seed_history(entries: Vec<String>)` is the
|
||||||
|
hydration entry point; `Persistence::read_recent_history`
|
||||||
|
is the loader (calls `history::read_recent_sources`).
|
||||||
|
- The hydration is delivered through
|
||||||
|
`AppEvent::ProjectSwitched { history_entries, .. }` for
|
||||||
|
switch flows (since `App` is owned by `run_loop`); for
|
||||||
|
the startup flow it's called inline.
|
||||||
|
- Up/Down recall jumps to the most-recent seeded entry
|
||||||
|
first, matching the in-session navigation semantics.
|
||||||
|
- Format-tolerant parser: `<ts>|ok|<source>` lines are
|
||||||
|
parsed via `splitn(3, '|')` so pipes inside the source
|
||||||
|
are preserved; unknown escape sequences in the source
|
||||||
|
are passed through literally.
|
||||||
|
|
||||||
|
**Migration framework scaffold (F3, ADR-0015 §9):**
|
||||||
|
|
||||||
|
- New module `src/persistence/migrations.rs`.
|
||||||
|
- `MigratorRegistry` is an ordered list of `MigrateFn`
|
||||||
|
function pointers, indexed by source version.
|
||||||
|
`production()` returns an empty registry (latest_version
|
||||||
|
= 1). New versions register their migrators here.
|
||||||
|
- `migrate_to_latest(body, registry, project_path)`:
|
||||||
|
1. Reads the `version:` field via a tiny `serde_yml`
|
||||||
|
wire type (`VersionOnly { version: u32 }`).
|
||||||
|
2. If `file_version == latest`: returns body unchanged
|
||||||
|
with `migrated_from = None`.
|
||||||
|
3. If `file_version > latest`: errors out
|
||||||
|
(`NewerThanSupported`).
|
||||||
|
4. Otherwise: writes
|
||||||
|
`<project_path>/project.yaml.v<file_version>.bak`,
|
||||||
|
runs each migrator in sequence, and verifies each
|
||||||
|
step bumped the `version:` field (catches forgetful
|
||||||
|
migrators).
|
||||||
|
- `ensure_project_yaml_migrated(project_path, registry)`
|
||||||
|
is the runtime-facing wrapper that pairs migration with
|
||||||
|
the read/write IO.
|
||||||
|
- Wired into `runtime::run()` and
|
||||||
|
`runtime::perform_switch()` so every project open runs
|
||||||
|
through the (currently no-op) migration step before the
|
||||||
|
database opens.
|
||||||
|
- Tests inject a fake v1→v2 migrator to exercise the
|
||||||
|
registry plumbing, the `.bak` write, the
|
||||||
|
forgot-to-bump-version check, the
|
||||||
|
newer-than-supported guard, and a propagated migrator
|
||||||
|
error.
|
||||||
|
|
||||||
|
## ADR / docs updates
|
||||||
|
|
||||||
|
- **ADR-0015 §11** — amended to record the export zip
|
||||||
|
layout (top-level folder = project name) and the
|
||||||
|
import auto-suffix collision behaviour (deviates from
|
||||||
|
§2's refuse-on-collision rule for `save` / `save as`).
|
||||||
|
- **`docs/requirements.md`** — A1 / I2 / F3 / E1 / L1a
|
||||||
|
flipped to `[x]` with implementation notes; test
|
||||||
|
baseline updated to 408 passing.
|
||||||
|
- **`CLAUDE.md`** — not touched this session; the rules
|
||||||
|
are unchanged. The repo-layout map there is slightly
|
||||||
|
out-of-date (no mention of `archive.rs` or
|
||||||
|
`persistence/migrations.rs`) — a quick fix-up is fair
|
||||||
|
game for the next session.
|
||||||
|
|
||||||
|
## Repository layout (delta vs. handoff-2)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
archive.rs — new (Iteration 5)
|
||||||
|
persistence/
|
||||||
|
mod.rs — read_recent_history added
|
||||||
|
migrations.rs — new (Iteration 6 / F3)
|
||||||
|
project/
|
||||||
|
mod.rs — read_last_project +
|
||||||
|
write_last_project added
|
||||||
|
cli.rs — --resume flag + ResumeWithPath
|
||||||
|
error variant
|
||||||
|
app.rs — export/import dispatch in
|
||||||
|
submit(); seed_history;
|
||||||
|
ExportSucceeded/Failed event
|
||||||
|
handlers; ProjectSwitched
|
||||||
|
carries history_entries
|
||||||
|
action.rs — Action::Export, Action::Import
|
||||||
|
event.rs — AppEvent::ExportSucceeded /
|
||||||
|
ExportFailed; ProjectSwitched
|
||||||
|
+ history_entries
|
||||||
|
runtime.rs — spawn_export, do_export,
|
||||||
|
resolve_import_destination,
|
||||||
|
read_history_seed,
|
||||||
|
SwitchRequest::Import,
|
||||||
|
--resume / last_project /
|
||||||
|
migration wiring
|
||||||
|
tests/
|
||||||
|
iteration5_export_import.rs — new (Iteration 5)
|
||||||
|
iteration6_resume_history.rs — new (Iteration 6)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sharp edges and subtleties (delta vs. handoff-2)
|
||||||
|
|
||||||
|
The previous handoff's sharp edges all still apply. New ones:
|
||||||
|
|
||||||
|
- **`Action::Export` runs on a tokio `spawn_blocking` task,
|
||||||
|
not the db worker.** Export writes the zip directly from
|
||||||
|
disk; auto-save guarantees the project's text sources are
|
||||||
|
current. The `history.log` entry for the `export` command
|
||||||
|
is appended synchronously from the dispatching arm BEFORE
|
||||||
|
the spawn (so the user-issued command lands in history
|
||||||
|
even if the export task itself fails).
|
||||||
|
- **`SwitchRequest::Import` runs `inspect_zip` BEFORE
|
||||||
|
dropping the current project.** A failed inspection (zip
|
||||||
|
not a project, multiple top folders, traversal entry,
|
||||||
|
etc.) leaves the user where they were. The actual
|
||||||
|
extraction also runs before the drop. Only after
|
||||||
|
extraction succeeds do we drop and reopen.
|
||||||
|
- **`ProjectSwitched` is now a 3-field event.** Tests that
|
||||||
|
construct it directly need the extra `history_entries:
|
||||||
|
Vec::new()` field. Iteration-4b had one such test;
|
||||||
|
updated.
|
||||||
|
- **Migration runs inside `perform_switch` AFTER the lock
|
||||||
|
on the new project is acquired but BEFORE the database
|
||||||
|
opens.** Order matters: a migration that mutates
|
||||||
|
`project.yaml` while another process holds the lock
|
||||||
|
would corrupt the file; doing the migration after our
|
||||||
|
own lock is held prevents that.
|
||||||
|
- **`migrate_to_latest` writes the `.bak` BEFORE running
|
||||||
|
any migrator.** If a migrator panics or returns an
|
||||||
|
error mid-chain, the `.bak` is the only intact copy of
|
||||||
|
the original. The runtime currently does not auto-restore
|
||||||
|
on failure — that's part of "future work" once a real
|
||||||
|
migrator lands.
|
||||||
|
- **`--resume` errors print to stderr BEFORE the terminal
|
||||||
|
is set up.** If the user is debugging by reading
|
||||||
|
`--log-file`, the resume error is in the shell, not the
|
||||||
|
log.
|
||||||
|
- **`last_project` write failures are non-fatal** (logged
|
||||||
|
via `tracing::warn`). Rationale: a failed write here
|
||||||
|
surfaces on the *next* `--resume` attempt with a clear
|
||||||
|
message, which is preferable to refusing to launch the
|
||||||
|
app over a stat / chmod hiccup.
|
||||||
|
- **The `zip` crate features are restricted** to
|
||||||
|
`default-features = false, features = ["deflate"]` to
|
||||||
|
hold the binary-size cost down. A future cipher /
|
||||||
|
compression demand can revisit.
|
||||||
|
|
||||||
|
## Pending — proposed next moves (in order)
|
||||||
|
|
||||||
|
Track-2's iteration backlog is now empty; ADR-0015 ships
|
||||||
|
the runtime as designed. The remaining items are the
|
||||||
|
deferred features called out in handoff-2's "Other deferred
|
||||||
|
items" list:
|
||||||
|
|
||||||
|
### 1. Complex WHERE expressions (C5a)
|
||||||
|
|
||||||
|
AND/OR/comparison operators/LIKE in UPDATE/DELETE/show-data
|
||||||
|
filters. The natural progression from DSL fluency into
|
||||||
|
real SQL. Needs a small ADR for the operator subset.
|
||||||
|
|
||||||
|
### 2. Indexes (C3 partial) + EXPLAIN QUERY PLAN (QA1)
|
||||||
|
|
||||||
|
Strong teaching demo. `add index <name> on <T>(<col>)` /
|
||||||
|
`drop index <name>`, plus rendering the `EXPLAIN QUERY PLAN`
|
||||||
|
output as an annotated tree (QA2 covers the tree rendering
|
||||||
|
specifics in its own ADR).
|
||||||
|
|
||||||
|
### 3. Column drops/renames/type changes (B2 / C2 partial)
|
||||||
|
|
||||||
|
The `rebuild_table` primitive already exists (ADR-0013).
|
||||||
|
The grammar additions and metadata updates are
|
||||||
|
straightforward; the work is mostly tests covering the
|
||||||
|
data-preservation invariants.
|
||||||
|
|
||||||
|
### 4. Friendly error layer (H1)
|
||||||
|
|
||||||
|
Translate raw SQLite messages to learner-friendly
|
||||||
|
equivalents. Partial today (FK errors are enriched both
|
||||||
|
ways); full SQL → English translation is the open work.
|
||||||
|
|
||||||
|
### 5. `replay` (U4)
|
||||||
|
|
||||||
|
The `history.log` format is already replay-compatible.
|
||||||
|
`replay <path>` runs commands from a `history.log` or
|
||||||
|
`.commands` file. The framework lands here; the U-series
|
||||||
|
items (snapshot/undo/redo, ADR-0006) follow.
|
||||||
|
|
||||||
|
### 6. CI (TT5)
|
||||||
|
|
||||||
|
Test infrastructure is in place; the GitHub Actions
|
||||||
|
workflow file (or equivalent) is not.
|
||||||
|
|
||||||
|
### 7. Bigger UX work
|
||||||
|
|
||||||
|
V4 session log + Markdown export, S2 indexes in the items
|
||||||
|
list, V1/V2 pretty rendering, H1a strong syntax-help. All
|
||||||
|
have their entries in `docs/requirements.md` and remain
|
||||||
|
deferred behind their respective ADRs.
|
||||||
|
|
||||||
|
## How to take over
|
||||||
|
|
||||||
|
1. Read this file.
|
||||||
|
2. Read `CLAUDE.md` for the working-style rules.
|
||||||
|
3. Read `docs/requirements.md` for granular progress.
|
||||||
|
4. Skim `docs/adr/README.md`; read ADR-0015 in full
|
||||||
|
(especially §11 with the import-collision amendment)
|
||||||
|
if you'll touch the project storage runtime, the
|
||||||
|
archive module, or the migration framework.
|
||||||
|
5. Run `cargo test` to confirm the 408-test green
|
||||||
|
baseline.
|
||||||
|
6. `cargo run --release -- --help` to see the updated CLI
|
||||||
|
banner.
|
||||||
|
|
||||||
|
### End-to-end smoke test
|
||||||
|
|
||||||
|
Verifies export, import, --resume, and persistent history.
|
||||||
|
Same data-dir flag throughout so the test is contained.
|
||||||
|
|
||||||
|
```
|
||||||
|
# Set up: launch under a clean data dir.
|
||||||
|
$ rm -rf /tmp/rdbms-iter5-iter6-smoke
|
||||||
|
$ rdbms-playground --data-dir /tmp/rdbms-iter5-iter6-smoke
|
||||||
|
|
||||||
|
# Inside the app:
|
||||||
|
help -- new commands listed
|
||||||
|
create table Customers with pk id:serial
|
||||||
|
add column Customers: Name (text)
|
||||||
|
insert into Customers ('Alice')
|
||||||
|
insert into Customers ('Bob')
|
||||||
|
save -- name it "MyOrders"
|
||||||
|
export -- writes
|
||||||
|
-- /tmp/rdbms-iter5-iter6-smoke/
|
||||||
|
-- 20260508-MyOrders-export-01.zip
|
||||||
|
quit
|
||||||
|
|
||||||
|
# Verify the zip on disk:
|
||||||
|
$ unzip -l /tmp/rdbms-iter5-iter6-smoke/*.zip
|
||||||
|
# Should show:
|
||||||
|
# MyOrders/project.yaml
|
||||||
|
# MyOrders/data/Customers.csv
|
||||||
|
# MyOrders/.gitignore
|
||||||
|
# and NOT:
|
||||||
|
# MyOrders/playground.db
|
||||||
|
# MyOrders/history.log
|
||||||
|
|
||||||
|
# Re-open via --resume and verify history is hydrated:
|
||||||
|
$ rdbms-playground --data-dir /tmp/rdbms-iter5-iter6-smoke --resume
|
||||||
|
# Up arrow should walk back through the export, save,
|
||||||
|
# inserts, add column, create table — all from the previous
|
||||||
|
# session.
|
||||||
|
|
||||||
|
# Inside the app:
|
||||||
|
import /tmp/rdbms-iter5-iter6-smoke/20260508-MyOrders-export-01.zip
|
||||||
|
-- creates MyOrders-02
|
||||||
|
-- (auto-suffix because
|
||||||
|
-- MyOrders already exists),
|
||||||
|
-- switches to it,
|
||||||
|
-- rebuilds .db from text.
|
||||||
|
show data Customers -- 'Alice' and 'Bob' present.
|
||||||
|
quit
|
||||||
|
|
||||||
|
# Final clean-up:
|
||||||
|
$ rm -rf /tmp/rdbms-iter5-iter6-smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
If anything in that sequence fails, something is wrong.
|
||||||
|
|
||||||
|
### Manual spot-checks worth running
|
||||||
|
|
||||||
|
- `--resume` with a missing `last_project` → stderr message.
|
||||||
|
- `--resume` with a stale recorded path → stderr message.
|
||||||
|
- `--resume <path>` (combined with positional) →
|
||||||
|
`ResumeWithPath` error from arg parsing.
|
||||||
|
- `export` with no current data dir created yet (rare;
|
||||||
|
data root resolution still works).
|
||||||
|
- `import <zip-with-multiple-top-folders>` →
|
||||||
|
`MultipleTopFolders` error in the output panel.
|
||||||
|
- `import <random.zip>` (no project.yaml in it) →
|
||||||
|
`NotAProjectArchive` error.
|
||||||
|
- After the scaffold migration framework: hand-edit a
|
||||||
|
project's `project.yaml` to `version: 99`, restart →
|
||||||
|
`migrate project.yaml` context error in the run-time
|
||||||
|
startup error path. (Or: write a real v1→v2 migrator
|
||||||
|
and watch it execute.)
|
||||||
+41
-25
@@ -26,10 +26,10 @@ repo is pushed).
|
|||||||
|
|
||||||
## Test baseline
|
## Test baseline
|
||||||
|
|
||||||
No test suite exists yet — the repo currently contains only
|
After Iterations 5 + 6 (export/import + --resume + persistent
|
||||||
docs. The baseline is therefore "0 passing, 0 failing, 0
|
input history + migration scaffold): **408 passing, 0 failing,
|
||||||
skipped." Subsequent phases establish the suite and measure
|
0 skipped** (`cargo test`). Clippy clean with the nursery
|
||||||
against it.
|
lint group enabled.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -72,13 +72,12 @@ against it.
|
|||||||
keys (and for ergonomics in command-driven workflows). Likely
|
keys (and for ergonomics in command-driven workflows). Likely
|
||||||
followed by Ctrl-W (delete previous word), Ctrl-K (delete to
|
followed by Ctrl-W (delete previous word), Ctrl-K (delete to
|
||||||
end), Ctrl-U (delete to start). Pending.
|
end), Ctrl-U (delete to start). Pending.
|
||||||
- [ ] **I2** Persistent navigable input history (project-scoped,
|
- [x] **I2** Persistent navigable input history (project-scoped).
|
||||||
with a global rolling history also available).
|
*(Implemented across Iterations 2 + 6: per-command append to
|
||||||
*(Progress: in-memory navigable history is implemented; the
|
`history.log` (Iter 2); on project open, the in-memory
|
||||||
on-disk record is `history.log` (Iteration 2). What's still
|
navigable history is hydrated from the tail of
|
||||||
missing for I2 is hydrating the navigable history from
|
`history.log` up to the same in-memory cap (Iter 6). Global
|
||||||
`history.log` on project open — Iteration 6. Global rolling
|
rolling history is out of scope per OOS-6 / N4.)*
|
||||||
history deferred per OOS-6 / N4.)*
|
|
||||||
- [ ] **I3** Tab completion for app commands, DSL keywords, table
|
- [ ] **I3** Tab completion for app commands, DSL keywords, table
|
||||||
names, column names, and SQL keywords.
|
names, column names, and SQL keywords.
|
||||||
- [ ] **I4** Syntax highlighting for both the DSL and SQL.
|
- [ ] **I4** Syntax highlighting for both the DSL and SQL.
|
||||||
@@ -104,10 +103,10 @@ against it.
|
|||||||
`rebuild`, `export`, `import`, `seed`, `replay`, `undo`,
|
`rebuild`, `export`, `import`, `seed`, `replay`, `undo`,
|
||||||
`redo`, `mode`, `help`, `hint`, `quit`.
|
`redo`, `mode`, `help`, `hint`, `quit`.
|
||||||
*(Progress: `quit`/`q`, `mode simple|advanced`, `help` (basic
|
*(Progress: `quit`/`q`, `mode simple|advanced`, `help` (basic
|
||||||
listing), `save`, `save as`, `load`, `new`, `rebuild` all
|
listing), `save`, `save as`, `load`, `new`, `rebuild`,
|
||||||
implemented (Iteration 4). `export` / `import` land in track
|
`export`, `import` all implemented (Iterations 4 + 5). `seed`
|
||||||
2's Iteration 5; `seed` in the seeding iteration; `replay` /
|
in the seeding iteration; `replay` / `undo` / `redo` in the
|
||||||
`undo` / `redo` in the U-series; `hint` with H2.)*
|
U-series; `hint` with H2.)*
|
||||||
|
|
||||||
## DSL data commands
|
## DSL data commands
|
||||||
|
|
||||||
@@ -277,8 +276,16 @@ against it.
|
|||||||
in each new project (Iteration 1). Per ADR-0007 amendment
|
in each new project (Iteration 1). Per ADR-0007 amendment
|
||||||
1, `history.log` is NOT in the template — user decides
|
1, `history.log` is NOT in the template — user decides
|
||||||
whether to commit it.
|
whether to commit it.
|
||||||
- [ ] **F3** Migration framework — pending Iteration 6.
|
- [x] **F3** Migration framework scaffold (Iteration 6).
|
||||||
Scaffold (no migrators yet) is the v1 deliverable.
|
`MigratorRegistry` + `migrate_to_latest` +
|
||||||
|
`ensure_project_yaml_migrated` are wired into every project
|
||||||
|
open; no migrators registered in v1 (the production
|
||||||
|
registry is empty). The framework is exercised by tests
|
||||||
|
that inject a fake v1→v2 migrator: registry plumbing,
|
||||||
|
`.v<N>.bak` backup, version-bump sanity check, and
|
||||||
|
newer-than-supported / malformed-version errors are all
|
||||||
|
covered. The first real migrator (when v2 ships) is a
|
||||||
|
one-file change.
|
||||||
|
|
||||||
## Undo and replay (per ADR-0006)
|
## Undo and replay (per ADR-0006)
|
||||||
|
|
||||||
@@ -295,9 +302,15 @@ against it.
|
|||||||
|
|
||||||
## Sharing and export (per ADR-0007)
|
## Sharing and export (per ADR-0007)
|
||||||
|
|
||||||
- [ ] **E1** `export` produces a zip excluding `playground.db`;
|
- [x] **E1** `export` produces a zip excluding `playground.db`
|
||||||
default filename `YYYYMMDD-<projectname>-export-NN.zip` with a
|
AND `history.log` (per ADR-0007 amendment 1); default
|
||||||
non-clobbering two-digit sequence.
|
filename `YYYYMMDD-<projectname>-export-NN.zip` with a
|
||||||
|
non-clobbering two-digit sequence under the active data root
|
||||||
|
(Iteration 5). The zip preserves the project's directory
|
||||||
|
name as a single top-level folder. `import <zip> [as <t>]`
|
||||||
|
is the inverse: derive target name from the zip's top
|
||||||
|
folder, auto-suffix `-NN` on collision (ADR-0015 §11
|
||||||
|
amendment), rebuild from text on open.
|
||||||
- [ ] **E2** User documentation includes sharing recipes for
|
- [ ] **E2** User documentation includes sharing recipes for
|
||||||
git, email, and direct file transfer.
|
git, email, and direct file transfer.
|
||||||
|
|
||||||
@@ -347,11 +360,14 @@ against it.
|
|||||||
- [x] **L1** Load a project via a positional CLI argument
|
- [x] **L1** Load a project via a positional CLI argument
|
||||||
(Iteration 1). Plus `--data-dir` to override the data root
|
(Iteration 1). Plus `--data-dir` to override the data root
|
||||||
and `--help` / `-h` for the usage banner.
|
and `--help` / `-h` for the usage banner.
|
||||||
- [ ] **L1a** `--resume` CLI flag opens the most recently used
|
- [x] **L1a** `--resume` CLI flag opens the most recently used
|
||||||
project (path tracked in `<data-root>/last_project`). Errors
|
project (path tracked in `<data-root>/last_project`).
|
||||||
cleanly if no previous project exists or the recorded path is
|
Iteration 6: errors cleanly with a stderr banner above the
|
||||||
gone; mutually exclusive with a positional path argument
|
shell prompt if no previous project is recorded or the
|
||||||
(ADR-0015 §7). Pending Iteration 6.
|
recorded path is gone — no silent fallback; mutually
|
||||||
|
exclusive with a positional path argument (ADR-0015 §7).
|
||||||
|
`last_project` is rewritten on every successful project
|
||||||
|
open (startup, load, new, save as, import).
|
||||||
- [~] **L2** Submit a command alongside project load — deferred,
|
- [~] **L2** Submit a command alongside project load — deferred,
|
||||||
not v1.
|
not v1.
|
||||||
|
|
||||||
|
|||||||
+23
@@ -248,6 +248,27 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replace the in-memory navigable history with `entries`,
|
||||||
|
/// truncating to the in-memory cap.
|
||||||
|
///
|
||||||
|
/// Used by the runtime to hydrate from the project's
|
||||||
|
/// `history.log` on open (I2-persist, ADR-0015 §12).
|
||||||
|
/// Entries should arrive in chronological order (oldest
|
||||||
|
/// first); the most recent stays at the back, which is
|
||||||
|
/// where Up/Down navigation expects it.
|
||||||
|
///
|
||||||
|
/// Cancels any in-flight history navigation so a hydrate
|
||||||
|
/// during a session (e.g. after `load`) doesn't leave a
|
||||||
|
/// dangling cursor pointing at a now-removed entry.
|
||||||
|
pub fn seed_history(&mut self, entries: Vec<String>) {
|
||||||
|
self.history = entries;
|
||||||
|
while self.history.len() > HISTORY_CAPACITY {
|
||||||
|
self.history.remove(0);
|
||||||
|
}
|
||||||
|
self.history_cursor = None;
|
||||||
|
self.history_draft = None;
|
||||||
|
}
|
||||||
|
|
||||||
/// Effective mode for the *next* submission, given the
|
/// Effective mode for the *next* submission, given the
|
||||||
/// persistent mode and the current input buffer. See
|
/// persistent mode and the current input buffer. See
|
||||||
/// [`EffectiveMode`].
|
/// [`EffectiveMode`].
|
||||||
@@ -353,12 +374,14 @@ impl App {
|
|||||||
AppEvent::ProjectSwitched {
|
AppEvent::ProjectSwitched {
|
||||||
display_name,
|
display_name,
|
||||||
is_temp,
|
is_temp,
|
||||||
|
history_entries,
|
||||||
} => {
|
} => {
|
||||||
self.note_system(format!("[ok] now editing: {display_name}"));
|
self.note_system(format!("[ok] now editing: {display_name}"));
|
||||||
self.project_name = Some(display_name);
|
self.project_name = Some(display_name);
|
||||||
self.project_is_temp = is_temp;
|
self.project_is_temp = is_temp;
|
||||||
self.tables.clear();
|
self.tables.clear();
|
||||||
self.current_table = None;
|
self.current_table = None;
|
||||||
|
self.seed_history(history_entries);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
AppEvent::ProjectSwitchFailed { error } => {
|
AppEvent::ProjectSwitchFailed { error } => {
|
||||||
|
|||||||
+44
-1
@@ -18,8 +18,14 @@ pub struct Args {
|
|||||||
pub data_dir: Option<PathBuf>,
|
pub data_dir: Option<PathBuf>,
|
||||||
/// Positional path argument: open an existing project at
|
/// Positional path argument: open an existing project at
|
||||||
/// this path (L1, ADR-0015 §1). Mutually exclusive with
|
/// this path (L1, ADR-0015 §1). Mutually exclusive with
|
||||||
/// `--resume` once that lands.
|
/// `--resume`.
|
||||||
pub project_path: Option<PathBuf>,
|
pub project_path: Option<PathBuf>,
|
||||||
|
/// `--resume`: open the most-recently-used project at
|
||||||
|
/// startup (L1a, ADR-0015 §7). Reads the path from
|
||||||
|
/// `<data-root>/last_project`. Mutually exclusive with
|
||||||
|
/// `<project-path>` — supplying both is an error rather
|
||||||
|
/// than silently picking one.
|
||||||
|
pub resume: bool,
|
||||||
/// `--help` / `-h`: print usage to stdout and exit. The
|
/// `--help` / `-h`: print usage to stdout and exit. The
|
||||||
/// runtime checks this flag before doing any other work.
|
/// runtime checks this flag before doing any other work.
|
||||||
pub help: bool,
|
pub help: bool,
|
||||||
@@ -44,6 +50,11 @@ Options:
|
|||||||
--data-dir <PATH> Use PATH as the data root instead of
|
--data-dir <PATH> Use PATH as the data root instead of
|
||||||
the OS-standard location for this run.
|
the OS-standard location for this run.
|
||||||
--log-file <PATH> Write tracing output to PATH.
|
--log-file <PATH> Write tracing output to PATH.
|
||||||
|
--resume Open the most-recently-used project
|
||||||
|
(path tracked under <data-root>/last_project).
|
||||||
|
Errors out if no previous project is
|
||||||
|
recorded. Mutually exclusive with
|
||||||
|
<project-path>.
|
||||||
|
|
||||||
App-level commands (typed inside the app, available in both modes):
|
App-level commands (typed inside the app, available in both modes):
|
||||||
quit / q Exit cleanly.
|
quit / q Exit cleanly.
|
||||||
@@ -80,6 +91,11 @@ pub enum ArgsError {
|
|||||||
Unknown(String),
|
Unknown(String),
|
||||||
#[error("only one project path may be supplied; got both `{first}` and `{second}`")]
|
#[error("only one project path may be supplied; got both `{first}` and `{second}`")]
|
||||||
MultiplePaths { first: String, second: String },
|
MultiplePaths { first: String, second: String },
|
||||||
|
#[error(
|
||||||
|
"--resume and a positional <project-path> are mutually exclusive; \
|
||||||
|
pass one or the other"
|
||||||
|
)]
|
||||||
|
ResumeWithPath,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Args {
|
impl Args {
|
||||||
@@ -98,6 +114,7 @@ impl Args {
|
|||||||
let mut log_path = env::var_os("RDBMS_PLAYGROUND_LOG_FILE").map(PathBuf::from);
|
let mut log_path = env::var_os("RDBMS_PLAYGROUND_LOG_FILE").map(PathBuf::from);
|
||||||
let mut data_dir: Option<PathBuf> = None;
|
let mut data_dir: Option<PathBuf> = None;
|
||||||
let mut project_path: Option<PathBuf> = None;
|
let mut project_path: Option<PathBuf> = None;
|
||||||
|
let mut resume = false;
|
||||||
let mut help = false;
|
let mut help = false;
|
||||||
let mut iter = iter.into_iter().map(Into::into);
|
let mut iter = iter.into_iter().map(Into::into);
|
||||||
while let Some(arg) = iter.next() {
|
while let Some(arg) = iter.next() {
|
||||||
@@ -105,6 +122,9 @@ impl Args {
|
|||||||
"--help" | "-h" => {
|
"--help" | "-h" => {
|
||||||
help = true;
|
help = true;
|
||||||
}
|
}
|
||||||
|
"--resume" => {
|
||||||
|
resume = true;
|
||||||
|
}
|
||||||
"--theme" => {
|
"--theme" => {
|
||||||
let value = iter.next().ok_or(ArgsError::MissingValue("theme"))?;
|
let value = iter.next().ok_or(ArgsError::MissingValue("theme"))?;
|
||||||
theme = match value.as_str() {
|
theme = match value.as_str() {
|
||||||
@@ -141,11 +161,15 @@ impl Args {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if resume && project_path.is_some() {
|
||||||
|
return Err(ArgsError::ResumeWithPath);
|
||||||
|
}
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
theme,
|
theme,
|
||||||
log_path,
|
log_path,
|
||||||
data_dir,
|
data_dir,
|
||||||
project_path,
|
project_path,
|
||||||
|
resume,
|
||||||
help,
|
help,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -264,6 +288,25 @@ mod tests {
|
|||||||
assert!(args.help);
|
assert!(args.help);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resume_flag_parses() {
|
||||||
|
let args = Args::parse(["--resume"]).unwrap();
|
||||||
|
assert!(args.resume);
|
||||||
|
assert!(args.project_path.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resume_with_positional_path_errors() {
|
||||||
|
let err = Args::parse(["--resume", "/some/path"]).unwrap_err();
|
||||||
|
assert!(matches!(err, ArgsError::ResumeWithPath), "got: {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn positional_path_with_resume_errors_in_either_order() {
|
||||||
|
let err = Args::parse(["/some/path", "--resume"]).unwrap_err();
|
||||||
|
assert!(matches!(err, ArgsError::ResumeWithPath), "got: {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_double_dash_flag_errors_even_with_positional() {
|
fn unknown_double_dash_flag_errors_even_with_positional() {
|
||||||
// Make sure the path-vs-flag distinction is robust:
|
// Make sure the path-vs-flag distinction is robust:
|
||||||
|
|||||||
+5
-2
@@ -78,11 +78,14 @@ pub enum AppEvent {
|
|||||||
entries: Vec<crate::app::LoadPickerEntry>,
|
entries: Vec<crate::app::LoadPickerEntry>,
|
||||||
},
|
},
|
||||||
/// A project switch (load / new / save-as / import)
|
/// A project switch (load / new / save-as / import)
|
||||||
/// succeeded. Carries the new display name + temp flag
|
/// succeeded. Carries the new display name, the temp
|
||||||
/// so App can update the status bar.
|
/// flag (drives the `[TEMP]` status-bar prefix), and the
|
||||||
|
/// seed entries for input-history hydration off the new
|
||||||
|
/// project's `history.log` (I2-persist, ADR-0015 §12).
|
||||||
ProjectSwitched {
|
ProjectSwitched {
|
||||||
display_name: String,
|
display_name: String,
|
||||||
is_temp: bool,
|
is_temp: bool,
|
||||||
|
history_entries: Vec<String>,
|
||||||
},
|
},
|
||||||
/// A project switch failed in a non-fatal way (target
|
/// A project switch failed in a non-fatal way (target
|
||||||
/// already exists, path unreadable, …). Surfaced as an
|
/// already exists, path unreadable, …). Surfaced as an
|
||||||
|
|||||||
@@ -28,6 +28,92 @@ pub(super) fn format_record(command_text: &str, timestamp_iso: String) -> String
|
|||||||
format!("{timestamp_iso}|ok|{escaped}\n")
|
format!("{timestamp_iso}|ok|{escaped}\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the most-recent `max_n` user-issued command sources
|
||||||
|
/// from `history_log_path`, in chronological order
|
||||||
|
/// (oldest-first within the returned slice).
|
||||||
|
///
|
||||||
|
/// This is the I2-persist hydration helper (ADR-0015 §12):
|
||||||
|
/// on project open, the runtime seeds the in-memory navigable
|
||||||
|
/// history from this list so Up/Down recall picks up where
|
||||||
|
/// the user left off in the previous session.
|
||||||
|
///
|
||||||
|
/// Lines that do not match the `<ts>|<status>|<source>` shape
|
||||||
|
/// are silently skipped — they are likely corruption or a
|
||||||
|
/// future format extension; either way, refusing to seed at
|
||||||
|
/// all because of a single bad line would be a worse UX than
|
||||||
|
/// quietly rejoining the user's history.
|
||||||
|
///
|
||||||
|
/// A missing file returns an empty `Vec`; other IO errors
|
||||||
|
/// are surfaced via `PersistenceError` so the caller can
|
||||||
|
/// decide how to handle them. In practice the runtime treats
|
||||||
|
/// hydration failures as non-fatal — the user just gets an
|
||||||
|
/// empty history and a tracing warning.
|
||||||
|
pub(super) fn read_recent_sources(
|
||||||
|
history_log_path: &std::path::Path,
|
||||||
|
max_n: usize,
|
||||||
|
) -> Result<Vec<String>, super::PersistenceError> {
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
let body = match std::fs::read_to_string(history_log_path) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
|
||||||
|
Err(source) => {
|
||||||
|
return Err(super::PersistenceError::Io {
|
||||||
|
operation: "read",
|
||||||
|
path: history_log_path.to_path_buf(),
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut sources: Vec<String> = body
|
||||||
|
.lines()
|
||||||
|
.filter_map(parse_record_source)
|
||||||
|
.collect();
|
||||||
|
if sources.len() > max_n {
|
||||||
|
let skip = sources.len() - max_n;
|
||||||
|
sources.drain(0..skip);
|
||||||
|
}
|
||||||
|
Ok(sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse one `<ts>|<status>|<source>` line and return the
|
||||||
|
/// unescaped source. Returns `None` for malformed lines.
|
||||||
|
fn parse_record_source(line: &str) -> Option<String> {
|
||||||
|
// Format: timestamp|status|source-with-pipes-allowed
|
||||||
|
// We split into at most 3 parts so a `|` inside source
|
||||||
|
// (which append() does NOT escape — pipes are valid SQL
|
||||||
|
// characters) is preserved.
|
||||||
|
let mut parts = line.splitn(3, '|');
|
||||||
|
let _ts = parts.next()?;
|
||||||
|
let _status = parts.next()?;
|
||||||
|
let source = parts.next()?;
|
||||||
|
Some(unescape_command(source))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unescape_command(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
let mut chars = s.chars();
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
if c != '\\' {
|
||||||
|
out.push(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match chars.next() {
|
||||||
|
Some('n') => out.push('\n'),
|
||||||
|
Some('r') => out.push('\r'),
|
||||||
|
Some('\\') => out.push('\\'),
|
||||||
|
// Preserve unknown escapes literally so a future
|
||||||
|
// extension to `escape_command` doesn't corrupt
|
||||||
|
// entries written before that extension.
|
||||||
|
Some(other) => {
|
||||||
|
out.push('\\');
|
||||||
|
out.push(other);
|
||||||
|
}
|
||||||
|
None => out.push('\\'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Append `line` (which already ends in `\n`) to the file at
|
/// Append `line` (which already ends in `\n`) to the file at
|
||||||
/// `path`. Creates the file if it doesn't exist. fsyncs after
|
/// `path`. Creates the file if it doesn't exist. fsyncs after
|
||||||
/// the write so a power-cut doesn't lose the latest entry.
|
/// the write so a power-cut doesn't lose the latest entry.
|
||||||
@@ -147,6 +233,65 @@ mod tests {
|
|||||||
assert_eq!(iso8601_from_unix_secs(1_778_112_000), "2026-05-07T00:00:00Z");
|
assert_eq!(iso8601_from_unix_secs(1_778_112_000), "2026-05-07T00:00:00Z");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_recent_sources_returns_empty_when_file_missing() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("nope.log");
|
||||||
|
let got = read_recent_sources(&path, 10).unwrap();
|
||||||
|
assert!(got.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_recent_sources_unescapes_newlines_and_backslashes() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("history.log");
|
||||||
|
let line1 = format_record("a\nb", "T1".to_string());
|
||||||
|
let line2 = format_record("c\\d", "T2".to_string());
|
||||||
|
std::fs::write(&path, format!("{line1}{line2}")).unwrap();
|
||||||
|
let got = read_recent_sources(&path, 10).unwrap();
|
||||||
|
assert_eq!(got, vec!["a\nb".to_string(), "c\\d".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_recent_sources_caps_at_max_n_keeping_most_recent() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("history.log");
|
||||||
|
let body: String = (0..10)
|
||||||
|
.map(|i| format_record(&format!("cmd{i}"), format!("T{i}")))
|
||||||
|
.collect();
|
||||||
|
std::fs::write(&path, body).unwrap();
|
||||||
|
let got = read_recent_sources(&path, 3).unwrap();
|
||||||
|
assert_eq!(got, vec!["cmd7".to_string(), "cmd8".to_string(), "cmd9".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_recent_sources_skips_malformed_lines() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("history.log");
|
||||||
|
// Two valid lines and one garbage line in the middle.
|
||||||
|
let body = format!(
|
||||||
|
"{}{}{}",
|
||||||
|
format_record("good1", "T1".to_string()),
|
||||||
|
"this is not a record\n",
|
||||||
|
format_record("good2", "T2".to_string()),
|
||||||
|
);
|
||||||
|
std::fs::write(&path, body).unwrap();
|
||||||
|
let got = read_recent_sources(&path, 10).unwrap();
|
||||||
|
assert_eq!(got, vec!["good1".to_string(), "good2".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_recent_sources_preserves_pipes_inside_source() {
|
||||||
|
// The append-side does NOT escape `|`, so pipes inside
|
||||||
|
// the source must round-trip through the parser. This
|
||||||
|
// is what splitn(3) on `|` is supposed to handle.
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let path = dir.path().join("history.log");
|
||||||
|
std::fs::write(&path, "T1|ok|select 'a|b' from t\n").unwrap();
|
||||||
|
let got = read_recent_sources(&path, 10).unwrap();
|
||||||
|
assert_eq!(got, vec!["select 'a|b' from t".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn append_creates_and_grows_file() {
|
fn append_creates_and_grows_file() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,428 @@
|
|||||||
|
//! Migration framework scaffold (Iteration 6, ADR-0015 §9 /
|
||||||
|
//! requirement F3).
|
||||||
|
//!
|
||||||
|
//! The shape lands in v1 even though no migrator is
|
||||||
|
//! registered: the cost is small, the wiring is non-trivial,
|
||||||
|
//! and shipping the framework now lets the *first* real
|
||||||
|
//! migrator (v1 → v2, when that lands) be a tightly scoped
|
||||||
|
//! one-file change rather than "design migrations + write a
|
||||||
|
//! migrator + integrate."
|
||||||
|
//!
|
||||||
|
//! Public surface:
|
||||||
|
//!
|
||||||
|
//! - [`MigratorRegistry`] — ordered list of `MigrateFn`s, one
|
||||||
|
//! per source version. Tests inject their own registries; the
|
||||||
|
//! production-default registry is empty.
|
||||||
|
//! - [`migrate_to_latest`] — given a YAML body and a registry,
|
||||||
|
//! detect the source version, run each migrator in
|
||||||
|
//! sequence, and return the upgraded body. Writes the
|
||||||
|
//! pre-migration body to `project.yaml.v<N>.bak` inside the
|
||||||
|
//! project (a recovery aid; the .gitignore template excludes
|
||||||
|
//! `project.yaml.v*.bak` so backups don't leak into git).
|
||||||
|
//!
|
||||||
|
//! What this does NOT do:
|
||||||
|
//!
|
||||||
|
//! - It does not write `project.yaml` itself. The runtime is
|
||||||
|
//! responsible for atomically writing the upgraded body back.
|
||||||
|
//! Keeping the migration step separate from the write step
|
||||||
|
//! makes the order of operations explicit at the call site
|
||||||
|
//! and trivially testable: pass in a body, get back a body.
|
||||||
|
//! - It does not parse anything beyond the leading `version:`
|
||||||
|
//! line. Full schema parsing is `yaml::parse_schema`'s job
|
||||||
|
//! and runs *after* migration so the parser only ever sees
|
||||||
|
//! the latest format.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
/// A pure migrator: takes a YAML body at version `N` and
|
||||||
|
/// returns the same project at version `N + 1`.
|
||||||
|
///
|
||||||
|
/// Migrators must not perform I/O. The framework is
|
||||||
|
/// responsible for the .bak copy and the write-back; the
|
||||||
|
/// migrator's job is purely the format transformation.
|
||||||
|
pub type MigrateFn = fn(&str) -> Result<String, MigrateError>;
|
||||||
|
|
||||||
|
/// Ordered list of migrators. `migrators[i]` runs from
|
||||||
|
/// version `i + 1` to version `i + 2` (so index 0 is v1→v2,
|
||||||
|
/// index 1 is v2→v3, etc.).
|
||||||
|
///
|
||||||
|
/// `latest_version()` is `1 + migrators.len()`. In v1 the
|
||||||
|
/// list is empty and `latest_version()` is `1`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MigratorRegistry {
|
||||||
|
pub migrators: Vec<MigrateFn>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MigratorRegistry {
|
||||||
|
/// Production-default registry: empty. As new versions
|
||||||
|
/// land, register the migrators here in source-version
|
||||||
|
/// order.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn production() -> Self {
|
||||||
|
Self {
|
||||||
|
migrators: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The newest schema version this build understands.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn latest_version(&self) -> u32 {
|
||||||
|
1u32.saturating_add(self.migrators.len() as u32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MigratorRegistry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::production()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum MigrateError {
|
||||||
|
#[error("could not read version field from project.yaml: {0}")]
|
||||||
|
VersionParse(String),
|
||||||
|
#[error(
|
||||||
|
"project.yaml is at version {file} but this build only understands \
|
||||||
|
up to version {latest}; upgrade the application or restore an \
|
||||||
|
older project.yaml"
|
||||||
|
)]
|
||||||
|
NewerThanSupported { file: u32, latest: u32 },
|
||||||
|
#[error(
|
||||||
|
"no migrator registered for version {0} (programmer error: \
|
||||||
|
registry latest_version disagrees with migrators length)"
|
||||||
|
)]
|
||||||
|
NoMigratorForVersion(u32),
|
||||||
|
#[error("migrator from v{from} to v{to} failed: {source}")]
|
||||||
|
StepFailed {
|
||||||
|
from: u32,
|
||||||
|
to: u32,
|
||||||
|
source: Box<Self>,
|
||||||
|
},
|
||||||
|
#[error("migrator produced an unparseable result: {0}")]
|
||||||
|
BadOutput(String),
|
||||||
|
#[error("io error during migration on `{}`: {source}", path.display())]
|
||||||
|
Io {
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
#[source]
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of running [`migrate_to_latest`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct MigrationOutcome {
|
||||||
|
/// The upgraded body. When no migration was needed this
|
||||||
|
/// is identical to the input body.
|
||||||
|
pub body: String,
|
||||||
|
/// Source version found in the input. `None` if the
|
||||||
|
/// input parsed but its version equals `latest_version`
|
||||||
|
/// (no migration ran); `Some(N)` if a migration ran from
|
||||||
|
/// `N` to `latest_version`.
|
||||||
|
pub migrated_from: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect the version of `body` and migrate it to the
|
||||||
|
/// registry's `latest_version()`.
|
||||||
|
///
|
||||||
|
/// If the body is already at the latest version, returns the
|
||||||
|
/// body unchanged with `migrated_from = None`. Otherwise:
|
||||||
|
///
|
||||||
|
/// 1. Writes `<project_path>/project.yaml.v<N>.bak` with the
|
||||||
|
/// original body (so the recovery aid is in place before
|
||||||
|
/// we start mutating).
|
||||||
|
/// 2. Runs each registered migrator in sequence from
|
||||||
|
/// `file_version` to `latest_version`.
|
||||||
|
/// 3. Returns the upgraded body for the caller to write back.
|
||||||
|
///
|
||||||
|
/// A future-version body (file_version > latest_version)
|
||||||
|
/// errors out — older builds shouldn't try to interpret
|
||||||
|
/// newer formats they don't understand.
|
||||||
|
pub fn migrate_to_latest(
|
||||||
|
body: &str,
|
||||||
|
registry: &MigratorRegistry,
|
||||||
|
project_path: &Path,
|
||||||
|
) -> Result<MigrationOutcome, MigrateError> {
|
||||||
|
let file_version = read_version(body)?;
|
||||||
|
let latest = registry.latest_version();
|
||||||
|
if file_version == latest {
|
||||||
|
return Ok(MigrationOutcome {
|
||||||
|
body: body.to_string(),
|
||||||
|
migrated_from: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if file_version > latest {
|
||||||
|
return Err(MigrateError::NewerThanSupported {
|
||||||
|
file: file_version,
|
||||||
|
latest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the .bak before any transformation runs so a
|
||||||
|
// mid-migration crash leaves the original recoverable.
|
||||||
|
let bak_path =
|
||||||
|
project_path.join(format!("{}.v{}.bak", crate::project::PROJECT_YAML, file_version));
|
||||||
|
std::fs::write(&bak_path, body).map_err(|source| MigrateError::Io {
|
||||||
|
path: bak_path.clone(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut current_body = body.to_string();
|
||||||
|
for v in file_version..latest {
|
||||||
|
let idx = (v - 1) as usize;
|
||||||
|
let migrator = registry
|
||||||
|
.migrators
|
||||||
|
.get(idx)
|
||||||
|
.ok_or(MigrateError::NoMigratorForVersion(v))?;
|
||||||
|
let next_body = migrator(¤t_body).map_err(|e| MigrateError::StepFailed {
|
||||||
|
from: v,
|
||||||
|
to: v + 1,
|
||||||
|
source: Box::new(e),
|
||||||
|
})?;
|
||||||
|
// Sanity: the new body must declare the next version.
|
||||||
|
// If a migrator forgets to bump, we'd loop endlessly
|
||||||
|
// through the chain — catch it here.
|
||||||
|
let advertised = read_version(&next_body)
|
||||||
|
.map_err(|e| MigrateError::BadOutput(e.to_string()))?;
|
||||||
|
if advertised != v + 1 {
|
||||||
|
return Err(MigrateError::BadOutput(format!(
|
||||||
|
"v{v}→v{} migrator left version field at {advertised}",
|
||||||
|
v + 1,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
current_body = next_body;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(MigrationOutcome {
|
||||||
|
body: current_body,
|
||||||
|
migrated_from: Some(file_version),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure the `project.yaml` at `project_path` is migrated
|
||||||
|
/// to the registry's latest version, writing the upgraded
|
||||||
|
/// body back to disk if a migration ran.
|
||||||
|
///
|
||||||
|
/// Convenience wrapper that pairs [`migrate_to_latest`] with
|
||||||
|
/// the read/write IO. Used by the runtime on every project
|
||||||
|
/// open (before the rebuild path or DB-existence check
|
||||||
|
/// touches anything).
|
||||||
|
///
|
||||||
|
/// A missing `project.yaml` is `Ok(MigrationOutcome { body:
|
||||||
|
/// "", migrated_from: None })` — a brand-new project that
|
||||||
|
/// the skeleton hasn't filled in yet falls into this branch
|
||||||
|
/// and is left alone.
|
||||||
|
pub fn ensure_project_yaml_migrated(
|
||||||
|
project_path: &Path,
|
||||||
|
registry: &MigratorRegistry,
|
||||||
|
) -> Result<MigrationOutcome, MigrateError> {
|
||||||
|
let yaml_path = project_path.join(crate::project::PROJECT_YAML);
|
||||||
|
let body = match std::fs::read_to_string(&yaml_path) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
return Ok(MigrationOutcome {
|
||||||
|
body: String::new(),
|
||||||
|
migrated_from: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(source) => {
|
||||||
|
return Err(MigrateError::Io {
|
||||||
|
path: yaml_path,
|
||||||
|
source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let outcome = migrate_to_latest(&body, registry, project_path)?;
|
||||||
|
if outcome.migrated_from.is_some() {
|
||||||
|
std::fs::write(&yaml_path, &outcome.body).map_err(|source| MigrateError::Io {
|
||||||
|
path: yaml_path,
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Ok(outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract just the `version:` field from a YAML body,
|
||||||
|
/// without parsing the rest of the document.
|
||||||
|
fn read_version(body: &str) -> Result<u32, MigrateError> {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct VersionOnly {
|
||||||
|
version: u32,
|
||||||
|
}
|
||||||
|
let v: VersionOnly = serde_yml::from_str(body).map_err(|e| {
|
||||||
|
MigrateError::VersionParse(e.to_string())
|
||||||
|
})?;
|
||||||
|
Ok(v.version)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn tempdir() -> tempfile::TempDir {
|
||||||
|
tempfile::tempdir().expect("create tempdir")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn v1_body() -> String {
|
||||||
|
"version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n"
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn production_registry_latest_version_is_1() {
|
||||||
|
let r = MigratorRegistry::production();
|
||||||
|
assert_eq!(r.latest_version(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_migration_runs_when_body_already_latest() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
let outcome = migrate_to_latest(
|
||||||
|
&v1_body(),
|
||||||
|
&MigratorRegistry::production(),
|
||||||
|
tmp.path(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(outcome.body, v1_body());
|
||||||
|
assert_eq!(outcome.migrated_from, None);
|
||||||
|
// No .bak written when nothing migrated.
|
||||||
|
let bak = tmp.path().join("project.yaml.v1.bak");
|
||||||
|
assert!(!bak.exists(), "no .bak when no migration");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn newer_than_supported_errors() {
|
||||||
|
let body = "version: 99\nproject:\n created_at: x\n";
|
||||||
|
let err = migrate_to_latest(body, &MigratorRegistry::production(), Path::new("/tmp"))
|
||||||
|
.expect_err("must reject");
|
||||||
|
assert!(
|
||||||
|
matches!(err, MigrateError::NewerThanSupported { file: 99, latest: 1 }),
|
||||||
|
"got: {err:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_version_errors() {
|
||||||
|
let body = "tables: []\n";
|
||||||
|
let err = migrate_to_latest(body, &MigratorRegistry::production(), Path::new("/tmp"))
|
||||||
|
.expect_err("must reject");
|
||||||
|
assert!(matches!(err, MigrateError::VersionParse(_)), "got: {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Exercise the framework with a fake v1→v2 migrator
|
||||||
|
// so we know the chain runs even without a real one. ---
|
||||||
|
|
||||||
|
fn fake_v1_to_v2(body: &str) -> Result<String, MigrateError> {
|
||||||
|
// Trivial transformation: bump the version number in
|
||||||
|
// place. Exercises the registry plumbing without
|
||||||
|
// committing to any real schema change.
|
||||||
|
Ok(body.replace("version: 1", "version: 2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry_with_v1_to_v2() -> MigratorRegistry {
|
||||||
|
MigratorRegistry {
|
||||||
|
migrators: vec![fake_v1_to_v2 as MigrateFn],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registry_with_one_migrator_advertises_latest_version_2() {
|
||||||
|
let r = registry_with_v1_to_v2();
|
||||||
|
assert_eq!(r.latest_version(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migrate_runs_chain_and_writes_bak() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
let outcome = migrate_to_latest(
|
||||||
|
&v1_body(),
|
||||||
|
®istry_with_v1_to_v2(),
|
||||||
|
tmp.path(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(outcome.migrated_from, Some(1));
|
||||||
|
assert!(outcome.body.contains("version: 2"));
|
||||||
|
let bak = tmp.path().join("project.yaml.v1.bak");
|
||||||
|
assert!(bak.exists(), "expected .v1.bak to be written");
|
||||||
|
let bak_body = std::fs::read_to_string(&bak).unwrap();
|
||||||
|
assert!(bak_body.contains("version: 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migrator_that_forgets_to_bump_version_is_caught() {
|
||||||
|
let bad: MigrateFn = |body| Ok(body.to_string()); // no change
|
||||||
|
let registry = MigratorRegistry {
|
||||||
|
migrators: vec![bad],
|
||||||
|
};
|
||||||
|
let tmp = tempdir();
|
||||||
|
let err = migrate_to_latest(&v1_body(), ®istry, tmp.path()).expect_err("must fail");
|
||||||
|
assert!(matches!(err, MigrateError::BadOutput(_)), "got: {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_yaml_migrated_no_op_on_v1_with_empty_registry() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
let yaml_path = tmp.path().join("project.yaml");
|
||||||
|
std::fs::write(&yaml_path, v1_body()).unwrap();
|
||||||
|
let outcome = ensure_project_yaml_migrated(
|
||||||
|
tmp.path(),
|
||||||
|
&MigratorRegistry::production(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(outcome.migrated_from, None);
|
||||||
|
// File unchanged.
|
||||||
|
let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
|
||||||
|
assert_eq!(on_disk, v1_body());
|
||||||
|
assert!(!tmp.path().join("project.yaml.v1.bak").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_yaml_migrated_writes_upgraded_body_and_bak() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
let yaml_path = tmp.path().join("project.yaml");
|
||||||
|
std::fs::write(&yaml_path, v1_body()).unwrap();
|
||||||
|
let outcome = ensure_project_yaml_migrated(
|
||||||
|
tmp.path(),
|
||||||
|
®istry_with_v1_to_v2(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(outcome.migrated_from, Some(1));
|
||||||
|
let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
|
||||||
|
assert!(on_disk.contains("version: 2"), "got: {on_disk}");
|
||||||
|
let bak = tmp.path().join("project.yaml.v1.bak");
|
||||||
|
assert!(bak.exists());
|
||||||
|
assert!(std::fs::read_to_string(&bak).unwrap().contains("version: 1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ensure_yaml_migrated_handles_missing_yaml() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
// No project.yaml exists.
|
||||||
|
let outcome = ensure_project_yaml_migrated(
|
||||||
|
tmp.path(),
|
||||||
|
&MigratorRegistry::production(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(outcome.migrated_from, None);
|
||||||
|
assert!(outcome.body.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn migrator_that_returns_internal_error_propagates() {
|
||||||
|
let bad: MigrateFn =
|
||||||
|
|_| Err(MigrateError::VersionParse("simulated".to_string()));
|
||||||
|
let registry = MigratorRegistry {
|
||||||
|
migrators: vec![bad],
|
||||||
|
};
|
||||||
|
let tmp = tempdir();
|
||||||
|
let err = migrate_to_latest(&v1_body(), ®istry, tmp.path()).expect_err("must fail");
|
||||||
|
match err {
|
||||||
|
MigrateError::StepFailed { from, to, .. } => {
|
||||||
|
assert_eq!(from, 1);
|
||||||
|
assert_eq!(to, 2);
|
||||||
|
}
|
||||||
|
other => panic!("expected StepFailed, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ use crate::project::{DATA_DIR, HISTORY_LOG, PROJECT_YAML};
|
|||||||
// during rebuild (ADR-0015 §7) are re-exported below.
|
// during rebuild (ADR-0015 §7) are re-exported below.
|
||||||
mod csv_io;
|
mod csv_io;
|
||||||
mod history;
|
mod history;
|
||||||
|
pub mod migrations;
|
||||||
mod yaml;
|
mod yaml;
|
||||||
|
|
||||||
pub(crate) use csv_io::{decode_cell, parse_csv};
|
pub(crate) use csv_io::{decode_cell, parse_csv};
|
||||||
@@ -212,6 +213,15 @@ impl Persistence {
|
|||||||
let line = history::format_record(command_text, history::utc_iso8601_now());
|
let line = history::format_record(command_text, history::utc_iso8601_now());
|
||||||
history::append(&path, &line)
|
history::append(&path, &line)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the most-recent `max_n` sources out of
|
||||||
|
/// `history.log` for input-history hydration on project
|
||||||
|
/// open (ADR-0015 §12). Returned in chronological order
|
||||||
|
/// (oldest first). A missing file is `Ok(Vec::new())`.
|
||||||
|
pub fn read_recent_history(&self, max_n: usize) -> Result<Vec<String>, PersistenceError> {
|
||||||
|
let path = self.project_path.join(HISTORY_LOG);
|
||||||
|
history::read_recent_sources(&path, max_n)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write `body` to `path` atomically via temp file + fsync +
|
/// Write `body` to `path` atomically via temp file + fsync +
|
||||||
|
|||||||
+87
-3
@@ -40,11 +40,61 @@ pub const PROJECTS_SUBDIR: &str = "projects";
|
|||||||
/// State file under the data root used by `--resume`.
|
/// State file under the data root used by `--resume`.
|
||||||
///
|
///
|
||||||
/// Records the absolute path of the most-recently-opened
|
/// Records the absolute path of the most-recently-opened
|
||||||
/// project (Iteration 6, ADR-0015 §7). Iteration 1 doesn't
|
/// project (Iteration 6, ADR-0015 §7). The runtime writes
|
||||||
/// read or write it yet; defining the constant now keeps
|
/// it on every successful project open and reads it when
|
||||||
/// related code colocated.
|
/// `--resume` is passed; a clean exit deliberately leaves
|
||||||
|
/// it intact (the whole point is to reopen "what I had").
|
||||||
pub const LAST_PROJECT_FILE: &str = "last_project";
|
pub const LAST_PROJECT_FILE: &str = "last_project";
|
||||||
|
|
||||||
|
/// Read the recorded last-project path under `data_root`,
|
||||||
|
/// stripping trailing whitespace/newlines.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(None)` when the file is absent (a fresh data
|
||||||
|
/// root), `Err(_)` for IO errors that aren't `NotFound`. The
|
||||||
|
/// runtime treats `None` as "no resume target" and surfaces
|
||||||
|
/// the absent path explicitly when `--resume` was requested.
|
||||||
|
pub fn read_last_project(data_root: &Path) -> std::io::Result<Option<PathBuf>> {
|
||||||
|
let path = data_root.join(LAST_PROJECT_FILE);
|
||||||
|
match fs::read_to_string(&path) {
|
||||||
|
Ok(body) => {
|
||||||
|
let trimmed = body.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Ok(Some(PathBuf::from(trimmed)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically write `project_path` as the recorded
|
||||||
|
/// last-project for `data_root` (uses temp-write + rename so
|
||||||
|
/// a crash mid-write never leaves a half-line behind).
|
||||||
|
///
|
||||||
|
/// The path is written verbatim, with a single trailing
|
||||||
|
/// newline. We don't canonicalize: a stale entry pointing at
|
||||||
|
/// a moved/deleted directory is the kind of error `--resume`
|
||||||
|
/// is supposed to surface clearly, not paper over by
|
||||||
|
/// resolving symlinks at write time.
|
||||||
|
pub fn write_last_project(
|
||||||
|
data_root: &Path,
|
||||||
|
project_path: &Path,
|
||||||
|
) -> std::io::Result<()> {
|
||||||
|
fs::create_dir_all(data_root)?;
|
||||||
|
let final_path = data_root.join(LAST_PROJECT_FILE);
|
||||||
|
let tmp_path = data_root.join(format!("{LAST_PROJECT_FILE}.tmp"));
|
||||||
|
{
|
||||||
|
use std::io::Write as _;
|
||||||
|
let mut f = fs::File::create(&tmp_path)?;
|
||||||
|
writeln!(f, "{}", project_path.display())?;
|
||||||
|
f.sync_all()?;
|
||||||
|
}
|
||||||
|
fs::rename(&tmp_path, &final_path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve the data root for this run.
|
/// Resolve the data root for this run.
|
||||||
///
|
///
|
||||||
/// - If `override_dir` is `Some`, that path is used verbatim
|
/// - If `override_dir` is `Some`, that path is used verbatim
|
||||||
@@ -812,4 +862,38 @@ mod tests {
|
|||||||
let project = Project::create_temp(tmp.path()).expect("create");
|
let project = Project::create_temp(tmp.path()).expect("create");
|
||||||
assert_eq!(project.db_path(), project.path().join(PLAYGROUND_DB));
|
assert_eq!(project.db_path(), project.path().join(PLAYGROUND_DB));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_last_project_returns_none_when_missing() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
assert!(read_last_project(tmp.path()).unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn write_then_read_last_project_round_trips() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
let target = std::path::PathBuf::from("/tmp/some/project");
|
||||||
|
write_last_project(tmp.path(), &target).unwrap();
|
||||||
|
let read_back = read_last_project(tmp.path()).unwrap();
|
||||||
|
assert_eq!(read_back, Some(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn last_project_strips_trailing_whitespace() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
fs::write(
|
||||||
|
tmp.path().join(LAST_PROJECT_FILE),
|
||||||
|
"/tmp/some/project\n\n ",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let read_back = read_last_project(tmp.path()).unwrap();
|
||||||
|
assert_eq!(read_back, Some(std::path::PathBuf::from("/tmp/some/project")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_last_project_file_is_treated_as_none() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
fs::write(tmp.path().join(LAST_PROJECT_FILE), " \n").unwrap();
|
||||||
|
assert!(read_last_project(tmp.path()).unwrap().is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-3
@@ -10,6 +10,7 @@
|
|||||||
//! additional producers.
|
//! additional producers.
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -34,7 +35,7 @@ use crate::dsl::Command;
|
|||||||
use crate::event::AppEvent;
|
use crate::event::AppEvent;
|
||||||
use crate::project::{
|
use crate::project::{
|
||||||
Project, ProjectKind, copy_project, list_projects, open_or_create, projects_dir,
|
Project, ProjectKind, copy_project, list_projects, open_or_create, projects_dir,
|
||||||
resolve_data_root, safely_delete_temp_project,
|
read_last_project, resolve_data_root, safely_delete_temp_project, write_last_project,
|
||||||
};
|
};
|
||||||
use crate::theme::Theme;
|
use crate::theme::Theme;
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
@@ -51,8 +52,71 @@ pub async fn run(args: Args) -> Result<()> {
|
|||||||
// Project alone, so we keep it ourselves.
|
// Project alone, so we keep it ourselves.
|
||||||
let data_root = resolve_data_root(args.data_dir.as_deref())
|
let data_root = resolve_data_root(args.data_dir.as_deref())
|
||||||
.context("resolve data root")?;
|
.context("resolve data root")?;
|
||||||
let project = open_or_create(args.project_path.as_deref(), Some(data_root.as_path()))
|
|
||||||
|
// Resolve the initial project path: --resume reads it from
|
||||||
|
// <data-root>/last_project; otherwise an explicit positional
|
||||||
|
// arg, falling back to a fresh auto-named temp.
|
||||||
|
//
|
||||||
|
// ADR-0015 §7: --resume errors out cleanly when the path is
|
||||||
|
// missing or the recorded project no longer exists. We
|
||||||
|
// surface those failures to stderr before booting the
|
||||||
|
// terminal so the message lands directly in the user's
|
||||||
|
// shell.
|
||||||
|
let initial_path: Option<PathBuf> = if args.resume {
|
||||||
|
match read_last_project(&data_root)
|
||||||
|
.context("read last_project")?
|
||||||
|
{
|
||||||
|
Some(p) if p.exists() => Some(p),
|
||||||
|
Some(p) => {
|
||||||
|
eprintln!(
|
||||||
|
"rdbms-playground: --resume: recorded project `{}` no longer exists",
|
||||||
|
p.display(),
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
eprintln!(
|
||||||
|
"rdbms-playground: --resume: no previous project recorded under `{}`",
|
||||||
|
data_root.display(),
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
args.project_path.clone()
|
||||||
|
};
|
||||||
|
let project = open_or_create(initial_path.as_deref(), Some(data_root.as_path()))
|
||||||
.context("open or create project")?;
|
.context("open or create project")?;
|
||||||
|
|
||||||
|
// Run any pending project.yaml migrations before the
|
||||||
|
// database opens (so the rebuild path only ever sees the
|
||||||
|
// latest schema). The registry is empty in v1; future
|
||||||
|
// versions register their migrators here. A migration
|
||||||
|
// that runs is recorded in tracing and leaves a
|
||||||
|
// `project.yaml.v<N>.bak` breadcrumb on disk; that's
|
||||||
|
// sufficient v1 UX and lets us defer dedicated event
|
||||||
|
// plumbing until a real migrator demands it.
|
||||||
|
let migrate_registry = crate::persistence::migrations::MigratorRegistry::production();
|
||||||
|
let migration_outcome = crate::persistence::migrations::ensure_project_yaml_migrated(
|
||||||
|
project.path(),
|
||||||
|
&migrate_registry,
|
||||||
|
)
|
||||||
|
.context("migrate project.yaml")?;
|
||||||
|
if let Some(from) = migration_outcome.migrated_from {
|
||||||
|
info!(
|
||||||
|
from_version = from,
|
||||||
|
to_version = migrate_registry.latest_version(),
|
||||||
|
"migrated project.yaml",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the just-opened project as the new resume target.
|
||||||
|
// Write failures here are non-fatal: --resume on the next
|
||||||
|
// launch will report the missing/stale state, which is the
|
||||||
|
// safer default than refusing to launch.
|
||||||
|
if let Err(e) = write_last_project(&data_root, project.path()) {
|
||||||
|
warn!(error = %e, "could not update last_project");
|
||||||
|
}
|
||||||
let db_path = project.db_path();
|
let db_path = project.db_path();
|
||||||
let display_name = project.display_name().to_string();
|
let display_name = project.display_name().to_string();
|
||||||
let project_path = project.path().to_path_buf();
|
let project_path = project.path().to_path_buf();
|
||||||
@@ -170,6 +234,11 @@ async fn run_loop(
|
|||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.project_name = Some(project_display_name);
|
app.project_name = Some(project_display_name);
|
||||||
app.project_is_temp = project_is_temp;
|
app.project_is_temp = project_is_temp;
|
||||||
|
// Seed the in-memory navigable history from the
|
||||||
|
// initial project's history.log (I2-persist, ADR-0015
|
||||||
|
// §12). Subsequent project switches re-seed via the
|
||||||
|
// `ProjectSwitched` event payload.
|
||||||
|
app.seed_history(read_history_seed(session.project().path()));
|
||||||
|
|
||||||
// Send any startup events (e.g., the system-message form
|
// Send any startup events (e.g., the system-message form
|
||||||
// of "rebuilt from text on missing .db") so they're
|
// of "rebuilt from text on missing .db") so they're
|
||||||
@@ -369,10 +438,12 @@ async fn handle_project_switch(
|
|||||||
) {
|
) {
|
||||||
match perform_switch(session, req, source).await {
|
match perform_switch(session, req, source).await {
|
||||||
Ok((display_name, is_temp)) => {
|
Ok((display_name, is_temp)) => {
|
||||||
|
let history_entries = read_history_seed(session.project().path());
|
||||||
let _ = event_tx
|
let _ = event_tx
|
||||||
.send(AppEvent::ProjectSwitched {
|
.send(AppEvent::ProjectSwitched {
|
||||||
display_name,
|
display_name,
|
||||||
is_temp,
|
is_temp,
|
||||||
|
history_entries,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
if let Ok(tables) = session.database().list_tables().await {
|
if let Ok(tables) = session.database().list_tables().await {
|
||||||
@@ -387,6 +458,28 @@ async fn handle_project_switch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the most-recent `HISTORY_HYDRATION_CAP` source lines
|
||||||
|
/// out of the project's `history.log` for input-history
|
||||||
|
/// seeding. Failures are logged and swallowed — an empty
|
||||||
|
/// hydration is the right fallback when the file is unreadable.
|
||||||
|
fn read_history_seed(project_path: &std::path::Path) -> Vec<String> {
|
||||||
|
let p = crate::persistence::Persistence::new(project_path.to_path_buf());
|
||||||
|
match p.read_recent_history(HISTORY_HYDRATION_CAP) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "history hydration failed; starting empty");
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maximum number of `history.log` entries to seed the
|
||||||
|
/// in-memory navigable history with on project open. Matches
|
||||||
|
/// the in-memory cap (`app::HISTORY_CAPACITY`) per ADR-0015
|
||||||
|
/// §12: "latest N entries, where N is the same in-memory
|
||||||
|
/// cap as today."
|
||||||
|
const HISTORY_HYDRATION_CAP: usize = 1000;
|
||||||
|
|
||||||
async fn perform_switch(
|
async fn perform_switch(
|
||||||
session: &mut Session,
|
session: &mut Session,
|
||||||
req: SwitchRequest,
|
req: SwitchRequest,
|
||||||
@@ -510,6 +603,19 @@ async fn perform_switch(
|
|||||||
};
|
};
|
||||||
let new_path = new_project.path().to_path_buf();
|
let new_path = new_project.path().to_path_buf();
|
||||||
|
|
||||||
|
// Run any pending project.yaml migrations before the
|
||||||
|
// database opens. Same registry as `run()`. A failed
|
||||||
|
// migration aborts the switch (the old project has
|
||||||
|
// already been dropped — user lands in a "no project"
|
||||||
|
// state momentarily, but the next user action will
|
||||||
|
// surface the error and they can retry).
|
||||||
|
let migrate_registry = crate::persistence::migrations::MigratorRegistry::production();
|
||||||
|
crate::persistence::migrations::ensure_project_yaml_migrated(
|
||||||
|
new_project.path(),
|
||||||
|
&migrate_registry,
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Open the new database (rebuild from text if .db is
|
// Open the new database (rebuild from text if .db is
|
||||||
// missing — applies to NewTemp's just-created project,
|
// missing — applies to NewTemp's just-created project,
|
||||||
// and to Load when the user opened a project whose .db
|
// and to Load when the user opened a project whose .db
|
||||||
@@ -535,7 +641,15 @@ async fn perform_switch(
|
|||||||
// history.log. The worker's persistence is wired but not
|
// history.log. The worker's persistence is wired but not
|
||||||
// directly addressable from here, so we use a fresh
|
// directly addressable from here, so we use a fresh
|
||||||
// Persistence handle for this single line.
|
// Persistence handle for this single line.
|
||||||
let _ = Persistence::new(new_path).append_history(&source);
|
let _ = Persistence::new(new_path.clone()).append_history(&source);
|
||||||
|
|
||||||
|
// Update the resume pointer so the next `--resume`
|
||||||
|
// launch reopens the project we just switched to. Write
|
||||||
|
// failures are non-fatal — see the same rationale at
|
||||||
|
// `run()` startup.
|
||||||
|
if let Err(e) = write_last_project(&session.data_root, &new_path) {
|
||||||
|
tracing::warn!(error = %e, "could not update last_project after switch");
|
||||||
|
}
|
||||||
|
|
||||||
Ok((display_name, is_temp))
|
Ok((display_name, is_temp))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ fn project_switched_event_updates_state() {
|
|||||||
app.update(AppEvent::ProjectSwitched {
|
app.update(AppEvent::ProjectSwitched {
|
||||||
display_name: "New Name".to_string(),
|
display_name: "New Name".to_string(),
|
||||||
is_temp: false,
|
is_temp: false,
|
||||||
|
history_entries: Vec::new(),
|
||||||
});
|
});
|
||||||
assert_eq!(app.project_name.as_deref(), Some("New Name"));
|
assert_eq!(app.project_name.as_deref(), Some("New Name"));
|
||||||
assert!(!app.project_is_temp);
|
assert!(!app.project_is_temp);
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
//! Iteration-6 integration tests: `--resume` + persistent
|
||||||
|
//! input history + migration framework scaffold (ADR-0015 §7,
|
||||||
|
//! §9, §12).
|
||||||
|
//!
|
||||||
|
//! Boots no Tokio runtime and no terminal — these tests
|
||||||
|
//! exercise the persistent state behind `--resume` (the
|
||||||
|
//! `last_project` file under the data root) and the input
|
||||||
|
//! history hydration off `history.log`.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||||
|
|
||||||
|
use rdbms_playground::app::App;
|
||||||
|
use rdbms_playground::cli::{Args, ArgsError};
|
||||||
|
use rdbms_playground::event::AppEvent;
|
||||||
|
use rdbms_playground::persistence::Persistence;
|
||||||
|
use rdbms_playground::project::{
|
||||||
|
self, LAST_PROJECT_FILE, Project, read_last_project, write_last_project,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn tempdir() -> tempfile::TempDir {
|
||||||
|
tempfile::tempdir().expect("create tempdir")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Args parsing for --resume ---------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn args_parses_resume_flag() {
|
||||||
|
let a = Args::parse(["--resume"]).unwrap();
|
||||||
|
assert!(a.resume);
|
||||||
|
assert!(a.project_path.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn args_resume_with_positional_path_is_an_error() {
|
||||||
|
let err = Args::parse(["--resume", "/tmp/foo"]).unwrap_err();
|
||||||
|
assert!(matches!(err, ArgsError::ResumeWithPath), "got: {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn args_resume_after_positional_path_also_errors() {
|
||||||
|
let err = Args::parse(["/tmp/foo", "--resume"]).unwrap_err();
|
||||||
|
assert!(matches!(err, ArgsError::ResumeWithPath), "got: {err:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn args_help_listing_mentions_resume() {
|
||||||
|
assert!(rdbms_playground::cli::HELP_TEXT.contains("--resume"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- last_project read/write ----------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn last_project_round_trips_through_disk() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
let target = tmp.path().join("MyProject");
|
||||||
|
fs::create_dir(&target).unwrap();
|
||||||
|
write_last_project(tmp.path(), &target).unwrap();
|
||||||
|
|
||||||
|
let on_disk = fs::read_to_string(tmp.path().join(LAST_PROJECT_FILE)).unwrap();
|
||||||
|
assert!(on_disk.contains("MyProject"));
|
||||||
|
|
||||||
|
assert_eq!(read_last_project(tmp.path()).unwrap(), Some(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn last_project_is_overwritten_each_call() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
let a = tmp.path().join("A");
|
||||||
|
let b = tmp.path().join("B");
|
||||||
|
fs::create_dir(&a).unwrap();
|
||||||
|
fs::create_dir(&b).unwrap();
|
||||||
|
write_last_project(tmp.path(), &a).unwrap();
|
||||||
|
write_last_project(tmp.path(), &b).unwrap();
|
||||||
|
assert_eq!(read_last_project(tmp.path()).unwrap(), Some(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn last_project_create_temp_path_resolves_to_existing_dir() {
|
||||||
|
// Sanity: the path we record is in fact something that
|
||||||
|
// exists when --resume tries to reopen it. This protects
|
||||||
|
// against future refactors that might write a placeholder.
|
||||||
|
let tmp = tempdir();
|
||||||
|
let project = Project::create_temp(tmp.path()).unwrap();
|
||||||
|
write_last_project(tmp.path(), project.path()).unwrap();
|
||||||
|
let read_back = read_last_project(tmp.path()).unwrap();
|
||||||
|
assert_eq!(read_back.as_deref(), Some(project.path()));
|
||||||
|
assert!(read_back.unwrap().exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_last_project_handles_missing_data_root_directory() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
let nested = tmp.path().join("does/not/exist/yet");
|
||||||
|
// Reading from a directory that hasn't been created at
|
||||||
|
// all should be Ok(None), not an error — the runtime's
|
||||||
|
// first launch lands here.
|
||||||
|
assert!(read_last_project(&nested).unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Stale path on resume: read returns Some(path) but the
|
||||||
|
// path does not exist. The runtime is responsible for
|
||||||
|
// surfacing this; we verify the building block here.
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn last_project_returns_stale_path_verbatim_for_runtime_to_detect() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
let stale = tmp.path().join("Vanished");
|
||||||
|
write_last_project(tmp.path(), &stale).unwrap();
|
||||||
|
let read_back = read_last_project(tmp.path()).unwrap();
|
||||||
|
assert_eq!(read_back.as_deref(), Some(stale.as_path()));
|
||||||
|
assert!(!stale.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Project lifecycle writes last_project ---------------------
|
||||||
|
// (Smoke test: launching open_or_create then opening again
|
||||||
|
// should be the same as write_last_project + reopen.)
|
||||||
|
|
||||||
|
// --- History hydration on project open ----------------------
|
||||||
|
|
||||||
|
const fn key(code: KeyCode) -> AppEvent {
|
||||||
|
AppEvent::Key(KeyEvent {
|
||||||
|
code,
|
||||||
|
modifiers: KeyModifiers::NONE,
|
||||||
|
kind: KeyEventKind::Press,
|
||||||
|
state: crossterm::event::KeyEventState::NONE,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_recent_history_returns_empty_when_log_missing() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
let p = Persistence::new(tmp.path().to_path_buf());
|
||||||
|
let entries = p.read_recent_history(10).unwrap();
|
||||||
|
assert!(entries.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn read_recent_history_returns_appended_entries_in_order() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
let project = Project::create_temp(tmp.path()).unwrap();
|
||||||
|
let p = Persistence::new(project.path().to_path_buf());
|
||||||
|
p.append_history("create table A with pk").unwrap();
|
||||||
|
p.append_history("create table B with pk").unwrap();
|
||||||
|
p.append_history("create table C with pk").unwrap();
|
||||||
|
let entries = p.read_recent_history(10).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
entries,
|
||||||
|
vec![
|
||||||
|
"create table A with pk".to_string(),
|
||||||
|
"create table B with pk".to_string(),
|
||||||
|
"create table C with pk".to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seed_history_replaces_in_memory_history() {
|
||||||
|
let mut app = App::new();
|
||||||
|
// Pre-existing in-session entries — should be replaced.
|
||||||
|
for c in "abc".chars() {
|
||||||
|
app.update(key(KeyCode::Char(c)));
|
||||||
|
}
|
||||||
|
app.update(key(KeyCode::Enter));
|
||||||
|
assert_eq!(app.history, vec!["abc".to_string()]);
|
||||||
|
|
||||||
|
app.seed_history(vec!["x".to_string(), "y".to_string()]);
|
||||||
|
assert_eq!(app.history, vec!["x".to_string(), "y".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seed_history_preserves_chronological_order_for_navigation() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.seed_history(vec![
|
||||||
|
"old".to_string(),
|
||||||
|
"middle".to_string(),
|
||||||
|
"newest".to_string(),
|
||||||
|
]);
|
||||||
|
// Up should recall "newest" first (the most recent
|
||||||
|
// entry, which is at the back of the vec by convention).
|
||||||
|
app.update(key(KeyCode::Up));
|
||||||
|
assert_eq!(app.input, "newest");
|
||||||
|
app.update(key(KeyCode::Up));
|
||||||
|
assert_eq!(app.input, "middle");
|
||||||
|
app.update(key(KeyCode::Up));
|
||||||
|
assert_eq!(app.input, "old");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn project_switched_event_seeds_history_from_payload() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.update(AppEvent::ProjectSwitched {
|
||||||
|
display_name: "Foo".to_string(),
|
||||||
|
is_temp: false,
|
||||||
|
history_entries: vec!["aa".to_string(), "bb".to_string()],
|
||||||
|
});
|
||||||
|
assert_eq!(app.history, vec!["aa".to_string(), "bb".to_string()]);
|
||||||
|
// Up navigates within the seeded entries.
|
||||||
|
app.update(key(KeyCode::Up));
|
||||||
|
assert_eq!(app.input, "bb");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn data_root_with_no_last_project_is_resume_safe() {
|
||||||
|
let tmp = tempdir();
|
||||||
|
// Fresh data root with no projects, no last_project.
|
||||||
|
let _project = project::open_or_create(None, Some(tmp.path())).unwrap();
|
||||||
|
// open_or_create itself doesn't write last_project (the
|
||||||
|
// runtime does, after a successful open). That's fine —
|
||||||
|
// the runtime test would write it. Verify that
|
||||||
|
// read_last_project here returns None as expected.
|
||||||
|
assert!(read_last_project(tmp.path()).unwrap().is_none());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user