Compare commits
3 Commits
8ac3537df0
...
ae73a4be85
| Author | SHA1 | Date | |
|---|---|---|---|
| ae73a4be85 | |||
| 4aeea55984 | |||
| eceedc19b7 |
@@ -213,6 +213,14 @@ working copy.
|
||||
|
||||
### 6. Persistence ordering
|
||||
|
||||
> **Amended by ADR-0052 (2026-06-13, issue #30):** `history.log` is no
|
||||
> longer written inside the worker transaction. It is a *journal* of typed
|
||||
> commands, not state, so success journaling moved to the dispatch layer
|
||||
> (next to the already-top-level failure journaling); `commit-db-last` now
|
||||
> governs the three **state** targets only (db + `project.yaml` +
|
||||
> `data/*.csv`), which still commit atomically in the worker. The journal
|
||||
> write is best-effort (amends ADR-0040).
|
||||
|
||||
A successful user command produces effects in four targets:
|
||||
the SQLite database, `project.yaml`, the relevant
|
||||
`data/<table>.csv` file(s), and `history.log`. INV-2 from the
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
Accepted. **Amended by ADR-0052 (2026-06-13, issue #30):** the status
|
||||
field gains an optional `:adv` mode suffix (`ok:adv` / `err:adv`) — the
|
||||
"non-breaking future extension" this ADR reserved — and **success
|
||||
journaling moves out of the worker to the dispatch layer**
|
||||
(`spawn_dsl_dispatch` / `run_replay` / app-command sites), next to the
|
||||
failure path, where the submission mode is in scope. `status_is_ok` keys
|
||||
off the base token, so `ok:adv` replays like `ok`.
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
**Accepted** — 2026-05-30 (issue #9). Amends the output conventions of
|
||||
ADR-0014 (data operations), ADR-0028 (query plans / `explain`), and
|
||||
ADR-0019 (failure rendering); builds on ADR-0037's mode-tagged echo
|
||||
line.
|
||||
line. **Amended by ADR-0052 (2026-06-13, issue #30):** a `history.log`
|
||||
*journal*-write failure on a **successful** command is no longer fatal —
|
||||
journaling moved to the dispatch layer (after the db commit), so it is
|
||||
best-effort (logged + ignored), consistent with the failure-journal path.
|
||||
State-write failures (yaml/csv/db) remain fatal.
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# ADR-0051: Bottom keybinding strip — context- and state-aware
|
||||
|
||||
## Status
|
||||
|
||||
**Accepted 2026-06-13 (issue #27).** Closes Gitea **#27**. All forks
|
||||
below were escalated to the user and user-chosen before any code was
|
||||
written; to be implemented test-first. Builds on ADR-0046 (nav focus),
|
||||
ADR-0003 (input modes), ADR-0049 (the #29 readline keys this strip now
|
||||
advertises), and ADR-0022 (the Tab-completion memo).
|
||||
|
||||
## Context
|
||||
|
||||
The bottom status line (`render_status_bar`, `ui.rs`) mixed keystrokes
|
||||
with typed-command words: `Enter submit · : advanced once · mode
|
||||
advanced switch · Ctrl-C quit`. That is redundant — the hint panel
|
||||
already teaches `help` and `Enter` when the input is empty — and it is
|
||||
static apart from a three-way mode branch, so it never reflects what the
|
||||
user can actually do *right now* (navigating the sidebar, cycling a
|
||||
completion, browsing history, editing a line).
|
||||
|
||||
Issue #27: repurpose the line as a **keybindings-only** strip that is
|
||||
**context-sensitive to nav focus** and **state-aware of the current
|
||||
transient interaction**, and move mode discovery into the empty-input
|
||||
hint.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. The strip is keybindings-only and state-selected
|
||||
|
||||
A single pure function `status_bar_bindings(app) -> Vec<Binding>`
|
||||
computes the strip from app state; `render_status_bar` is a thin
|
||||
renderer over it (so the binding sets are unit-testable without a
|
||||
Frame). `history_cursor` is private to `App`, so a small
|
||||
`pub fn is_browsing_history(&self) -> bool` accessor exposes the
|
||||
history-navigation predicate; `mode` / `nav_focus` / `last_completion`
|
||||
are already `pub` and `effective_mode()` is a `pub` method. The state is
|
||||
chosen by **priority — first match wins**:
|
||||
|
||||
| Priority | State (predicate) | Strip |
|
||||
|---|---|---|
|
||||
| 1 | **Sidebar focus** (`nav_focus` in a sidebar) | `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input` |
|
||||
| 2 | **Completion memo live** (`last_completion.is_some()`) | `Tab/Shift-Tab cycle · Esc cancel · Enter run` |
|
||||
| 3 | **History navigation** (`history_cursor.is_some()`) | `↑↓ browse · Esc clear · Enter run` |
|
||||
| 4 | **Editing** (Input focus, input non-empty) | `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` |
|
||||
| 5 | **Default** (Input focus, input empty) | `Ctrl-O sidebar · Tab complete · ↑ history · Enter run` |
|
||||
|
||||
Priority order matters: a completion memo or history navigation is a
|
||||
non-empty-input situation, so states 2 and 3 must precede state 4. The
|
||||
sidebar overlay occludes the input entirely (ADR-0046), so state 1 wins
|
||||
outright.
|
||||
|
||||
### 2. Mode discovery moves off the strip, into the empty-input hint
|
||||
|
||||
The typed-command advertisements (`mode advanced` / `mode simple`
|
||||
switch, the `:` one-shot) leave the strip — they are not keystrokes.
|
||||
Mode discovery moves to the **empty-input hint** (`resolve_hint_lines`'s
|
||||
`(None, None)` arm), in **simple mode only**:
|
||||
|
||||
- **Simple:** `… · \`mode advanced\` for SQL`
|
||||
- **Advanced (persistent):** no pointer.
|
||||
|
||||
The pointer omits the verb "type" — the surrounding prompt already
|
||||
implies it (we don't say "type `help`" either). Advanced mode shows
|
||||
**no** pointer (user decision, post-trial): a user who switched into
|
||||
advanced mode knows how they got there, and `help` covers the way back —
|
||||
a "switch back" pointer only reads naturally in the moment right after
|
||||
switching, so it earns its space poorly.
|
||||
|
||||
The one-shot advanced state's old `Backspace cancel one-shot` label is
|
||||
**subsumed** by the editing state (the input is non-empty in one-shot;
|
||||
Esc-clear and Backspace both cancel it). No behaviour is lost — only the
|
||||
dedicated label.
|
||||
|
||||
### 3. Width: no drop machinery; a budget test instead
|
||||
|
||||
The longest strip (state 4, editing) is ≈ **65 display columns**, which
|
||||
fits every supported width (90-col screencasts, 80-col terminals) with
|
||||
margin — so the priority-drop / abbreviation machinery considered would
|
||||
never trigger and is not built (user-confirmed). Ratatui's existing
|
||||
**clip-at-edge** is the trivial fallback for pathologically narrow
|
||||
(< 65-col) terminals. Instead, a **width-budget unit test** pins the
|
||||
longest rendered strip within an 80-col budget, keeping the strip lean
|
||||
*by construction* — a future over-long strip fails the test rather than
|
||||
silently clipping in a cast.
|
||||
|
||||
## Forks (all user-chosen)
|
||||
|
||||
- **Editing state — yes:** when the input has text, surface the #29
|
||||
readline keys (Esc-clear, Ctrl-A/E, Ctrl-W); the strip stays lean
|
||||
(nav/complete/history) when empty. (vs not advertising the #29 keys.)
|
||||
- **`Ctrl-C quit` — omitted** from the strip (vs always shown): quit is
|
||||
a near-universal convention; omitting it keeps the strips lean and
|
||||
matches the issue's sketch.
|
||||
- **Width — budget test, no drop logic** (vs graceful priority-drop /
|
||||
abbreviation): the strips fit at supported widths, so the machinery
|
||||
would be dead weight (user's own observation).
|
||||
|
||||
## Consequences
|
||||
|
||||
- The strip now teaches the keys for the *current* situation; learners
|
||||
see `Tab/Shift-Tab cycle` exactly while cycling, the editing keys
|
||||
exactly while editing, etc.
|
||||
- The #29 readline keys (ADR-0049) gain their on-screen advertisement,
|
||||
closing that ADR's deferred item.
|
||||
- 15 existing full-panel insta snapshots churn (the bottom line — and,
|
||||
on empty-input views, the hint pointer — changes in every one,
|
||||
including the rebuild-confirm modal view, whose modal box is itself
|
||||
unchanged); each diff was reviewed, not blind-accepted.
|
||||
- `requirements.md` is unaffected (an ADR-tracked UI refinement); the
|
||||
change is cross-referenced from the commit + this ADR.
|
||||
|
||||
## Tests
|
||||
|
||||
- **Tier-1 (`ui.rs` unit):** `status_bar_bindings` returns the expected
|
||||
key set for each of the five states (sidebar, completion-live,
|
||||
history-nav, editing, default) — the completion/history states driven
|
||||
through real key events (`update`) so the predicate transitions are
|
||||
exercised, the others by setting `App` fields; plus the width-budget
|
||||
assertion across states. (Per-state coverage is these unit tests, not
|
||||
snapshots — a one-line strip is asserted more precisely by its exact
|
||||
key list than by a full-panel snapshot.)
|
||||
- **Tier-1:** the empty-input hint appends the correct mode pointer in
|
||||
Simple vs Advanced, and does **not** append it when an ambient hint is
|
||||
showing (non-empty input).
|
||||
- **Tier-3 (`walking_skeleton`):** the old `status_bar_lists_quit_and_
|
||||
submit_in_all_modes` (which asserted the pre-ADR strip) is rewritten +
|
||||
renamed to assert the keystroke-only, state-aware strip end-to-end
|
||||
through the real render path (default → editing transition).
|
||||
- **Tier-2 (insta):** the 15 full-panel snapshots re-accepted (each diff
|
||||
reviewed — strip line and/or hint pointer only).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Modal-aware strip.** While a modal is open (load picker, rebuild /
|
||||
undo confirm) it owns the keyboard and carries its own in-box key
|
||||
hints; the bottom strip under a modal computes from input state
|
||||
exactly as it does today (modals render *over* the status bar). This
|
||||
issue does not redesign the modal case — pre-existing behaviour,
|
||||
unchanged and not worsened.
|
||||
- A persistent/togglable help overlay listing *all* keys (the strip is a
|
||||
contextual subset, not a cheatsheet).
|
||||
- Per-key colour theming beyond the existing key/label/separator styles.
|
||||
- Localisation of the new label strings beyond adding catalog entries.
|
||||
- The remaining I1b kill keys' (Ctrl-K/Ctrl-U) advertisement — the
|
||||
editing strip shows the highest-value subset (Esc/Ctrl-A/E/Ctrl-W) to
|
||||
stay within the width budget; Ctrl-K/U remain unadvertised muscle
|
||||
memory.
|
||||
@@ -0,0 +1,241 @@
|
||||
# ADR-0052: Mode-tagged history for cross-mode recall
|
||||
|
||||
## Status
|
||||
|
||||
**Accepted + implemented 2026-06-13 (issue #30).** Closes Gitea **#30** —
|
||||
both the feature ("reuse advanced history commands in simple mode by
|
||||
prepending `:`") and the bug reported in its comment (the `:` one-shot
|
||||
prefix lost across sessions). All forks user-chosen before any code.
|
||||
**Amends ADR-0034** (journal status field gains a `:adv` tag; *journaling
|
||||
moves from the worker to the dispatch layer*), **ADR-0015 §5/§6**
|
||||
(history.log leaves the worker transaction — `commit-db-last` now scopes
|
||||
yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure
|
||||
is best-effort, no longer fatal); references ADR-0003 (the `:` one-shot
|
||||
sigil). Plan: `docs/plans/20260613-issue-30-top-of-chain-journaling.md`
|
||||
(pre-build `/runda`, then a second `/runda` that drove the journaling
|
||||
relocation + the app-command exclusion). **2471 tests pass / 0 fail / 0
|
||||
skip (1 ignored), clippy clean.**
|
||||
|
||||
> **Why journaling moved (the key architectural turn).** The first draft
|
||||
> kept journaling in the worker and threaded the mode down to it (~30-site
|
||||
> plumbing). On review the user asked the right question: why is the
|
||||
> journal written deep in the worker at all, when the failure path already
|
||||
> journals at the top of the chain where command + mode + outcome are all
|
||||
> in scope? It shouldn't — `history.log` is a *journal of typed commands*,
|
||||
> not *state*. So success journaling moved up next to the failure path
|
||||
> (`spawn_dsl_dispatch` / `run_replay` / the app-command sites), the
|
||||
> mode-plumbing dilemma dissolved, and the worker's `finalize_persistence`
|
||||
> now writes only the state sources (yaml/csv). Consequence: the journal
|
||||
> write is best-effort (the command is already committed), consistent with
|
||||
> the failure path — see §5.
|
||||
|
||||
## Context
|
||||
|
||||
The input-history ring and `history.log` carry **no mode information**,
|
||||
which causes two coupled problems:
|
||||
|
||||
1. **Feature gap.** A command typed in advanced mode (`select * from T`)
|
||||
is stored bare. Recalled in simple mode it is not valid DSL → it just
|
||||
errors. There is no way to know it was an advanced (SQL) command and
|
||||
offer it back in a runnable form.
|
||||
|
||||
2. **Bug (issue #30 comment).** A `:`-one-shot advanced command in simple
|
||||
mode recalls correctly **in-session** (the in-memory ring stores the
|
||||
raw `:select 1`), but after quit+resume it comes back **without** the
|
||||
`:` and is unusable. Root cause: the ring stores the raw input
|
||||
(`:select 1`), but the worker journals the **stripped** `effective_input`
|
||||
(`select 1`) — submission strips the `:` before dispatch (ADR-0003) —
|
||||
so the on-disk `source` never carried the `:`, and hydration loses it.
|
||||
|
||||
Both reduce to: **history does not record the submission mode**, and the
|
||||
in-memory and on-disk representations disagree about the `:`.
|
||||
|
||||
## Decision
|
||||
|
||||
Record the **submission mode** per history entry, keep the on-disk
|
||||
`source` **canonical** (stripped — replay is unaffected), and have
|
||||
**recall reconstruct the runnable line** for the current mode.
|
||||
|
||||
### 1. In-memory ring stores the `:`-prefixed runnable form
|
||||
|
||||
`App.history` stays `Vec<String>` — no type change, so the public ring,
|
||||
the `ProjectSwitched` payload, and `seed_history` are untouched. An
|
||||
**advanced** entry is stored in its **simple-mode runnable form**, the
|
||||
`: `-prefixed string (e.g. `: select * from T`); a **simple** entry is
|
||||
stored bare. This is exactly what the in-session one-shot ring already
|
||||
does (`:select 1` recalls as typed) — generalised to *persistent*-advanced
|
||||
commands too, and made reconstructable on hydration. Because a simple
|
||||
DSL command can never begin with `:` (the sole sigil, ADR-0003), a
|
||||
leading `:` unambiguously marks an advanced entry.
|
||||
|
||||
`submit` builds the stored line from the submission: advanced →
|
||||
`": " + effective_input` (the `: ` matches the auto-space the typed
|
||||
one-shot inserts), simple → `effective_input`. This is computed **after**
|
||||
`effective_input` (today `push_history` runs on the raw `trimmed` before
|
||||
stripping; the reorder also drops a bare `:`, which never executed). The
|
||||
draft (`history_draft`) stays a plain `String`. `push_history` itself is
|
||||
unchanged — it still takes one `&str`.
|
||||
|
||||
### 2. Recall strips the `:` for advanced mode
|
||||
|
||||
`history_back` / `history_forward` set `self.input` from the stored
|
||||
string, then strip a leading `:` **iff the current persistent mode is
|
||||
Advanced**:
|
||||
|
||||
```
|
||||
if self.mode == Mode::Advanced && stored.starts_with(':') { stored[1..].trim_start() } else { stored }
|
||||
```
|
||||
|
||||
So an advanced entry recalls as `: select * from T` in **simple** mode
|
||||
(runs via the one-shot escape — the feature, and the cross-session bug
|
||||
fix) and bare `select * from T` in **advanced** mode (runs as SQL). A
|
||||
simple entry recalls bare in either mode (simple DSL already runs in
|
||||
advanced mode — issue #30). In-session and cross-session paths share the
|
||||
same stored form, so they finally agree.
|
||||
|
||||
### 3. On-disk: a mode tag in the status field
|
||||
|
||||
The record stays three pipe-separated fields `<ts>|<status>|<source>`
|
||||
(so `source` remains the last, pipe-tolerant, canonical field — replay
|
||||
reads it unchanged). The **status token** gains an optional `:adv`
|
||||
suffix:
|
||||
|
||||
| Submission | Success | Failure |
|
||||
|---|---|---|
|
||||
| Simple | `ok` | `err` |
|
||||
| Advanced (persistent or one-shot) | `ok:adv` | `err:adv` |
|
||||
|
||||
ADR-0034 §1 already reserved the status field for "additional values …
|
||||
a non-breaking future extension"; this is that extension. The status
|
||||
parser splits the token on `:`: the base (`ok`/`err`) gives replayability
|
||||
(`status_is_ok` ⇔ base == `ok`), the `adv` suffix gives the mode — so an
|
||||
unknown future token degrades to "not ok, simple" rather than mis-parsing.
|
||||
|
||||
### Journaling location: the dispatch layer, not the worker
|
||||
|
||||
Both tags are written **at the dispatch layer**, where command + mode +
|
||||
outcome are all in scope — so the mode needs no plumbing into the worker:
|
||||
|
||||
- **Success:** `spawn_dsl_dispatch`, immediately after
|
||||
`execute_command_typed` returns `Ok`, calls
|
||||
`append_history(source, submission_mode.is_advanced())` (best-effort).
|
||||
`run_replay` does the same per replayed line (tagged simple — replay is
|
||||
mode-agnostic), and the app-command sites (`perform_switch` /
|
||||
`spawn_export` / `spawn_rebuild`) journal **simple** (`advanced = false`
|
||||
— app commands run in any mode, so no `:` on recall; this also avoids a
|
||||
redundant `: undo`).
|
||||
- **Failure:** unchanged location (the App→`JournalFailure`→runtime path,
|
||||
already at the top), now carrying the mode — `JournalFailure` gains
|
||||
`advanced`, and `DslFailed` gains `submission_mode` for the
|
||||
worker-rejection sub-path (the parse-failure sub-path has it in
|
||||
`dispatch_dsl`). `Ok`/`Err` are exclusive, so success-in-spawn and
|
||||
failure-in-App-path never double-journal.
|
||||
|
||||
The worker's `finalize_persistence` and the four no-op-skip / three
|
||||
read-only sites **no longer journal** — they leave the state writes
|
||||
(yaml/csv) in the worker transaction and let the dispatch layer journal
|
||||
the `Ok` outcome.
|
||||
|
||||
### 4. Hydration reconstructs the `:`-prefixed form
|
||||
|
||||
`read_recent_sources` parses each record's status tag and, for an
|
||||
advanced record, **reconstructs** the `: `-prefixed string from the
|
||||
canonical `source` (`format!(": {source}")`); simple records pass through
|
||||
bare. It still returns `Vec<String>`, so `read_history_seed`,
|
||||
`seed_history`, and the `ProjectSwitched` payload are **unchanged**. A
|
||||
hydrated entry is therefore byte-identical to its in-session form, and
|
||||
recall behaves identically.
|
||||
|
||||
### Back-compatibility
|
||||
|
||||
Old `history.log` files have only `ok` / `err` tokens → parsed as
|
||||
`advanced = false` (simple). Their advanced commands stay un-`:`-able on
|
||||
recall — the pre-existing behaviour, not a regression; nothing migrates.
|
||||
`status_is_ok` keys off the base token, so `ok:adv` records replay
|
||||
exactly as `ok` does today (source is canonical either way).
|
||||
|
||||
### Journal write is best-effort (amends ADR-0040)
|
||||
|
||||
Because the journal is now written *after* the worker replies (i.e. after
|
||||
`tx.commit`), a journal-write failure can no longer roll the command back.
|
||||
It is **best-effort** — logged and ignored, exactly like the failure path
|
||||
already is (ADR-0034 §4) — so the two journal paths are finally
|
||||
consistent. State integrity is unchanged: yaml/csv/db still commit
|
||||
atomically in the worker (a *state*-write failure still rolls back and is
|
||||
fatal). The only property given up: on a rare journal-write failure (disk
|
||||
full) a committed command may be missing from `history.log` — not
|
||||
recallable/replayable next session, but the state is correct. User-chosen
|
||||
over keeping journaling coupled in the worker (which would have needed the
|
||||
~30-site mode plumbing). See the plan's §2 for the full trade-off.
|
||||
|
||||
## Forks (user-chosen)
|
||||
|
||||
- **Format = mode tag in the status field** (`ok:adv`/`err:adv`), over a
|
||||
new 4th field (ambiguous with unescaped pipes in old `source`s without
|
||||
a version bump) or a `:`-prefix in `source` (would make `source`
|
||||
non-canonical and force replay to strip it).
|
||||
- **Scope = unified** (bug + feature) over bug-only: one mechanism does
|
||||
both, and keeping `source` canonical for replay needs the mode tag
|
||||
regardless, so bug-only is barely smaller and leaves the main ask open.
|
||||
- **Journaling location = dispatch layer, best-effort** over keeping it
|
||||
worker-coupled-and-fatal (which needed the ~30-site mode plumbing). The
|
||||
user's architectural call (§Status).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Advanced history is reusable in simple mode; the `:` one-shot survives
|
||||
resume. The in-memory and on-disk representations agree.
|
||||
- **Journaling left the worker.** `finalize_persistence` and the
|
||||
no-op-skip / read-only sites no longer journal; success is journalled at
|
||||
the dispatch layer (`spawn_dsl_dispatch` / `run_replay` / app-command
|
||||
sites). The ring stays `Vec<String>`; `seed_history` / `ProjectSwitched`
|
||||
are untouched. The vestigial worker `source` plumbing (the `_source`
|
||||
param on `finalize_persistence` / `do_rebuild_from_text` and the thin
|
||||
read-only `*_request` wrappers) is left in place — a clean follow-up.
|
||||
- **App commands recall bare.** Because they are dispatched outside the
|
||||
`ExecuteDsl`/spawn path, app commands journal **simple** (`advanced =
|
||||
false`) at their own sites, and `submit` excludes them from the ring's
|
||||
`advanced` flag (`!is_app_command`) — so `mode advanced` / `undo` recall
|
||||
bare and run fine in simple mode, with no redundant `:`.
|
||||
- **Journaling is now uniform (user-confirmed).** The spawn journals on
|
||||
`outcome.is_ok()`, so **every** successful command is recorded — closing
|
||||
a pre-existing gap where `show table` / `show data` / `select` journalled
|
||||
but `show tables`/`show relationships`/`show indexes`, `show relationship
|
||||
<name>`, and `explain` did **not** (their worker arms carried no
|
||||
`source` / no journal call). The new behaviour matches ADR-0034 §1
|
||||
("record every submitted command"); those reads are now recallable and
|
||||
are re-run harmlessly on replay (`explain` never executes; shows produce
|
||||
output, no state change). A DA finding, accepted as the more-correct
|
||||
behaviour over re-adding command-outcome gating to preserve the old
|
||||
inconsistency.
|
||||
- **Replay re-journaling.** When `replay` re-dispatches a line, the
|
||||
re-written record is tagged from how replay dispatched (mode-agnostic →
|
||||
`ok`), so a replayed advanced command may be re-journalled without
|
||||
`:adv`. Replay correctness of execution is unchanged (it already parses
|
||||
mode-agnostically); this only affects the *tag* of the re-written line.
|
||||
Noted; not addressed here (replay's own mode-fidelity is out of scope).
|
||||
|
||||
## Tests
|
||||
|
||||
- **Tier-1 (`app.rs`):** an advanced one-shot / persistent-advanced
|
||||
submission is stored `: `-prefixed; it recalls as `: …` in simple mode
|
||||
and bare in advanced mode; a simple entry recalls bare in both; a bare
|
||||
`:` is not stored; a parse-failure is still recallable; dedup/cap hold.
|
||||
- **Tier-1 (`history.rs`):** the writer emits `ok:adv`/`err:adv`;
|
||||
`read_recent_sources` reconstructs the `: `-prefix for `:adv` records
|
||||
and leaves `ok`/`err` records bare (so old logs read as simple);
|
||||
`status_is_ok` is true for `ok` and `ok:adv`.
|
||||
- **Tier-3 (`iteration6_resume_history` / it):** the headline
|
||||
**regression** — type a `:`-one-shot advanced command, journal +
|
||||
hydrate, and assert it recalls **with** `:` in simple mode (fails on
|
||||
current code); plus a persistent-advanced command round-tripping to a
|
||||
`: …` recall.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Replay re-journaling mode-fidelity (above).
|
||||
- Special-casing app commands to avoid the redundant recall `: `.
|
||||
- Distinguishing one-shot from persistent advanced on recall (both are
|
||||
simply "advanced" — the `:` is what simple mode needs either way).
|
||||
- A format version marker / pipe-escaping in `source` (unneeded — the
|
||||
status-tag approach keeps `source` last and canonical).
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,203 @@
|
||||
# Session handoff — 2026-06-14 (69)
|
||||
|
||||
Sixty-ninth handover. Continues from handoff-68 (an issue-burndown that
|
||||
closed #25/#26/#31/#32/#33/#34). This session **closed the four
|
||||
remaining open issues** — #29, #28, #27, #30 — each landed with the full
|
||||
phased workflow + `/runda` + Devil's-Advocate passes before commit, and
|
||||
each producing a new ADR. Net: **four issues closed, four commits, four
|
||||
new ADRs (0049–0052), +63 tests, zero regressions, the tracker is now
|
||||
empty.**
|
||||
|
||||
The four interlock: **#29** added the input-field readline keys, **#27**
|
||||
advertises them in a state-aware status strip, and **#30**'s history
|
||||
recall now respects modes. **#30** also turned into a real architecture
|
||||
change (journaling relocation) — read §2.4 carefully before touching that
|
||||
area.
|
||||
|
||||
## §1. State at handoff
|
||||
|
||||
**Branch:** `main`. Working tree **clean**; all work committed. The two
|
||||
most recent commits are local (normal working state — push is the user's
|
||||
step).
|
||||
|
||||
**Tests: 2471 passing / 0 failing / 0 skipped / 1 ignored** (the
|
||||
long-standing `friendly` doctest). **Clippy clean** (nursery, all
|
||||
targets). Breakdown: 1771 lib + 500 integration (`it`) + 200
|
||||
typing-surface-matrix. **+35 over handoff-68's 2436** (net: #29 +22, #28
|
||||
+0, #27 +9, #30 +4 — its new history.rs/app.rs/iteration6 tests minus the
|
||||
15 retired worker-journaling tests; trust the live `cargo test` count).
|
||||
|
||||
**Commits this session:**
|
||||
```
|
||||
4aeea55 feat(history): mode-tagged history + top-of-chain journaling (#30)
|
||||
eceedc1 feat(ui): context- and state-aware bottom keybinding strip (#27)
|
||||
8ac3537 feat(render): incidental-DDL confirmations show structure only, no relationships (#28)
|
||||
66c8bda feat(input): readline keymap — Esc-clear + Ctrl-A/E/W/K/U (#29)
|
||||
```
|
||||
|
||||
**Open Gitea issues: none.** `tea issues list --state open` is empty.
|
||||
|
||||
## §2. Issues closed this session (all committed, tested, `/runda`-reviewed)
|
||||
|
||||
Each closed on `git.lazyeval.net/oli/rdbms-playground` with a summary
|
||||
comment.
|
||||
|
||||
### 2.1 — #29 (`66c8bda`) — input-field readline keymap (ADR-0049)
|
||||
|
||||
Implements the deferred **I1b** readline shortcuts: `Esc` clears a
|
||||
partly-typed command (only when no completion memo is alive — the memo
|
||||
wins first, ADR-0022); `Ctrl-A`/`Ctrl-E` = Home/End; `Ctrl-W` deletes
|
||||
the previous word (readline-style, UTF-8 safe); `Ctrl-K`/`Ctrl-U` kill to
|
||||
end/start. Cursor-only keys leave history nav intact; buffer-mutating
|
||||
keys end it. **DA caught** the need for the `Ctrl-O`+`Esc` (sidebar
|
||||
nav-exit) interaction not to clear the draft — locked with a regression
|
||||
test. `requirements.md` I1b → `[x]`.
|
||||
|
||||
### 2.2 — #28 (`8ac3537`) — incidental-DDL confirmations: structure-only (ADR-0050)
|
||||
|
||||
Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/
|
||||
`rename`/`change column`, `add`/`drop index`) now render **structure
|
||||
only** — no `References:` / `Referenced by:` block. Relationship-subject
|
||||
surfaces (`show table`, `add`/`drop relationship`) keep their ADR-0044
|
||||
diagrams. The prose renderer (`relationship_prose_lines` + `cols_disp`)
|
||||
was deleted. **Supersedes** ADR-0044 §1's incidental-DDL prose clause and
|
||||
the relationship-block half of ADR-0016 §5 (both annotated).
|
||||
|
||||
### 2.3 — #27 (`eceedc1`) — context- and state-aware keybinding strip (ADR-0051)
|
||||
|
||||
The bottom status line is now keystrokes-only and **state-selected** by
|
||||
priority (sidebar focus / completion-memo / history-nav / editing /
|
||||
default). The editing state surfaces the #29 keys (closing ADR-0049's
|
||||
deferred advertisement). Mode-switch advertisements left the strip; the
|
||||
empty-input hint gained a simple-mode `` `mode advanced` for SQL `` pointer
|
||||
(advanced mode shows none — user decision). New `App::is_browsing_history()`
|
||||
exposes the private `history_cursor`. 15 full-panel snapshots re-accepted.
|
||||
|
||||
### 2.4 — #30 (`4aeea55`) — mode-tagged history + top-of-chain journaling (ADR-0052) **← read before touching journaling**
|
||||
|
||||
Closed both the feature (advanced history reusable in simple mode) and
|
||||
the bug (the `:` one-shot prefix lost across sessions). Two halves:
|
||||
|
||||
1. **Mode-tagged history.** The `history.log` status token gains an
|
||||
optional `:adv` suffix (`ok` / `ok:adv` / `err` / `err:adv`); `source`
|
||||
stays last + canonical so replay is unaffected. The in-memory ring
|
||||
(still `Vec<String>`) stores advanced entries in their `: `-prefixed
|
||||
simple-mode runnable form; recall **strips the `:` in advanced mode**
|
||||
and keeps it in simple; hydration reconstructs the prefix from the tag.
|
||||
App commands journal simple and are excluded from the ring's advanced
|
||||
flag, so they recall bare.
|
||||
|
||||
2. **Journaling relocation (the architecture change).** Success
|
||||
journaling **moved out of the worker** to the dispatch layer
|
||||
(`spawn_dsl_dispatch` / `run_replay` / the app-command sites), next to
|
||||
the already-top-level failure journaling — so the submission mode is in
|
||||
scope with no worker plumbing. `finalize_persistence` now writes only
|
||||
the **state** sources (yaml/csv); the journal write is **best-effort**
|
||||
(the command is already committed — consistent with the failure path).
|
||||
**Amends ADR-0015 §6** (history.log out of the worker tx; commit-db-last
|
||||
scopes yaml/csv/db only), **ADR-0034** (status tag + journaling
|
||||
location), **ADR-0040** (journal-write best-effort, not fatal).
|
||||
|
||||
**Two DA findings, both resolved:** (a) the app-command `advanced` flag
|
||||
must exclude app commands (else `: save as` diverges); (b) the spawn
|
||||
journals on `outcome.is_ok()`, so journaling is now **uniform** — read
|
||||
commands that didn't journal before (`show tables`/`show relationships`/
|
||||
`show indexes`, `show relationship <name>`, `explain`) now do, matching
|
||||
ADR-0034 §1. **User-confirmed** as the more-correct behaviour (harmless
|
||||
on replay — reads/`explain` don't mutate).
|
||||
|
||||
**Test migration:** 15 worker-level journaling tests were retired (the
|
||||
worker no longer journals — their yaml/csv/operation assertions were
|
||||
kept) and re-covered at the new layer: `history.rs` status-tag +
|
||||
`:`-reconstruct; `app.rs` recall matrix; the cross-session regression
|
||||
`advanced_command_journalled_then_hydrated_recalls_with_colon_in_simple`
|
||||
in `iteration6_resume_history`; the replay tests cover `run_replay`
|
||||
journaling.
|
||||
|
||||
Plan: `docs/plans/20260613-issue-30-top-of-chain-journaling.md`.
|
||||
|
||||
## §3. Next session — start here
|
||||
|
||||
The user's stated plan for the next session, in order:
|
||||
|
||||
1. **Pick up the ADR-0052 follow-up** (below).
|
||||
2. **Check for any newly-filed open issues** (`tea issues list --state
|
||||
open`) — none at handoff, but check fresh.
|
||||
3. **Then** take on remaining open tasks from the general requirements
|
||||
(`docs/requirements.md`) — see §5.
|
||||
|
||||
### The ADR-0052 follow-up — unwind the vestigial worker `source` plumbing
|
||||
|
||||
When journaling moved out of the worker, the `source` that the worker
|
||||
threaded purely for journaling became dead. To avoid orphaning the param
|
||||
across ~28 handlers, the refactor **left it in place** as vestigial:
|
||||
|
||||
- `finalize_persistence(conn, persistence, _source, changes)` — the
|
||||
`_source` param is now unused (kept so its ~28 callers still pass
|
||||
`source`, which they otherwise also use for `snapshot_then`).
|
||||
- `do_rebuild_from_text(conn, _persistence, _source, project_path)` —
|
||||
both `_persistence` and `_source` vestigial.
|
||||
- Three thin read-only wrappers in `db.rs` —
|
||||
`do_describe_table_request`, `do_query_data_request`,
|
||||
`do_run_select_request` — now just delegate to their non-`_request`
|
||||
twin (`do_describe_table` / `do_query_data` / `do_run_select`) with
|
||||
vestigial `_persistence` / `_source` params and one caller each
|
||||
(`db.rs` Request arms ~2409 / ~2749 / ~2759).
|
||||
|
||||
**The cleanup:** remove `_source` from `finalize_persistence` + drop the
|
||||
arg at its ~28 callers (the callers keep `source` for `snapshot_then`, so
|
||||
only the `finalize_persistence(...)` call loses the arg); remove the
|
||||
`_persistence`/`_source` params from `do_rebuild_from_text`; and inline
|
||||
the three `*_request` wrappers at their single call sites (replace
|
||||
`do_describe_table_request(conn, persistence, source, name)` with
|
||||
`do_describe_table(conn, &name)`, etc.), deleting the wrappers. Purely
|
||||
mechanical, compiler-guided, no behaviour change. Establish the green
|
||||
baseline first (`cargo test`), then verify nothing moved.
|
||||
|
||||
## §4. Carried-over follow-up (website branch, not `main`)
|
||||
|
||||
- **Website `seed` cast re-record** (from #34, handoff-68 §4) — still
|
||||
tracked on the `website` branch, not here. Likely redundant (full
|
||||
re-record sweep before publication).
|
||||
|
||||
## §5. Remaining roadmap — `docs/requirements.md` (next session's §3-step 3)
|
||||
|
||||
With the issue tracker empty, the next work comes from the document-based
|
||||
requirements. Open / partial items worth weighing (the user picks):
|
||||
|
||||
- **H2 `hint`** — the last A1 gap (contextual help for the current
|
||||
command); its own ADR. (`requirements.md` H2.)
|
||||
- **TT5 CI** — runs all tiers on Linux/macOS/Windows; no CI workflow yet
|
||||
(a `ci` branch reportedly exists — check its state first). Couples with
|
||||
**D1–D3** (cross-platform prebuilt binaries + Homebrew/Scoop).
|
||||
- **TT4 PTY (Tier-4)** — ADR-0008 specifies the PTY harness + four
|
||||
critical flows; still not wired (no PTY deps/tests).
|
||||
- **I1 multi-line input** (Ctrl-Enter submits, Enter inserts newline) and
|
||||
**I5 / B3 in-flight cancellation** (Ctrl-C cancels a running command).
|
||||
- **V4 session journal** — scrollable per-session log + Markdown export
|
||||
(the bigger UX project; own ADR).
|
||||
- **TU1 tutorial / lesson system** — design + ADR pending (acknowledged
|
||||
in scope).
|
||||
- Smaller partials: **C3a** modify relationship (drop+add covers it
|
||||
today), **C4** m:n convenience, **V3** ER-diagram export, the **NFR-***
|
||||
performance/visual targets (mostly unmeasured), **N4** global rolling
|
||||
history (OOS for v1).
|
||||
|
||||
No strong ordering — these are the user's call. Several need a new ADR
|
||||
(H2, V4, TU1); CI/release (TT5/D1–D3) is the most "shippable-product"
|
||||
track if that's the priority.
|
||||
|
||||
## §6. How to take over
|
||||
|
||||
1. Read handoffs 67 → 68 → 69, `CLAUDE.md`, `docs/requirements.md`.
|
||||
2. Confirm green baseline: `cargo test` (expect **2471 pass / 1 ignored**)
|
||||
+ `cargo clippy --all-targets` (clean).
|
||||
3. `tea issues list --state open` — pick up anything new first.
|
||||
4. Then the ADR-0052 follow-up (§3), then requirements (§5).
|
||||
5. Follow the project workflow: phased (requirements → divergent → eval →
|
||||
execute → verify), test-first, `/runda` + DA pass before every commit,
|
||||
ADR amendment for any decided-area change + the README index-upkeep
|
||||
rule, and confirm the commit message with the user before committing.
|
||||
6. Consider a `cargo sweep` at this milestone (`target/` grows across
|
||||
sessions; see CLAUDE.md "Build hygiene"). (`sweep.timestamp` was
|
||||
removed this session.)
|
||||
@@ -0,0 +1,247 @@
|
||||
# Plan — issue #30: mode-tagged history + top-of-chain journaling
|
||||
|
||||
**Status:** draft for `/runda` review (2026-06-13).
|
||||
**Issue:** #30 — advanced history reusable in simple mode (prepend `:`),
|
||||
and the bug: the `:` one-shot prefix is lost across sessions.
|
||||
**ADR:** ADR-0052 (new); amends ADR-0015 §6, ADR-0034, ADR-0040;
|
||||
references ADR-0003.
|
||||
|
||||
## 1. Goal & root cause
|
||||
|
||||
Two coupled needs, one root cause — **history entries carry no mode**:
|
||||
- **Bug:** the in-memory ring stores the raw `:select 1`, but the worker
|
||||
journals the *stripped* `select 1`, so cross-session the `:` is lost
|
||||
and the command recalls bare (unusable in simple mode).
|
||||
- **Feature:** persistent-advanced commands (`select 1` typed in advanced
|
||||
mode) can't be told apart from simple DSL, so they can't be offered
|
||||
back with a `:` in simple mode.
|
||||
|
||||
Fix: **record the submission mode per entry** (status tag `:adv`), keep
|
||||
the on-disk `source` canonical, and have **recall prepend/strip `:`** for
|
||||
the current mode.
|
||||
|
||||
## 2. The architecture insight (why this plan is shaped this way)
|
||||
|
||||
Journaling **success** lives deep in the worker: `finalize_persistence`
|
||||
(db.rs:3096-3099) writes `history.log` *inside the db transaction, before
|
||||
`tx.commit()`*, alongside yaml/csv — plus four no-op-skip sites and three
|
||||
read-only helpers. **Failure** journaling already lives at the top
|
||||
(runtime.rs:484-495, best-effort). Threading the mode *down* to the
|
||||
worker would mean ~30 `Request` variants + `Database` methods +
|
||||
`execute_command_typed` arms — because the journal write is far from
|
||||
where the mode is known.
|
||||
|
||||
So instead: **move success journaling up to the dispatch layer**, next to
|
||||
where failure journaling already is and where mode + outcome + source are
|
||||
all in scope. The mode then needs no plumbing. This is the correct
|
||||
separation anyway — `history.log` is an append-only *journal of what was
|
||||
typed*, not *state*; the state sources (yaml/csv/db) stay atomic in the
|
||||
worker.
|
||||
|
||||
### Semantic changes this entails (must be vetted)
|
||||
|
||||
1. **history.log leaves the worker transaction** (amends ADR-0015 §6).
|
||||
`commit-db-last` still governs yaml/csv/db (the state); the journal is
|
||||
written *after* the worker replies (i.e. after `tx.commit`), at the
|
||||
dispatch layer.
|
||||
2. **Success-journal write failure: fatal → best-effort** (amends
|
||||
ADR-0040). Today a failed `history.log` write on a *successful*
|
||||
command rolls the command back and shows a fatal banner. After: the
|
||||
command stays committed; the journal write is best-effort (logged +
|
||||
ignored), exactly like the failure path already is. The two journal
|
||||
paths become *consistent*.
|
||||
3. **Consequence:** on a rare journal-write failure (disk full /
|
||||
permissions) a successful command is applied but may be missing from
|
||||
`history.log` — not recallable next session, not replayable. The state
|
||||
(yaml/csv/db) is unaffected and consistent. This is a graceful
|
||||
degradation, not corruption, and is logged. (Today the same disk-full
|
||||
instead kills the app mid-command.)
|
||||
|
||||
**Open question for review/user:** is trading "fatal on journal-write
|
||||
failure" for "best-effort, command still succeeds" acceptable? The plan
|
||||
assumes **yes** (a journal is auxiliary; killing the app over it is worse
|
||||
UX). If not, journaling must stay coupled in the worker and we pay the
|
||||
~30-site mode plumbing instead.
|
||||
|
||||
## 3. On-disk format (mode tag in status — already chosen + partly built)
|
||||
|
||||
Record stays `<ts>|<status>|<source>`; the **status token** gains an
|
||||
optional `:adv` suffix (ADR-0052). `source` stays canonical so replay is
|
||||
unaffected.
|
||||
|
||||
| Submission | Success | Failure |
|
||||
|---|---|---|
|
||||
| Simple / app command | `ok` | `err` |
|
||||
| Advanced (SQL, persistent or one-shot) | `ok:adv` | `err:adv` |
|
||||
|
||||
**Done already** (history.rs / mod.rs):
|
||||
- `status_token(base, advanced)`, `parse_status(status) -> (is_ok, advanced)`.
|
||||
- `parse_record_source` reconstructs `": {cmd}"` for `:adv` records.
|
||||
- `parse_journal_record.status_is_ok` via `parse_status` (so `ok:adv` replays).
|
||||
- `append_history(text, advanced)`, `append_history_failure(text, advanced)`.
|
||||
|
||||
Back-compat: old `ok`/`err` logs → simple; nothing migrates.
|
||||
|
||||
## 4. In-memory ring & recall (app.rs) — the #30 behaviour
|
||||
|
||||
The ring stays `Vec<String>`. An **advanced** entry is stored in its
|
||||
`: `-prefixed simple-mode runnable form (matching the existing in-session
|
||||
one-shot ring); a **simple** entry bare. A leading `:` unambiguously
|
||||
marks advanced (simple DSL can never start with `:`).
|
||||
|
||||
- **`submit`** (app.rs:1704): compute `effective_input` + `submission_mode`,
|
||||
parse once for the app-command check (already done at 1751), then build
|
||||
the ring line. The **`advanced` flag excludes app commands** —
|
||||
`advanced = submission_mode.is_advanced() && !is_app_command` — because
|
||||
app commands (`undo`, `mode …`, `save as`, …) run in *any* mode and must
|
||||
**not** get a `:` on recall. Ring line: `": " + effective_input` if
|
||||
`advanced`, else `effective_input`; `push_history(&ring_line)`. (Today it
|
||||
pushes the raw `trimmed` *before* stripping; the reorder also drops a
|
||||
bare `:`, which executed nothing, and is what lets the app-command check
|
||||
precede the push.) `ExecuteDsl.source` stays the **canonical**
|
||||
`effective_input`.
|
||||
- *Why the app-command exclusion matters (DA finding):* without it,
|
||||
`: save as foo` (an app command via the one-shot) would store `: save
|
||||
as foo` in the ring but journal `save as foo` (app commands journal
|
||||
simple at their own sites, §5) — the very in-session-vs-cross-session
|
||||
divergence #30 is fixing, re-introduced for app commands. Excluding
|
||||
them keeps ring and disk agreeing (both bare).
|
||||
- **`history_back` / `history_forward`**: after cloning the stored entry
|
||||
into `self.input`, strip a leading `:` **iff `self.mode == Advanced`**
|
||||
(so an advanced entry runs as bare SQL in advanced mode, and as `: …`
|
||||
one-shot in simple mode). A small helper `recall_display(stored)`.
|
||||
- `seed_history` / `ProjectSwitched` payload: **unchanged** (`Vec<String>`);
|
||||
hydration already returns the `: `-prefixed form (§3).
|
||||
|
||||
Recall matrix:
|
||||
| entry \ current mode | Simple | Advanced |
|
||||
|---|---|---|
|
||||
| advanced (`: select 1`) | `: select 1` (one-shot) | `select 1` (SQL) |
|
||||
| simple (`create …`) | `create …` | `create …` |
|
||||
|
||||
## 5. Move success journaling worker → dispatch layer
|
||||
|
||||
**Remove** (worker stops journaling success):
|
||||
- `finalize_persistence` history write (db.rs:3096-3099). Keep yaml/csv.
|
||||
The now-unused `source` param: remove it + drop the arg at its ~30
|
||||
callers (mechanical, compiler-guided). (Handlers keep their own
|
||||
`source` for `snapshot_then`.)
|
||||
- The 4 no-op-skip `append_history` (db.rs:2267, 2311, 2524, 2560) — these
|
||||
outcomes (`SchemaSkipped` etc.) are `Ok` at the dispatch layer, so the
|
||||
new top-level journal covers them.
|
||||
- The 3 read-only helper `append_history` (db.rs:8372 show table, 9996
|
||||
show data, 10014 select) — `Ok(Query)`/`Ok(ShowList)` at the top.
|
||||
|
||||
**Add** (dispatch-layer journaling, all best-effort + logged):
|
||||
- **`spawn_dsl_dispatch`** (runtime.rs ~1433): pass `project_path` in;
|
||||
after `execute_command_typed`, `if outcome.is_ok() {
|
||||
Persistence::new(path).append_history(&source_for_journal,
|
||||
submission_mode.is_advanced()) }`. (Failures stay in the existing path,
|
||||
§6 — no double-journal, since Ok and Err are exclusive.)
|
||||
- **`run_replay`** (runtime.rs ~2540): after each line's
|
||||
`execute_command_typed`, `if outcome.is_ok() { append_history(
|
||||
&command_text, false) }` — replay is mode-agnostic, journalled
|
||||
**simple**. (Preserves ADR-0034 §3 "replayed sub-commands land in
|
||||
history"; a replayed advanced command re-journals without `:adv` — a
|
||||
documented OOS, not a regression: today it re-journals as plain `ok`.)
|
||||
- **`spawn_rebuild`** (runtime.rs ~503): after a successful rebuild,
|
||||
`append_history("rebuild"/source, false)`. (Rebuild journalled via
|
||||
`finalize_persistence` today; that write is gone, so add it here.)
|
||||
|
||||
**Unchanged** (already at the dispatch layer, app commands):
|
||||
- `perform_switch` (974: save-as/load/new) and `spawn_export` (1043) —
|
||||
already best-effort `append_history(&source)`; add the new `advanced`
|
||||
arg as `false` (app commands run in any mode → no `:` needed on recall;
|
||||
this also fixes the would-be "redundant `: undo`" — app commands
|
||||
journal **simple** because they're dispatched here, never via
|
||||
`ExecuteDsl`/the spawn).
|
||||
- `undo`/`redo`/`copy`/`help`/`quit`: not journalled today; unchanged.
|
||||
- The **`replay` command itself**: dispatched as `Action::Replay`, never
|
||||
reaches the spawn → not journalled (preserves the ADR-0034 §3 exclusion
|
||||
without extra work); nested `replay` skip in `run_replay` unchanged.
|
||||
|
||||
### DA-confirmed design choice: split, don't unify
|
||||
|
||||
Success journals in the spawn (`Ok` arm); **all** failures stay in the
|
||||
existing App→`JournalFailure`→runtime path (just gaining the mode).
|
||||
Considered and rejected: moving worker-rejection failures into the spawn
|
||||
too (to "unify"). It doesn't actually unify — parse failures never reach
|
||||
the spawn, so they'd stay in the App path regardless — and it adds a
|
||||
double-journal hazard (must also strip the App's `DslFailed`→
|
||||
`JournalFailure` emission). The split keeps the failure path **untouched
|
||||
in structure** (lowest risk); `Ok`/`Err` are exclusive so there is no
|
||||
double-journal. **Verified safe:** undo/redo never touches `history.log`
|
||||
(the snapshot copies db+yaml+csv only, undo.rs:15-16), and `snapshot_then`'s
|
||||
redo-clear keys on `source.is_some()`, independent of journaling — so
|
||||
removing the worker journal write does not perturb undo/snapshot at all.
|
||||
|
||||
## 6. Failure journaling — add the mode (location unchanged)
|
||||
|
||||
Keep both failure origins where they are (best-effort, dispatch/App
|
||||
layer); thread the mode so they tag `err:adv`:
|
||||
- **`Action::JournalFailure`** (action.rs:42): add `advanced: bool` (or
|
||||
`submission_mode`).
|
||||
- **`AppEvent::DslFailed`** (event.rs): add `submission_mode` (the
|
||||
worker-rejection path — the App can't recover the mode from an async
|
||||
reply otherwise).
|
||||
- **App**: the parse-failure path (`dispatch_dsl` Err arm) has
|
||||
`submission_mode` directly; the `DslFailed` handler reads it off the
|
||||
event. Both emit `JournalFailure { source, advanced }`.
|
||||
- **runtime.rs:492**: `append_history_failure(&source, advanced)`.
|
||||
|
||||
## 7. Tests
|
||||
|
||||
- **history.rs (Tier-1):** `status_token`/`parse_status` round-trip;
|
||||
`read_recent_sources` reconstructs `": …"` for `:adv` and leaves
|
||||
`ok`/`err` bare; `status_is_ok` true for `ok` & `ok:adv`; old-log
|
||||
back-compat.
|
||||
- **app.rs (Tier-1):** advanced submission stored `: `-prefixed; recall
|
||||
prepends in simple / strips in advanced; simple bare in both; bare `:`
|
||||
not stored; a parse-failure is still recallable; dedup/cap hold.
|
||||
- **iteration6_resume_history (Tier-3) — headline regression:** journal
|
||||
an advanced command (`append_history(text, true)`), hydrate, recall in
|
||||
simple → `: …`; and the full bug repro through `submit` + journal +
|
||||
hydrate if feasible.
|
||||
- **replay_command (Tier-3):** replayed commands still land in
|
||||
history.log (now via `run_replay`'s call); the `replay`-self-exclusion
|
||||
+ nested-skip still hold; advanced lines replay (status `ok:adv`
|
||||
treated as ok).
|
||||
- **Journaling relocation:** a success no longer fatals on a journal
|
||||
write failure (best-effort) — if cheaply testable; at minimum a worker
|
||||
test that previously asserted worker-side journaling is updated/removed.
|
||||
- **Update mechanical call sites:** `append_history(_, advanced)` /
|
||||
`append_history_failure(_, advanced)` at the db.rs inline tests
|
||||
(8372/9996/10014/11324 — likely now removed with the production sites),
|
||||
iteration6 (144-170), mod.rs (600).
|
||||
|
||||
## 8. ADR work
|
||||
|
||||
- **ADR-0052 (new):** the #30 feature + bug, the status-tag format, the
|
||||
`: `-prefixed ring + recall, AND the journaling relocation (it's the
|
||||
enabling refactor). Forks: status-tag format; unified scope;
|
||||
dispatch-layer journaling (best-effort).
|
||||
- **ADR-0015 §6 amendment:** history.log out of the worker transaction;
|
||||
commit-db-last now scopes yaml/csv/db; journal is a dispatch-layer
|
||||
best-effort side-record.
|
||||
- **ADR-0034 amendment:** journaling location (dispatch layer);
|
||||
status-field `:adv` extension (it already reserved the field).
|
||||
- **ADR-0040 amendment:** a success-path journal-write failure is no
|
||||
longer fatal — best-effort, consistent with the failure path.
|
||||
- README index upkeep for every ADR touched.
|
||||
|
||||
## 9. Risks / watch-list
|
||||
|
||||
- **Double-journaling**: ensure Ok→spawn and Err→App-path stay exclusive;
|
||||
do NOT also leave a worker journal.
|
||||
- **Under/over-journaling vs today**: top-level "journal on every Ok"
|
||||
must match today's "journal every command with a source" — verified:
|
||||
reads + skips are Ok outcomes, internal ops never reach the spawn.
|
||||
- **finalize_persistence source-param removal**: 30 mechanical call-site
|
||||
edits; compiler-guided.
|
||||
- **Replay re-journal mode fidelity**: replayed advanced commands
|
||||
re-journal as simple (OOS, not a regression).
|
||||
- **best-effort journal**: rare write-failure leaves a command unjournaled
|
||||
(logged). User decision (§2 open question).
|
||||
- **app-command mode**: journalled simple by construction (dispatched
|
||||
outside the spawn) — this is correct (they run in any mode), and
|
||||
resolves the earlier "redundant `: undo`" worry.
|
||||
@@ -41,6 +41,11 @@ pub enum Action {
|
||||
/// §4). `source` is the original user-typed text.
|
||||
JournalFailure {
|
||||
source: String,
|
||||
/// Whether the failed submission was advanced (ADR-0052): tags the
|
||||
/// `err` record `err:adv` so a failed advanced command hydrates in
|
||||
/// its `:`-prefixed form, recallable in simple mode. App commands
|
||||
/// (mode-agnostic) are `false`.
|
||||
advanced: bool,
|
||||
},
|
||||
/// User issued the `rebuild` app-level command (ADR-0015
|
||||
/// §7, §11). Runtime computes a summary from
|
||||
|
||||
+148
-20
@@ -646,6 +646,17 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the user is currently browsing a recalled history entry
|
||||
/// (Up/Down navigation, unedited). Exposes the private
|
||||
/// `history_cursor` predicate so the context-aware status strip
|
||||
/// (ADR-0051) can select its history-navigation state. Editing the
|
||||
/// recalled line ends navigation (`cancel_history_navigation`), so
|
||||
/// this is `false` again the moment the user types.
|
||||
#[must_use]
|
||||
pub const fn is_browsing_history(&self) -> bool {
|
||||
self.history_cursor.is_some()
|
||||
}
|
||||
|
||||
/// The input view the **live-feedback** walkers (completion, ambient
|
||||
/// hint, validity verdict, highlight overlays) should see, plus the
|
||||
/// byte offset stripped from the front and the cursor mapped into the
|
||||
@@ -863,13 +874,16 @@ impl App {
|
||||
error,
|
||||
facts,
|
||||
source,
|
||||
advanced,
|
||||
} => {
|
||||
self.handle_dsl_failure(&command, error, facts);
|
||||
// ADR-0034 §1/§2: an execution failure is journalled
|
||||
// `err` so it is recallable across sessions (the
|
||||
// worker only journals successful commands). The App
|
||||
// emits the intent; the runtime does the append.
|
||||
vec![Action::JournalFailure { source }]
|
||||
// emits the intent; the runtime does the append. The
|
||||
// mode rides along (ADR-0052) so an advanced failure
|
||||
// tags `err:adv`.
|
||||
vec![Action::JournalFailure { source, advanced }]
|
||||
}
|
||||
AppEvent::TablesRefreshed(tables) => {
|
||||
trace!(count = tables.len(), "tables refreshed");
|
||||
@@ -1637,11 +1651,27 @@ impl App {
|
||||
Some(i) => i - 1,
|
||||
};
|
||||
self.history_cursor = Some(next_index);
|
||||
self.input = self.history[next_index].clone();
|
||||
let stored = self.history[next_index].clone();
|
||||
self.input = self.recall_display(&stored);
|
||||
self.input_cursor = self.input.len();
|
||||
self.input_scroll_offset = 0;
|
||||
}
|
||||
|
||||
/// The display form of a stored history entry for the current mode
|
||||
/// (ADR-0052, issue #30). An advanced entry is stored in its
|
||||
/// `:`-prefixed simple-mode runnable form; in **advanced** mode the
|
||||
/// `:` is stripped so it runs as bare SQL, while in **simple** mode it
|
||||
/// stays prefixed and runs via the one-shot escape. A simple entry
|
||||
/// (never starting with `:`) is returned unchanged in either mode.
|
||||
fn recall_display(&self, stored: &str) -> String {
|
||||
if self.mode == Mode::Advanced
|
||||
&& let Some(rest) = stored.strip_prefix(':')
|
||||
{
|
||||
return rest.trim_start().to_string();
|
||||
}
|
||||
stored.to_string()
|
||||
}
|
||||
|
||||
/// Move forwards in history (towards newer entries; eventually
|
||||
/// returning to the user's saved draft).
|
||||
fn history_forward(&mut self) {
|
||||
@@ -1650,7 +1680,8 @@ impl App {
|
||||
};
|
||||
if i + 1 < self.history.len() {
|
||||
self.history_cursor = Some(i + 1);
|
||||
self.input = self.history[i + 1].clone();
|
||||
let stored = self.history[i + 1].clone();
|
||||
self.input = self.recall_display(&stored);
|
||||
} else {
|
||||
// Past the most recent entry — restore the draft and
|
||||
// exit navigation mode.
|
||||
@@ -1698,10 +1729,6 @@ impl App {
|
||||
if trimmed.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Record the original (trimmed) line in history regardless
|
||||
// of whether it parses, so users can recall and edit
|
||||
// typo'd commands.
|
||||
self.push_history(trimmed);
|
||||
|
||||
// `:` one-shot escape: in simple mode, a leading `:` means
|
||||
// treat *this single submission* as advanced. The persistent
|
||||
@@ -1718,6 +1745,9 @@ impl App {
|
||||
};
|
||||
|
||||
if effective_input.is_empty() {
|
||||
// A bare `:` (one-shot with nothing after it) executes
|
||||
// nothing and is not recorded — the push moved below the
|
||||
// strip (ADR-0052), so it no longer lands in history.
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
@@ -1728,16 +1758,31 @@ impl App {
|
||||
"submit"
|
||||
);
|
||||
|
||||
// Parse-first: app-level commands and DSL commands now
|
||||
// share the chumsky parser (per the round-5 refactor).
|
||||
// App commands work in both modes — they're not gated by
|
||||
// `effective_mode`. Anything that parses to a non-App
|
||||
// variant falls through to the existing mode-specific
|
||||
// path: simple → DSL execution; advanced → SQL placeholder.
|
||||
// Anything that fails to parse falls through too — the
|
||||
// simple-mode path renders the friendly parse error, the
|
||||
// advanced-mode path renders the SQL placeholder.
|
||||
if let Ok(Command::App(app_cmd)) = parse_command(&effective_input) {
|
||||
// Parse-first: app-level commands and DSL commands share the
|
||||
// parser. App commands work in both modes — they're not gated by
|
||||
// `effective_mode`. Anything that parses to a non-App variant (or
|
||||
// fails to parse) falls through to the mode-specific path.
|
||||
let parsed = parse_command(&effective_input);
|
||||
|
||||
// ADR-0052 (issue #30): record the command for cross-mode recall.
|
||||
// An **advanced** (SQL) command is stored in its `:`-prefixed
|
||||
// simple-mode runnable form, so it can be recalled and re-run in
|
||||
// simple mode (recall strips the `:` again in advanced mode). A
|
||||
// simple command — and **any app command**, which runs in either
|
||||
// mode and so must not gain a `:` — is stored bare. Recorded
|
||||
// regardless of whether it parses, so typo'd commands stay
|
||||
// recallable. The canonical (un-prefixed) text is what reaches
|
||||
// the journal via `ExecuteDsl.source`.
|
||||
let is_app = matches!(&parsed, Ok(Command::App(_)));
|
||||
let advanced = submission_mode.is_advanced() && !is_app;
|
||||
let ring_line = if advanced {
|
||||
format!(": {effective_input}")
|
||||
} else {
|
||||
effective_input.clone()
|
||||
};
|
||||
self.push_history(&ring_line);
|
||||
|
||||
if let Ok(Command::App(app_cmd)) = parsed {
|
||||
return self.dispatch_app_command(app_cmd, &effective_input);
|
||||
}
|
||||
|
||||
@@ -1950,6 +1995,7 @@ impl App {
|
||||
self.note_error(note);
|
||||
return vec![Action::JournalFailure {
|
||||
source: input.to_string(),
|
||||
advanced: submission_mode.is_advanced(),
|
||||
}];
|
||||
}
|
||||
// Issue #17: simple-mode (DSL) counterpart. A wrong-count
|
||||
@@ -1977,6 +2023,7 @@ impl App {
|
||||
self.note_error(render_usage_block(input, mode));
|
||||
return vec![Action::JournalFailure {
|
||||
source: input.to_string(),
|
||||
advanced: submission_mode.is_advanced(),
|
||||
}];
|
||||
}
|
||||
self.push_output(OutputLine::echo(input, mode));
|
||||
@@ -2063,6 +2110,7 @@ impl App {
|
||||
// append; the App only emits the intent.
|
||||
vec![Action::JournalFailure {
|
||||
source: input.to_string(),
|
||||
advanced: submission_mode.is_advanced(),
|
||||
}]
|
||||
}
|
||||
}
|
||||
@@ -5482,6 +5530,7 @@ mod tests {
|
||||
},
|
||||
facts: crate::friendly::FailureContext::default(),
|
||||
source: String::new(),
|
||||
advanced: false,
|
||||
});
|
||||
let last = app.output.back().unwrap();
|
||||
assert_eq!(last.kind, OutputKind::Error);
|
||||
@@ -5540,6 +5589,7 @@ mod tests {
|
||||
error: err,
|
||||
facts,
|
||||
source: String::new(),
|
||||
advanced: false,
|
||||
});
|
||||
let body = app
|
||||
.output
|
||||
@@ -5589,6 +5639,7 @@ mod tests {
|
||||
error: err,
|
||||
facts,
|
||||
source: String::new(),
|
||||
advanced: false,
|
||||
});
|
||||
let body = app
|
||||
.output
|
||||
@@ -5621,6 +5672,7 @@ mod tests {
|
||||
error: err(),
|
||||
facts: crate::friendly::FailureContext::default(),
|
||||
source: String::new(),
|
||||
advanced: false,
|
||||
});
|
||||
let verbose_text = app
|
||||
.output
|
||||
@@ -5641,6 +5693,7 @@ mod tests {
|
||||
error: err(),
|
||||
facts: crate::friendly::FailureContext::default(),
|
||||
source: String::new(),
|
||||
advanced: false,
|
||||
});
|
||||
let short_text = app
|
||||
.output
|
||||
@@ -6316,7 +6369,7 @@ mod tests {
|
||||
assert!(
|
||||
matches!(
|
||||
actions.as_slice(),
|
||||
[Action::JournalFailure { source }] if source == "florp glorp"
|
||||
[Action::JournalFailure { source, .. }] if source == "florp glorp"
|
||||
),
|
||||
"expected JournalFailure for the typo'd line; got {actions:?}",
|
||||
);
|
||||
@@ -6339,11 +6392,12 @@ mod tests {
|
||||
},
|
||||
facts: crate::friendly::FailureContext::default(),
|
||||
source: "drop table Ghost".to_string(),
|
||||
advanced: false,
|
||||
});
|
||||
assert!(
|
||||
matches!(
|
||||
actions.as_slice(),
|
||||
[Action::JournalFailure { source }] if source == "drop table Ghost"
|
||||
[Action::JournalFailure { source, .. }] if source == "drop table Ghost"
|
||||
),
|
||||
"expected JournalFailure carrying the source; got {actions:?}",
|
||||
);
|
||||
@@ -6472,6 +6526,80 @@ mod tests {
|
||||
assert_eq!(app.input, "drop table AX");
|
||||
}
|
||||
|
||||
// ---- ADR-0052 (issue #30): mode-aware history recall ----
|
||||
|
||||
#[test]
|
||||
fn one_shot_advanced_command_recalls_with_colon_in_simple_mode() {
|
||||
// The bug: a `:`-one-shot advanced command must recall WITH the
|
||||
// `:` so it re-runs in simple mode (in-session and, via the
|
||||
// `:`-prefixed ring form, across sessions too).
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, ": select 1");
|
||||
submit(&mut app);
|
||||
app.update(key(KeyCode::Up));
|
||||
assert_eq!(app.input, ": select 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistent_advanced_command_recalls_with_colon_back_in_simple_mode() {
|
||||
// The feature: a command typed in *persistent* advanced mode
|
||||
// recalls into simple mode with a `:` so it stays runnable.
|
||||
let mut app = App::new();
|
||||
app.mode = Mode::Advanced;
|
||||
type_str(&mut app, "select 1");
|
||||
submit(&mut app);
|
||||
// Switch back to simple and recall.
|
||||
app.mode = Mode::Simple;
|
||||
app.update(key(KeyCode::Up));
|
||||
assert_eq!(app.input, ": select 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_command_recalls_bare_in_advanced_mode() {
|
||||
// In advanced mode the stored `:`-prefix is stripped so it runs
|
||||
// as bare SQL.
|
||||
let mut app = App::new();
|
||||
app.mode = Mode::Advanced;
|
||||
type_str(&mut app, "select 1");
|
||||
submit(&mut app);
|
||||
app.update(key(KeyCode::Up));
|
||||
assert_eq!(app.input, "select 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_command_recalls_bare_in_either_mode() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "drop table T");
|
||||
submit(&mut app);
|
||||
app.update(key(KeyCode::Up));
|
||||
assert_eq!(app.input, "drop table T");
|
||||
app.mode = Mode::Advanced;
|
||||
app.update(key(KeyCode::Down)); // back to draft
|
||||
app.update(key(KeyCode::Up));
|
||||
assert_eq!(app.input, "drop table T");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_command_recalls_bare_even_when_typed_with_colon() {
|
||||
// An app command runs in any mode, so it must NOT gain a `:` on
|
||||
// recall even when entered via the one-shot escape.
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, ": mode advanced");
|
||||
submit(&mut app);
|
||||
app.update(key(KeyCode::Up));
|
||||
assert_eq!(app.input, "mode advanced");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_colon_is_not_recorded_in_history() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, ":");
|
||||
submit(&mut app);
|
||||
// Nothing recallable.
|
||||
app.update(key(KeyCode::Up));
|
||||
assert_eq!(app.input, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_column_with_text_type_emits_execute_action() {
|
||||
let mut app = App::new();
|
||||
|
||||
@@ -2262,12 +2262,10 @@ fn handle_request(
|
||||
// (`show table`), it belongs in the complete journal
|
||||
// (ADR-0034). ADR-0035 §4.
|
||||
if if_not_exists && user_table_exists(conn, &name).unwrap_or(false) {
|
||||
let result = do_describe_table(conn, &name).and_then(|desc| {
|
||||
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
|
||||
p.append_history(text).map_err(DbError::from_persistence)?;
|
||||
}
|
||||
Ok(CreateOutcome::Skipped(desc))
|
||||
});
|
||||
// ADR-0052: journaling moved to the dispatch layer; this
|
||||
// no-op skip is an `Ok` outcome there and is journalled by
|
||||
// the spawn like any other.
|
||||
let result = do_describe_table(conn, &name).map(CreateOutcome::Skipped);
|
||||
let _ = reply.send(result);
|
||||
} else {
|
||||
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
|
||||
@@ -2306,12 +2304,8 @@ fn handle_request(
|
||||
// line is still journalled — like the `CREATE TABLE IF NOT
|
||||
// EXISTS` skip and other no-ops (ADR-0034). ADR-0035 §4.
|
||||
if if_exists && !user_table_exists(conn, &name).unwrap_or(false) {
|
||||
let result = (|| {
|
||||
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
|
||||
p.append_history(text).map_err(DbError::from_persistence)?;
|
||||
}
|
||||
Ok(DropOutcome::Skipped)
|
||||
})();
|
||||
// ADR-0052: journaling moved to the dispatch layer.
|
||||
let result: Result<DropOutcome, DbError> = Ok(DropOutcome::Skipped);
|
||||
let _ = reply.send(result);
|
||||
} else {
|
||||
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
|
||||
@@ -2519,12 +2513,9 @@ fn handle_request(
|
||||
// ADR-0035 §4). Existence uses the same user-index lookup as
|
||||
// `do_drop_index` (`sql IS NOT NULL`).
|
||||
if if_exists && !index_exists(conn, &name, true).unwrap_or(false) {
|
||||
let result = (|| {
|
||||
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
|
||||
p.append_history(text).map_err(DbError::from_persistence)?;
|
||||
}
|
||||
Ok(DropIndexOutcome::Skipped)
|
||||
})();
|
||||
// ADR-0052: journaling moved to the dispatch layer.
|
||||
let result: Result<DropIndexOutcome, DbError> =
|
||||
Ok(DropIndexOutcome::Skipped);
|
||||
let _ = reply.send(result);
|
||||
} else {
|
||||
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
|
||||
@@ -2555,12 +2546,9 @@ fn handle_request(
|
||||
// hits `do_add_index`'s redundant-set refusal (ADR-0025).
|
||||
let resolved = resolve_index_name(name.as_deref(), &table, &columns);
|
||||
if if_not_exists && index_exists(conn, &resolved, false).unwrap_or(false) {
|
||||
let result = (|| {
|
||||
if let (Some(p), Some(text)) = (persistence, source.as_deref()) {
|
||||
p.append_history(text).map_err(DbError::from_persistence)?;
|
||||
}
|
||||
Ok(CreateIndexOutcome::Skipped(resolved.clone()))
|
||||
})();
|
||||
// ADR-0052: journaling moved to the dispatch layer.
|
||||
let result: Result<CreateIndexOutcome, DbError> =
|
||||
Ok(CreateIndexOutcome::Skipped(resolved));
|
||||
let _ = reply.send(result);
|
||||
} else {
|
||||
snapshot_then(snap, batch, conn, source.as_deref(), reply, || {
|
||||
@@ -3065,10 +3053,21 @@ struct Changes {
|
||||
/// Read-only requests (no schema change, no row writes, no
|
||||
/// drops) still use this to append `history.log` if `source`
|
||||
/// is set; they pass an empty `Changes`.
|
||||
// Persist the **state** sources (project.yaml + data/*.csv) for a
|
||||
// committed mutation, inside the worker transaction (ADR-0015 §6
|
||||
// commit-db-last). `history.log` is NOT written here — ADR-0052 moved
|
||||
// journaling to the dispatch layer (runtime), so the command's mode is
|
||||
// available without plumbing it through the worker, and a journal-write
|
||||
// failure no longer rolls back a committed command (it is best-effort,
|
||||
// like the failure path).
|
||||
fn finalize_persistence(
|
||||
conn: &Connection,
|
||||
persistence: Option<&Persistence>,
|
||||
source: Option<&str>,
|
||||
// Vestigial since ADR-0052 (the `history.log` write that used it moved
|
||||
// to the dispatch layer). Retained so the ~28 worker handlers that
|
||||
// thread `source` to here keep a use for it, rather than orphaning the
|
||||
// param across all of them; a later cleanup could unwind that plumbing.
|
||||
_source: Option<&str>,
|
||||
changes: &Changes,
|
||||
) -> Result<(), DbError> {
|
||||
let Some(p) = persistence else {
|
||||
@@ -3093,10 +3092,6 @@ fn finalize_persistence(
|
||||
p.delete_table_data(table)
|
||||
.map_err(DbError::from_persistence)?;
|
||||
}
|
||||
if let Some(text) = source {
|
||||
p.append_history(text)
|
||||
.map_err(DbError::from_persistence)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -8361,18 +8356,18 @@ fn do_drop_index(
|
||||
/// Read-only wrapper around `do_describe_table` that runs an
|
||||
/// auxiliary `history.log` append for user-issued
|
||||
/// `show table` commands.
|
||||
// ADR-0052: journaling moved to the dispatch layer, so this read-only
|
||||
// `show table` wrapper no longer appends to `history.log` — the spawn
|
||||
// journals the `Ok` outcome. Kept as a thin delegate (a later cleanup
|
||||
// could inline `do_describe_table` at the one call site); `_persistence`
|
||||
// / `_source` are vestigial.
|
||||
fn do_describe_table_request(
|
||||
conn: &Connection,
|
||||
persistence: Option<&Persistence>,
|
||||
source: Option<&str>,
|
||||
_persistence: Option<&Persistence>,
|
||||
_source: Option<&str>,
|
||||
name: &str,
|
||||
) -> Result<TableDescription, DbError> {
|
||||
let description = do_describe_table(conn, name)?;
|
||||
if let (Some(p), Some(text)) = (persistence, source) {
|
||||
p.append_history(text)
|
||||
.map_err(DbError::from_persistence)?;
|
||||
}
|
||||
Ok(description)
|
||||
do_describe_table(conn, name)
|
||||
}
|
||||
|
||||
fn do_describe_table(conn: &Connection, name: &str) -> Result<TableDescription, DbError> {
|
||||
@@ -9981,40 +9976,32 @@ fn do_delete(
|
||||
})
|
||||
}
|
||||
|
||||
/// Read-only wrapper that adds the `history.log` append for
|
||||
/// `show data` user commands.
|
||||
/// Read-only `show data` wrapper. ADR-0052: journaling moved to the
|
||||
/// dispatch layer (`_persistence` / `_source` vestigial).
|
||||
fn do_query_data_request(
|
||||
conn: &Connection,
|
||||
persistence: Option<&Persistence>,
|
||||
source: Option<&str>,
|
||||
_persistence: Option<&Persistence>,
|
||||
_source: Option<&str>,
|
||||
table: &str,
|
||||
filter: Option<&Expr>,
|
||||
limit: Option<u64>,
|
||||
) -> Result<DataResult, DbError> {
|
||||
let data = do_query_data(conn, table, filter, limit)?;
|
||||
if let (Some(p), Some(text)) = (persistence, source) {
|
||||
p.append_history(text)
|
||||
.map_err(DbError::from_persistence)?;
|
||||
}
|
||||
Ok(data)
|
||||
// ADR-0052: journaling moved to the dispatch layer (`_persistence` /
|
||||
// `_source` vestigial; the spawn journals the `Ok` outcome).
|
||||
do_query_data(conn, table, filter, limit)
|
||||
}
|
||||
|
||||
/// Worker handler for `Request::RunSelect` (ADR-0030 §6,
|
||||
/// ADR-0031). Mirrors `do_query_data_request`: run the
|
||||
/// statement, append the literal line to `history.log` so a
|
||||
/// Worker handler for `Request::RunSelect` (ADR-0030 §6, ADR-0031).
|
||||
/// ADR-0052: journaling moved to the dispatch layer, so this no longer
|
||||
/// appends to `history.log` — the spawn journals the literal line so a
|
||||
/// replay re-runs it (ADR-0030 §11).
|
||||
fn do_run_select_request(
|
||||
conn: &Connection,
|
||||
persistence: Option<&Persistence>,
|
||||
source: Option<&str>,
|
||||
_persistence: Option<&Persistence>,
|
||||
_source: Option<&str>,
|
||||
sql: &str,
|
||||
) -> Result<DataResult, DbError> {
|
||||
let data = do_run_select(conn, sql)?;
|
||||
if let (Some(p), Some(text)) = (persistence, source) {
|
||||
p.append_history(text)
|
||||
.map_err(DbError::from_persistence)?;
|
||||
}
|
||||
Ok(data)
|
||||
do_run_select(conn, sql)
|
||||
}
|
||||
|
||||
/// Currently-stored non-NULL values of one column, for shortid
|
||||
@@ -11119,8 +11106,10 @@ fn read_relationships_inbound(
|
||||
/// violation aborts with a fatal error.
|
||||
fn do_rebuild_from_text(
|
||||
conn: &Connection,
|
||||
persistence: Option<&Persistence>,
|
||||
source: Option<&str>,
|
||||
// Vestigial since ADR-0052: `rebuild` is journalled at the dispatch
|
||||
// layer (`spawn_rebuild`), not here.
|
||||
_persistence: Option<&Persistence>,
|
||||
_source: Option<&str>,
|
||||
project_path: &Path,
|
||||
) -> Result<(), DbError> {
|
||||
debug!(path = %project_path.display(), "rebuild_from_text");
|
||||
@@ -11320,10 +11309,8 @@ fn do_rebuild_from_text(
|
||||
// 7. Append `history.log` if this rebuild was
|
||||
// user-initiated (the silent on-load case has
|
||||
// `source = None`).
|
||||
if let (Some(p), Some(text)) = (persistence, source) {
|
||||
p.append_history(text)
|
||||
.map_err(DbError::from_persistence)?;
|
||||
}
|
||||
// ADR-0052: `rebuild` is journalled at the dispatch layer
|
||||
// (`spawn_rebuild`), not here — journaling left the worker.
|
||||
|
||||
tx.commit().map_err(DbError::from_rusqlite)?;
|
||||
Ok(())
|
||||
|
||||
@@ -161,6 +161,11 @@ pub enum AppEvent {
|
||||
/// commands, so an execution failure would otherwise be
|
||||
/// lost across sessions.
|
||||
source: String,
|
||||
/// Whether the rejected command was submitted in an advanced
|
||||
/// effective mode (ADR-0052): threaded so the App can tag the
|
||||
/// `err` record `err:adv` and the failed advanced command
|
||||
/// hydrates in its `:`-prefixed, simple-mode-recallable form.
|
||||
advanced: bool,
|
||||
},
|
||||
/// Refreshed list of tables in the database.
|
||||
TablesRefreshed(Vec<String>),
|
||||
|
||||
+13
-5
@@ -446,6 +446,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("undo.redo_failed", &["error"]),
|
||||
// ---- Status bar + panels ----
|
||||
("panel.hint_empty", &[]),
|
||||
("panel.hint_mode_advanced", &[]),
|
||||
("panel.hint_title", &[]),
|
||||
("panel.output_title", &[]),
|
||||
("panel.relationships_empty", &[]),
|
||||
@@ -462,18 +463,25 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("save.title_as", &[]),
|
||||
("save.title_save", &[]),
|
||||
// ---- Shortcut hint labels ----
|
||||
("shortcut.advanced_once", &[]),
|
||||
("shortcut.back_to_list", &[]),
|
||||
("shortcut.browse", &[]),
|
||||
("shortcut.browse_path", &[]),
|
||||
("shortcut.cancel", &[]),
|
||||
("shortcut.cancel_one_shot", &[]),
|
||||
("shortcut.clear", &[]),
|
||||
("shortcut.complete", &[]),
|
||||
("shortcut.confirm", &[]),
|
||||
("shortcut.cycle", &[]),
|
||||
("shortcut.del_word", &[]),
|
||||
("shortcut.history", &[]),
|
||||
("shortcut.home_end", &[]),
|
||||
("shortcut.load", &[]),
|
||||
("shortcut.nav", &[]),
|
||||
("shortcut.next_pane", &[]),
|
||||
("shortcut.no", &[]),
|
||||
("shortcut.quit", &[]),
|
||||
("shortcut.run", &[]),
|
||||
("shortcut.scroll", &[]),
|
||||
("shortcut.select", &[]),
|
||||
("shortcut.submit", &[]),
|
||||
("shortcut.switch", &[]),
|
||||
("shortcut.to_input", &[]),
|
||||
("shortcut.yes", &[]),
|
||||
// ---- mode / messages banners ----
|
||||
("messages.set_short", &[]),
|
||||
|
||||
@@ -883,14 +883,21 @@ panel:
|
||||
relationships_title: "Relationships"
|
||||
relationships_empty: "(none)"
|
||||
hint_empty: "Type a command — press Tab for options, `help` for a list"
|
||||
# Mode-discovery pointer appended to the empty-input hint in SIMPLE
|
||||
# mode (ADR-0051): the `mode advanced` switch left the keybinding
|
||||
# strip, so the hint advertises it. Leading separator continues the
|
||||
# prompt line. Advanced mode shows no pointer — users know how they
|
||||
# got there, and `help` covers the way back.
|
||||
hint_mode_advanced: " · `mode advanced` for SQL"
|
||||
# Panel titles for the output and hint panels (rendered inside
|
||||
# the rounded border, hence the leading/trailing space).
|
||||
output_title: "Output"
|
||||
hint_title: "Hint"
|
||||
|
||||
# ---- Shortcut hints (paired with key names in the bottom bar) -------
|
||||
# The bottom strip is keystrokes-only and state-aware (ADR-0051). Labels
|
||||
# pair with a key name in the renderer (e.g. `Enter` + `run`).
|
||||
shortcut:
|
||||
submit: "submit"
|
||||
confirm: "confirm"
|
||||
cancel: "cancel"
|
||||
yes: "Yes"
|
||||
@@ -899,10 +906,19 @@ shortcut:
|
||||
select: "select"
|
||||
browse_path: "browse path"
|
||||
back_to_list: "back to list"
|
||||
switch: "switch"
|
||||
advanced_once: "advanced once"
|
||||
cancel_one_shot: "cancel one-shot"
|
||||
quit: "quit"
|
||||
# Status-strip labels (ADR-0051, issue #27).
|
||||
run: "run"
|
||||
nav: "sidebar"
|
||||
next_pane: "next pane"
|
||||
scroll: "scroll"
|
||||
to_input: "input"
|
||||
cycle: "cycle"
|
||||
browse: "browse"
|
||||
clear: "clear"
|
||||
complete: "complete"
|
||||
history: "history"
|
||||
home_end: "home/end"
|
||||
del_word: "del word"
|
||||
|
||||
# ---- mode / messages banners (app-level commands) -------------------
|
||||
mode:
|
||||
|
||||
+112
-4
@@ -28,7 +28,35 @@ use super::PersistenceError;
|
||||
pub(super) const STATUS_OK: &str = "ok";
|
||||
pub(super) const STATUS_ERR: &str = "err";
|
||||
|
||||
/// Format a successful-command record. Pure; no I/O.
|
||||
/// The optional status suffix marking an advanced-mode submission
|
||||
/// (ADR-0052, issue #30): `ok:adv` / `err:adv`. Recorded so that
|
||||
/// hydration can reconstruct the `:`-prefixed runnable form of an
|
||||
/// advanced command, making advanced history reusable in simple mode.
|
||||
pub(super) const ADV_SUFFIX: &str = "adv";
|
||||
|
||||
/// Build the status token for a `base` (`ok`/`err`) and submission mode.
|
||||
pub(super) fn status_token(base: &str, advanced: bool) -> String {
|
||||
if advanced {
|
||||
format!("{base}:{ADV_SUFFIX}")
|
||||
} else {
|
||||
base.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a status token into `(is_ok, advanced)` (ADR-0052). The base
|
||||
/// (`ok` ⇒ replayable, anything else ⇒ skip) precedes an optional
|
||||
/// `:adv` mode suffix. An unknown base degrades to `(false, _)`, so
|
||||
/// replay skips it rather than mis-running it.
|
||||
pub(super) fn parse_status(status: &str) -> (bool, bool) {
|
||||
let (base, suffix) = status.split_once(':').unwrap_or((status, ""));
|
||||
(base == STATUS_OK, suffix == ADV_SUFFIX)
|
||||
}
|
||||
|
||||
/// Format a successful-command record. Pure; no I/O. (Simple-mode
|
||||
/// convenience used by tests; production threads the mode through
|
||||
/// [`format_record_with_status`] + [`status_token`], so this is
|
||||
/// test-only since ADR-0052.)
|
||||
#[cfg(test)]
|
||||
pub(super) fn format_record(command_text: &str, timestamp_iso: String) -> String {
|
||||
format_record_with_status(command_text, timestamp_iso, STATUS_OK)
|
||||
}
|
||||
@@ -100,9 +128,20 @@ fn parse_record_source(line: &str) -> Option<String> {
|
||||
// characters) is preserved.
|
||||
let mut parts = line.splitn(3, '|');
|
||||
let _ts = parts.next()?;
|
||||
let _status = parts.next()?;
|
||||
let status = parts.next()?;
|
||||
let source = parts.next()?;
|
||||
Some(unescape_command(source))
|
||||
let (_is_ok, advanced) = parse_status(status);
|
||||
let command = unescape_command(source);
|
||||
// ADR-0052: an advanced record is hydrated in its `:`-prefixed
|
||||
// simple-mode runnable form, so cross-session recall matches the
|
||||
// in-session ring (and recall strips the `:` again in advanced
|
||||
// mode). A simple record hydrates bare. Old `ok`/`err` logs have no
|
||||
// `:adv` suffix → read as simple, unchanged.
|
||||
Some(if advanced {
|
||||
format!(": {command}")
|
||||
} else {
|
||||
command
|
||||
})
|
||||
}
|
||||
|
||||
/// A parsed journal record (ADR-0034 §3). `source` is already
|
||||
@@ -129,8 +168,11 @@ pub(super) fn parse_journal_record(line: &str) -> Option<JournalRecord> {
|
||||
if !looks_like_iso8601(ts) {
|
||||
return None;
|
||||
}
|
||||
// ADR-0052: the status may carry a `:adv` mode suffix; replayability
|
||||
// keys off the base token only (`ok` / `ok:adv` are both ok).
|
||||
let (status_is_ok, _advanced) = parse_status(status);
|
||||
Some(JournalRecord {
|
||||
status_is_ok: status == STATUS_OK,
|
||||
status_is_ok,
|
||||
source: unescape_command(source),
|
||||
})
|
||||
}
|
||||
@@ -436,4 +478,70 @@ mod tests {
|
||||
let body = fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(body, "first|ok|a\nsecond|ok|b\n");
|
||||
}
|
||||
|
||||
// ---- ADR-0052 (issue #30): mode tag in the status field ----
|
||||
|
||||
#[test]
|
||||
fn status_token_builds_and_parses_the_adv_suffix() {
|
||||
assert_eq!(status_token(STATUS_OK, false), "ok");
|
||||
assert_eq!(status_token(STATUS_OK, true), "ok:adv");
|
||||
assert_eq!(status_token(STATUS_ERR, true), "err:adv");
|
||||
assert_eq!(parse_status("ok"), (true, false));
|
||||
assert_eq!(parse_status("ok:adv"), (true, true));
|
||||
assert_eq!(parse_status("err"), (false, false));
|
||||
assert_eq!(parse_status("err:adv"), (false, true));
|
||||
// Unknown base → not ok (replay skips it), simple.
|
||||
assert_eq!(parse_status("frobnicate"), (false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_recent_sources_reconstructs_colon_prefix_for_advanced() {
|
||||
// An advanced record (`ok:adv`) hydrates in its `:`-prefixed
|
||||
// simple-mode runnable form; a simple record stays bare. This is
|
||||
// the cross-session half of the issue #30 fix.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("history.log");
|
||||
let adv = format_record_with_status(
|
||||
"select * from T",
|
||||
"2026-06-13T10:00:00Z".to_string(),
|
||||
&status_token(STATUS_OK, true),
|
||||
);
|
||||
let simple = format_record_with_status(
|
||||
"create table T with pk",
|
||||
"2026-06-13T10:00:01Z".to_string(),
|
||||
&status_token(STATUS_OK, false),
|
||||
);
|
||||
std::fs::write(&path, format!("{adv}{simple}")).unwrap();
|
||||
let got = read_recent_sources(&path, 10).unwrap();
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
": select * from T".to_string(),
|
||||
"create table T with pk".to_string(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_journal_record_treats_ok_adv_as_ok() {
|
||||
// Replay keys off the base token, so `ok:adv` replays like `ok`
|
||||
// (source stays canonical).
|
||||
let rec = parse_journal_record("2026-06-13T10:00:00Z|ok:adv|select * from T")
|
||||
.expect("ok:adv journal record");
|
||||
assert!(rec.status_is_ok);
|
||||
assert_eq!(rec.source, "select * from T");
|
||||
let err = parse_journal_record("2026-06-13T10:00:00Z|err:adv|select bad")
|
||||
.expect("err:adv journal record");
|
||||
assert!(!err.status_is_ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_three_field_log_reads_as_simple() {
|
||||
// Back-compat: a pre-ADR-0052 log (no `:adv`) hydrates bare.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("history.log");
|
||||
std::fs::write(&path, "2026-01-01T00:00:00Z|ok|select 1\n").unwrap();
|
||||
let got = read_recent_sources(&path, 10).unwrap();
|
||||
assert_eq!(got, vec!["select 1".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
+32
-9
@@ -395,11 +395,26 @@ impl Persistence {
|
||||
}
|
||||
}
|
||||
|
||||
/// Append one successful-command record to `history.log`.
|
||||
pub fn append_history(&self, command_text: &str) -> Result<(), PersistenceError> {
|
||||
/// Append one successful-command record to `history.log`. `advanced`
|
||||
/// (ADR-0052) tags the record `ok:adv` when the command was submitted
|
||||
/// in an advanced effective mode, so hydration can reconstruct its
|
||||
/// `:`-prefixed form for reuse in simple mode.
|
||||
pub fn append_history(
|
||||
&self,
|
||||
command_text: &str,
|
||||
advanced: bool,
|
||||
) -> Result<(), PersistenceError> {
|
||||
let path = self.project_path.join(HISTORY_LOG);
|
||||
let line = history::format_record(command_text, history::utc_iso8601_now());
|
||||
debug!(len = command_text.len(), "persist: append ok record to history.log");
|
||||
let status = history::status_token(history::STATUS_OK, advanced);
|
||||
let line = history::format_record_with_status(
|
||||
command_text,
|
||||
history::utc_iso8601_now(),
|
||||
&status,
|
||||
);
|
||||
debug!(
|
||||
len = command_text.len(),
|
||||
advanced, "persist: append ok record to history.log"
|
||||
);
|
||||
history::append(&path, &line)
|
||||
}
|
||||
|
||||
@@ -410,14 +425,22 @@ impl Persistence {
|
||||
/// transactional `ok` journal). Best-effort at the call site:
|
||||
/// a failure to record a failure must never escalate a user
|
||||
/// error into a fatal (ADR-0034 §4).
|
||||
pub fn append_history_failure(&self, command_text: &str) -> Result<(), PersistenceError> {
|
||||
pub fn append_history_failure(
|
||||
&self,
|
||||
command_text: &str,
|
||||
advanced: bool,
|
||||
) -> Result<(), PersistenceError> {
|
||||
let path = self.project_path.join(HISTORY_LOG);
|
||||
let status = history::status_token(history::STATUS_ERR, advanced);
|
||||
let line = history::format_record_with_status(
|
||||
command_text,
|
||||
history::utc_iso8601_now(),
|
||||
history::STATUS_ERR,
|
||||
&status,
|
||||
);
|
||||
debug!(
|
||||
len = command_text.len(),
|
||||
advanced, "persist: append err record to history.log"
|
||||
);
|
||||
debug!(len = command_text.len(), "persist: append err record to history.log");
|
||||
history::append(&path, &line)
|
||||
}
|
||||
|
||||
@@ -577,8 +600,8 @@ mod tests {
|
||||
fn append_history_creates_and_appends() {
|
||||
let dir = tempdir();
|
||||
let p = Persistence::new(dir.path().to_path_buf());
|
||||
p.append_history("create table Foo with pk id(serial)").unwrap();
|
||||
p.append_history("insert into Foo (1)").unwrap();
|
||||
p.append_history("create table Foo with pk id(serial)", false).unwrap();
|
||||
p.append_history("insert into Foo (1)", false).unwrap();
|
||||
let body = fs::read_to_string(dir.path().join(HISTORY_LOG)).unwrap();
|
||||
let lines: Vec<&str> = body.trim_end().lines().collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
|
||||
+48
-9
@@ -479,17 +479,19 @@ async fn run_loop(
|
||||
command,
|
||||
source,
|
||||
submission_mode,
|
||||
session.project().path().to_path_buf(),
|
||||
);
|
||||
}
|
||||
Action::JournalFailure { source } => {
|
||||
Action::JournalFailure { source, advanced } => {
|
||||
// ADR-0034 §1/§4: record a failed command as an
|
||||
// `err` record. Best-effort — a failure to record
|
||||
// a failure must never escalate a user error into
|
||||
// a fatal, so the result is logged and ignored.
|
||||
// `err` record (ADR-0052: `err:adv` when advanced).
|
||||
// Best-effort — a failure to record a failure must
|
||||
// never escalate a user error into a fatal, so the
|
||||
// result is logged and ignored.
|
||||
if let Err(e) = crate::persistence::Persistence::new(
|
||||
session.project().path().to_path_buf(),
|
||||
)
|
||||
.append_history_failure(&source)
|
||||
.append_history_failure(&source, advanced)
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to journal err record (ignored)");
|
||||
}
|
||||
@@ -971,7 +973,9 @@ async fn perform_switch(
|
||||
// history.log. The worker's persistence is wired but not
|
||||
// directly addressable from here, so we use a fresh
|
||||
// Persistence handle for this single line.
|
||||
let _ = Persistence::new(new_path.clone()).append_history(&source);
|
||||
// App-lifecycle command (save-as/load/new): journalled simple
|
||||
// (ADR-0052 — app commands run in any mode, so no `:` on recall).
|
||||
let _ = Persistence::new(new_path.clone()).append_history(&source, false);
|
||||
|
||||
// Update the resume pointer so the next `--resume` launch
|
||||
// reopens the project we just switched to — unless it is a
|
||||
@@ -1040,7 +1044,9 @@ fn spawn_export(
|
||||
source: String,
|
||||
event_tx: mpsc::Sender<AppEvent>,
|
||||
) {
|
||||
let _ = crate::persistence::Persistence::new(project_path.clone()).append_history(&source);
|
||||
// `export` app command: journalled simple (ADR-0052).
|
||||
let _ = crate::persistence::Persistence::new(project_path.clone())
|
||||
.append_history(&source, false);
|
||||
tokio::spawn(async move {
|
||||
let outcome = tokio::task::spawn_blocking(move || {
|
||||
do_export(&project_path, &project_name, &data_root, target.as_deref())
|
||||
@@ -1295,11 +1301,20 @@ fn spawn_rebuild(
|
||||
source: String,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let source_for_journal = source.clone();
|
||||
match database
|
||||
.rebuild_from_text(project_path.clone(), Some(source))
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
// ADR-0052: journal `rebuild` at the dispatch layer (the
|
||||
// worker no longer journals); simple (app command),
|
||||
// best-effort.
|
||||
if let Err(e) = crate::persistence::Persistence::new(project_path.clone())
|
||||
.append_history(&source_for_journal, false)
|
||||
{
|
||||
warn!(error = %e, "failed to journal rebuild (ignored)");
|
||||
}
|
||||
let summary = summarize_project(&project_path)
|
||||
.unwrap_or_else(|_| "rebuild complete".to_string());
|
||||
let _ = event_tx
|
||||
@@ -1407,10 +1422,11 @@ fn spawn_dsl_dispatch(
|
||||
command: Command,
|
||||
source: String,
|
||||
submission_mode: crate::app::EffectiveMode,
|
||||
project_path: std::path::PathBuf,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
// Retain the source for `DslFailed` so the App can journal a
|
||||
// rejected command as `err` (ADR-0034 §1/§2).
|
||||
// Retain the source for journaling (ADR-0034 §1/§2; ADR-0052
|
||||
// moved success journaling here, next to the failure path).
|
||||
let source_for_journal = source.clone();
|
||||
// ADR-0038: the DSL → SQL teaching echo fires for a DSL-form
|
||||
// command submitted in an advanced effective mode (ADR-0037).
|
||||
@@ -1431,6 +1447,19 @@ fn spawn_dsl_dispatch(
|
||||
let lookups = collect_echo_lookups(&database, &command, submission_mode).await;
|
||||
let echo = crate::echo::echo_for(&command, submission_mode);
|
||||
let outcome = execute_command_typed(&database, command.clone(), source).await;
|
||||
// ADR-0052 (issue #30): journal a SUCCESSFUL command here, at the
|
||||
// top of the chain — the canonical source + submission mode are
|
||||
// both in scope, so no mode-plumbing into the worker is needed.
|
||||
// Best-effort (ADR-0040 amended): the command is already committed;
|
||||
// a journal-write failure is logged, never fatal. Failures stay on
|
||||
// the `JournalFailure` path (Ok/Err are exclusive — no double
|
||||
// journal). `:adv` tags an advanced submission (ADR-0052).
|
||||
if outcome.is_ok()
|
||||
&& let Err(e) = crate::persistence::Persistence::new(project_path)
|
||||
.append_history(&source_for_journal, submission_mode.is_advanced())
|
||||
{
|
||||
warn!(error = %e, "failed to journal ok record (ignored)");
|
||||
}
|
||||
let event = match outcome {
|
||||
Ok(CommandOutcome::Schema(description)) => {
|
||||
let schema_echo = build_schema_echo(
|
||||
@@ -1569,6 +1598,7 @@ fn spawn_dsl_dispatch(
|
||||
error,
|
||||
facts,
|
||||
source: source_for_journal,
|
||||
advanced: submission_mode.is_advanced(),
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -2540,6 +2570,15 @@ pub async fn run_replay(
|
||||
execute_command_typed(database, command, command_text.clone()).await;
|
||||
match outcome {
|
||||
Ok(_) => {
|
||||
// ADR-0052: journal the replayed line at the dispatch
|
||||
// layer (the worker no longer journals). Replay is
|
||||
// mode-agnostic, so the re-written record is tagged
|
||||
// simple; best-effort, like the interactive path.
|
||||
if let Err(e) = crate::persistence::Persistence::new(project_root.to_path_buf())
|
||||
.append_history(&command_text, false)
|
||||
{
|
||||
warn!(error = %e, "failed to journal replayed line (ignored)");
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
Err(DbError::PersistenceFatal {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2326
|
||||
assertion_line: 2836
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
@@ -26,4 +26,4 @@ expression: snapshot
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · mode simple switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2309
|
||||
assertion_line: 2819
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
@@ -22,8 +22,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│ │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` │
|
||||
│for SQL │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2317
|
||||
assertion_line: 2827
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
@@ -22,8 +22,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│ │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` │
|
||||
│for SQL │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 3442
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -23,8 +24,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 3388
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -23,8 +24,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 3378
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -23,8 +24,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 3431
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -23,8 +24,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 3457
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -23,8 +24,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2369
|
||||
assertion_line: 2880
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
@@ -26,4 +26,4 @@ expression: snapshot
|
||||
│insert into <Table> [(<col>[, ...])] [values] (<value>[, ...]) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2967
|
||||
assertion_line: 3347
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Tables ───────────────────────────────────╮ ─────────────────────────────────╮
|
||||
@@ -22,8 +22,8 @@ expression: snapshot
|
||||
╰───────────────────────────────────────────╯ │
|
||||
╭ Relationships ────────────────────────────╮ ─────────────────────────────────╯
|
||||
│Customers_Orders │ ─────────────────────────────────╮
|
||||
│ Customers.id -> │ ` for a list │
|
||||
│ Customers.id -> │ ` for a list · `mode advanced` │
|
||||
│ Orders.customer_id │ │
|
||||
╰───────────────────────────────────────────╯ ─────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2385
|
||||
assertion_line: 2896
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
@@ -26,4 +26,4 @@ expression: snapshot
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · Backspace cancel one-shot · Ctrl-C quit
|
||||
Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2679
|
||||
assertion_line: 3099
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -22,8 +22,8 @@ expression: snapshot
|
||||
╰──────────────────────────╯│ │
|
||||
╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
│(none) │╭ Hint ──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ││Type a command — press Tab for options, `help` for a list │
|
||||
│ ││ │
|
||||
│ ││Type a command — press Tab for options, `help` for a list · `mode advanced` │
|
||||
│ ││for SQL │
|
||||
╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2399
|
||||
assertion_line: 2909
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
@@ -22,8 +22,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│ │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` │
|
||||
│for SQL │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2789
|
||||
assertion_line: 3209
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -22,8 +22,8 @@ expression: snapshot
|
||||
╰──────────────────────────╯│ │
|
||||
╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
│Customers_Orders │╭ Hint ──────────────────────────────────────────────────────────────────────────╮
|
||||
│ Customers.id -> ││Type a command — press Tab for options, `help` for a list │
|
||||
│ Orders.customer_id ││ │
|
||||
│ Customers.id -> ││Type a command — press Tab for options, `help` for a list · `mode advanced` │
|
||||
│ Orders.customer_id ││for SQL │
|
||||
╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · Enter run
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2265
|
||||
assertion_line: 2616
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────╮
|
||||
@@ -46,4 +46,4 @@ expression: snapshot
|
||||
│with `mode advanced`, or prefix the line with `:` to run… │
|
||||
╰──────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch ·
|
||||
Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Ente
|
||||
|
||||
@@ -1694,7 +1694,19 @@ fn resolve_hint_lines(
|
||||
(None, Some(crate::input_render::AmbientHint::Candidates { items, selected })) => {
|
||||
vec![render_candidate_line(&items, selected, inner, theme)]
|
||||
}
|
||||
(None, None) => prose(&crate::t!("panel.hint_empty")),
|
||||
// Empty input: the base prompt, plus — in simple mode only — a
|
||||
// pointer to advanced mode (ADR-0051, issue #27), since the
|
||||
// `mode advanced` switch left the keybinding strip. Advanced
|
||||
// mode shows no pointer: users know how they reached it, and
|
||||
// `help` covers the way back. (One-shot never reaches here — its
|
||||
// `:` makes the input non-empty → ambient path.)
|
||||
(None, None) => {
|
||||
let mut text = crate::t!("panel.hint_empty");
|
||||
if matches!(app.effective_mode(), EffectiveMode::Simple) {
|
||||
text.push_str(&crate::t!("panel.hint_mode_advanced"));
|
||||
}
|
||||
prose(&text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1845,6 +1857,63 @@ fn render_candidate_line(
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
/// The keybinding strip is keystrokes-only and **state-selected**
|
||||
/// (ADR-0051, issue #27): it advertises the keys for the user's *current*
|
||||
/// interaction, chosen by priority — first matching state wins.
|
||||
///
|
||||
/// Returns `(key, label)` pairs (label localised via `t!`); the renderer
|
||||
/// is a thin span builder over this list, so the binding sets are
|
||||
/// unit-testable without a `Frame`. Mode-switch / `:` advertisements
|
||||
/// deliberately leave the strip — they are typed commands, not
|
||||
/// keystrokes — and move to the empty-input hint (`resolve_hint_lines`).
|
||||
fn status_bar_bindings(app: &App) -> Vec<(&'static str, String)> {
|
||||
// 1. Sidebar focus (Ctrl-O): the input is occluded by the overlay,
|
||||
// so the panel-scroll keys win outright (ADR-0046).
|
||||
if app.nav_focus.in_sidebar() {
|
||||
return vec![
|
||||
("Ctrl-O", crate::t!("shortcut.next_pane")),
|
||||
("↑↓/PgUp/PgDn", crate::t!("shortcut.scroll")),
|
||||
("Esc", crate::t!("shortcut.to_input")),
|
||||
];
|
||||
}
|
||||
// 2. A live Tab-completion memo (ADR-0022): cycling keys. Pressing
|
||||
// Up clears the memo, so this never co-occurs with state 3.
|
||||
if app.last_completion.is_some() {
|
||||
return vec![
|
||||
("Tab/Shift-Tab", crate::t!("shortcut.cycle")),
|
||||
("Esc", crate::t!("shortcut.cancel")),
|
||||
("Enter", crate::t!("shortcut.run")),
|
||||
];
|
||||
}
|
||||
// 3. Browsing recalled history (unedited): browse keys. Editing the
|
||||
// recalled line ends navigation, dropping to state 4.
|
||||
if app.is_browsing_history() {
|
||||
return vec![
|
||||
("↑↓", crate::t!("shortcut.browse")),
|
||||
("Esc", crate::t!("shortcut.clear")),
|
||||
("Enter", crate::t!("shortcut.run")),
|
||||
];
|
||||
}
|
||||
// 4. Editing — the input has text: surface the readline edit keys
|
||||
// (ADR-0049). The highest-value subset stays within the width
|
||||
// budget; Ctrl-K/U remain unadvertised muscle memory.
|
||||
if !app.input.is_empty() {
|
||||
return vec![
|
||||
("Esc", crate::t!("shortcut.clear")),
|
||||
("Ctrl-A/E", crate::t!("shortcut.home_end")),
|
||||
("Ctrl-W", crate::t!("shortcut.del_word")),
|
||||
("Enter", crate::t!("shortcut.run")),
|
||||
];
|
||||
}
|
||||
// 5. Default — empty input, Input focus.
|
||||
vec![
|
||||
("Ctrl-O", crate::t!("shortcut.nav")),
|
||||
("Tab", crate::t!("shortcut.complete")),
|
||||
("↑", crate::t!("shortcut.history")),
|
||||
("Enter", crate::t!("shortcut.run")),
|
||||
]
|
||||
}
|
||||
|
||||
fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||||
let key_style = Style::default()
|
||||
.fg(theme.fg)
|
||||
@@ -1855,35 +1924,14 @@ fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect
|
||||
|
||||
let separator = Span::styled(" · ", sep_style);
|
||||
let mut spans: Vec<Span<'_>> = Vec::new();
|
||||
|
||||
let push_shortcut = |spans: &mut Vec<Span<'_>>, key: &'static str, label: &str| {
|
||||
for (key, label) in status_bar_bindings(app) {
|
||||
if !spans.is_empty() {
|
||||
spans.push(separator.clone());
|
||||
}
|
||||
spans.push(Span::styled(key, key_style));
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(label.to_string(), label_style));
|
||||
};
|
||||
|
||||
let submit = crate::t!("shortcut.submit");
|
||||
push_shortcut(&mut spans, "Enter", &submit);
|
||||
let switch = crate::t!("shortcut.switch");
|
||||
let advanced_once = crate::t!("shortcut.advanced_once");
|
||||
let cancel_one_shot = crate::t!("shortcut.cancel_one_shot");
|
||||
let quit = crate::t!("shortcut.quit");
|
||||
match app.effective_mode() {
|
||||
EffectiveMode::Simple => {
|
||||
push_shortcut(&mut spans, ":", &advanced_once);
|
||||
push_shortcut(&mut spans, "mode advanced", &switch);
|
||||
spans.push(Span::styled(label, label_style));
|
||||
}
|
||||
EffectiveMode::AdvancedPersistent => {
|
||||
push_shortcut(&mut spans, "mode simple", &switch);
|
||||
}
|
||||
EffectiveMode::AdvancedOneShot => {
|
||||
push_shortcut(&mut spans, "Backspace", &cancel_one_shot);
|
||||
}
|
||||
}
|
||||
push_shortcut(&mut spans, "Ctrl-C", &quit);
|
||||
|
||||
let paragraph = Paragraph::new(Line::from(spans)).style(bar_style);
|
||||
frame.render_widget(paragraph, area);
|
||||
@@ -2582,6 +2630,168 @@ mod tests {
|
||||
.expect("hint bottom border present")
|
||||
}
|
||||
|
||||
// ---- ADR-0051 (issue #27): context- and state-aware strip ----
|
||||
|
||||
fn key_event(code: crossterm::event::KeyCode) -> crate::event::AppEvent {
|
||||
crate::event::AppEvent::Key(crossterm::event::KeyEvent::new(
|
||||
code,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
))
|
||||
}
|
||||
|
||||
/// The `key` column of the strip's bindings, in order.
|
||||
fn strip_keys(app: &App) -> Vec<&'static str> {
|
||||
status_bar_bindings(app).into_iter().map(|(k, _)| k).collect()
|
||||
}
|
||||
|
||||
/// The full rendered strip text (keys + labels + separators).
|
||||
fn strip_text(app: &App) -> String {
|
||||
status_bar_bindings(app)
|
||||
.iter()
|
||||
.map(|(k, l)| format!("{k} {l}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ")
|
||||
}
|
||||
|
||||
fn hint_text(lines: &[Line<'_>]) -> String {
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| l.spans.iter().map(|s| s.content.clone()).collect::<String>())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_default_state_is_nav_complete_history_run() {
|
||||
let app = App::new();
|
||||
assert_eq!(strip_keys(&app), vec!["Ctrl-O", "Tab", "↑", "Enter"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_editing_state_surfaces_readline_keys() {
|
||||
// Input has text (no completion/history transient) → the #29
|
||||
// editing keys (ADR-0049).
|
||||
let mut app = App::new();
|
||||
app.input.push_str("create ta");
|
||||
assert_eq!(
|
||||
strip_keys(&app),
|
||||
vec!["Esc", "Ctrl-A/E", "Ctrl-W", "Enter"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_sidebar_focus_state_is_pane_scroll_input() {
|
||||
let mut app = App::new();
|
||||
app.nav_focus = NavFocus::SidebarTables;
|
||||
assert_eq!(
|
||||
strip_keys(&app),
|
||||
vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"],
|
||||
);
|
||||
// ...and the relationships sidebar is the same state.
|
||||
app.nav_focus = NavFocus::SidebarRelationships;
|
||||
assert_eq!(strip_keys(&app), vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_completion_memo_state_is_cycle_cancel_run() {
|
||||
// Drive the real flow: `show ` + Tab leaves a multi-candidate
|
||||
// memo (ADR-0022). The strip must win over the editing state.
|
||||
let mut app = App::new();
|
||||
for c in "show ".chars() {
|
||||
app.update(key_event(crossterm::event::KeyCode::Char(c)));
|
||||
}
|
||||
app.update(key_event(crossterm::event::KeyCode::Tab));
|
||||
assert!(app.last_completion.is_some(), "memo set by Tab");
|
||||
assert!(!app.input.is_empty(), "input non-empty — would be editing");
|
||||
assert_eq!(
|
||||
strip_keys(&app),
|
||||
vec!["Tab/Shift-Tab", "Esc", "Enter"],
|
||||
"completion state wins over editing",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_history_navigation_state_is_browse_clear_run() {
|
||||
// Submit a command, then Up to recall it — `history_cursor` is
|
||||
// set, input is the (non-empty) recalled line, no memo.
|
||||
let mut app = App::new();
|
||||
for c in "drop table T".chars() {
|
||||
app.update(key_event(crossterm::event::KeyCode::Char(c)));
|
||||
}
|
||||
app.update(key_event(crossterm::event::KeyCode::Enter)); // submit
|
||||
app.update(key_event(crossterm::event::KeyCode::Up)); // recall
|
||||
assert!(app.is_browsing_history(), "browsing recalled history");
|
||||
assert!(app.last_completion.is_none(), "no completion memo");
|
||||
assert_eq!(
|
||||
strip_keys(&app),
|
||||
vec!["↑↓", "Esc", "Enter"],
|
||||
"history state wins over editing",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_strip_state_fits_the_eighty_column_budget() {
|
||||
// ADR-0051 §3: the strips are kept lean by construction — the
|
||||
// longest must fit an 80-col status line, so no graceful-drop
|
||||
// machinery is needed. A future over-long strip fails here.
|
||||
let sidebar = {
|
||||
let mut a = App::new();
|
||||
a.nav_focus = NavFocus::SidebarTables;
|
||||
a
|
||||
};
|
||||
let editing = {
|
||||
let mut a = App::new();
|
||||
a.input.push('x');
|
||||
a
|
||||
};
|
||||
for app in [&App::new(), &sidebar, &editing] {
|
||||
let text = strip_text(app);
|
||||
assert!(
|
||||
text.chars().count() <= 80,
|
||||
"strip {} cols > 80: {text:?}",
|
||||
text.chars().count(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_hint_advertises_advanced_mode_in_simple() {
|
||||
let app = App::new();
|
||||
// Wide width so the pointer never wrap-splits.
|
||||
let text = hint_text(&resolve_hint_lines(&app, &Theme::dark(), 200, 3));
|
||||
assert!(
|
||||
text.contains("`mode advanced` for SQL"),
|
||||
"simple empty hint carries the advanced pointer:\n{text}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_mode_empty_hint_has_no_mode_pointer() {
|
||||
// ADR-0051: advanced mode shows no mode pointer (users know how
|
||||
// they got there; `help` covers the way back).
|
||||
let mut app = App::new();
|
||||
app.mode = Mode::Advanced;
|
||||
let text = hint_text(&resolve_hint_lines(&app, &Theme::dark(), 200, 3));
|
||||
assert!(
|
||||
!text.contains("mode simple") && !text.contains("mode advanced"),
|
||||
"advanced empty hint carries no mode pointer:\n{text}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_replaces_the_empty_hint_mode_pointer() {
|
||||
// Non-empty input → ambient hint path, not the empty-hint
|
||||
// mode pointer.
|
||||
let mut app = App::new();
|
||||
app.input.push_str("create table");
|
||||
app.input_cursor = app.input.len();
|
||||
let text = hint_text(&resolve_hint_lines(&app, &Theme::dark(), 200, 3));
|
||||
assert!(
|
||||
!text.contains("for SQL"),
|
||||
"no mode pointer once typing:\n{text}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_wrapped_truncates_with_ellipsis_past_max() {
|
||||
// ≤ max rows: untouched.
|
||||
|
||||
@@ -15,7 +15,7 @@ use rdbms_playground::db::Database;
|
||||
use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, RowFilter, Type, Value};
|
||||
use rdbms_playground::persistence::Persistence;
|
||||
use rdbms_playground::project::{
|
||||
self, DATA_DIR, HISTORY_LOG, PROJECT_YAML,
|
||||
self, DATA_DIR, PROJECT_YAML,
|
||||
};
|
||||
|
||||
fn tempdir() -> tempfile::TempDir {
|
||||
@@ -44,11 +44,6 @@ fn open_project(
|
||||
(project, db, path)
|
||||
}
|
||||
|
||||
fn read_history(project_path: &Path) -> Vec<String> {
|
||||
let body = fs::read_to_string(project_path.join(HISTORY_LOG)).unwrap_or_default();
|
||||
body.lines().map(str::to_string).collect()
|
||||
}
|
||||
|
||||
fn read_yaml(project_path: &Path) -> String {
|
||||
fs::read_to_string(project_path.join(PROJECT_YAML)).expect("project.yaml")
|
||||
}
|
||||
@@ -82,9 +77,9 @@ fn create_table_writes_yaml_and_history() {
|
||||
assert!(yaml.contains("type: serial"), "yaml: {yaml}");
|
||||
assert!(yaml.contains("type: text"), "yaml: {yaml}");
|
||||
|
||||
let history = read_history(&path);
|
||||
assert_eq!(history.len(), 1, "expected one history line; got {history:?}");
|
||||
assert!(history[0].ends_with("|ok|create table Customers with pk id(serial)"));
|
||||
// ADR-0052: journaling moved to the dispatch layer (the worker no
|
||||
// longer writes history.log); this test verifies only the yaml state.
|
||||
// Journaling is covered by the history.rs/app.rs/replay tests.
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -119,11 +114,8 @@ fn insert_writes_csv_and_history() {
|
||||
assert_eq!(lines[0], "id,Name");
|
||||
assert_eq!(lines[1], "1,Alice");
|
||||
|
||||
let history = read_history(&path);
|
||||
assert!(
|
||||
history.iter().any(|l| l.ends_with("|ok|insert into Customers ('Alice')")),
|
||||
"history missing insert: {history:?}",
|
||||
);
|
||||
// ADR-0052: journaling moved off the worker; this test verifies the
|
||||
// csv state only (journaling covered elsewhere).
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -322,39 +314,6 @@ fn delete_all_rows_removes_csv() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_table_appends_history_only() {
|
||||
let data = tempdir();
|
||||
let (_p, db, path) = open_project(&data);
|
||||
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
"Customers".to_string(),
|
||||
vec![ColumnSpec::new("id".to_string(), Type::Serial)],
|
||||
vec!["id".to_string()],
|
||||
Some("create table Customers with pk id(serial)".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let yaml_before = read_yaml(&path);
|
||||
db.describe_table(
|
||||
"Customers".to_string(),
|
||||
Some("show table Customers".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let yaml_after = read_yaml(&path);
|
||||
// YAML body did not change for a read-only command.
|
||||
assert_eq!(yaml_before, yaml_after);
|
||||
});
|
||||
|
||||
let history = read_history(&path);
|
||||
assert!(
|
||||
history.iter().any(|l| l.ends_with("|ok|show table Customers")),
|
||||
"history missing show entry: {history:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_command_does_not_append_history_or_change_yaml() {
|
||||
let data = tempdir();
|
||||
@@ -387,13 +346,8 @@ fn failed_command_does_not_append_history_or_change_yaml() {
|
||||
assert_eq!(yaml_before, yaml_after, "failed cmd must not change yaml");
|
||||
});
|
||||
|
||||
let history = read_history(&path);
|
||||
// Only the first (successful) create_table should have logged.
|
||||
let create_count = history
|
||||
.iter()
|
||||
.filter(|l| l.contains("|ok|create table Customers"))
|
||||
.count();
|
||||
assert_eq!(create_count, 1, "expected exactly one logged create; got: {history:?}");
|
||||
// ADR-0052: journaling moved off the worker; this test now verifies
|
||||
// only that a failed command does not change the yaml state.
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -178,10 +178,7 @@ fn rebuild_against_populated_db_wipes_and_reloads() {
|
||||
assert_eq!(rows.rows.len(), 1);
|
||||
assert_eq!(rows.rows[0][1].as_deref(), Some("Edna"));
|
||||
|
||||
// history.log should contain the rebuild entry.
|
||||
let history = fs::read_to_string(project_path.join("history.log")).unwrap();
|
||||
assert!(
|
||||
history.lines().any(|l| l.ends_with("|ok|rebuild")),
|
||||
"history.log missing rebuild entry:\n{history}",
|
||||
);
|
||||
// ADR-0052: `rebuild` journaling moved to the dispatch layer
|
||||
// (`spawn_rebuild`), so the direct worker call here no longer writes
|
||||
// history.log; this test verifies the wipe/reload behaviour only.
|
||||
}
|
||||
|
||||
@@ -141,9 +141,9 @@ 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();
|
||||
p.append_history("create table A with pk", false).unwrap();
|
||||
p.append_history("create table B with pk", false).unwrap();
|
||||
p.append_history("create table C with pk", false).unwrap();
|
||||
let entries = p.read_recent_history(10).unwrap();
|
||||
assert_eq!(
|
||||
entries,
|
||||
@@ -165,9 +165,9 @@ fn hydration_reads_both_ok_and_err_records() {
|
||||
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_failure("insert into A (1, 2, 3)").unwrap();
|
||||
p.append_history("show data A").unwrap();
|
||||
p.append_history("create table A with pk", false).unwrap();
|
||||
p.append_history_failure("insert into A (1, 2, 3)", false).unwrap();
|
||||
p.append_history("show data A", false).unwrap();
|
||||
let entries = p.read_recent_history(10).unwrap();
|
||||
assert_eq!(
|
||||
entries,
|
||||
@@ -194,6 +194,32 @@ fn seed_history_replaces_in_memory_history() {
|
||||
assert_eq!(app.history, vec!["x".to_string(), "y".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_command_journalled_then_hydrated_recalls_with_colon_in_simple() {
|
||||
// ADR-0052 (issue #30) — the headline cross-session regression: an
|
||||
// advanced command journalled `ok:adv`, then hydrated on a fresh
|
||||
// session, recalls WITH its `:` so it re-runs in simple mode. (Before
|
||||
// the fix, the `:` was lost on disk and the command came back bare.)
|
||||
let tmp = tempdir();
|
||||
let project = Project::create_temp(tmp.path()).unwrap();
|
||||
let p = Persistence::new(project.path().to_path_buf());
|
||||
// The dispatch layer journals the canonical source + advanced flag.
|
||||
p.append_history("select * from T", true).unwrap();
|
||||
p.append_history("create table T with pk", false).unwrap();
|
||||
|
||||
// Fresh session: hydrate the ring from disk.
|
||||
let entries = p.read_recent_history(10).unwrap();
|
||||
let mut app = App::new();
|
||||
app.seed_history(entries);
|
||||
|
||||
// In simple mode the simple command recalls bare, the advanced one
|
||||
// recalls `:`-prefixed (runnable via the one-shot escape).
|
||||
app.update(key(KeyCode::Up));
|
||||
assert_eq!(app.input, "create table T with pk");
|
||||
app.update(key(KeyCode::Up));
|
||||
assert_eq!(app.input, ": select * from T");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_history_preserves_chronological_order_for_navigation() {
|
||||
let mut app = App::new();
|
||||
|
||||
@@ -430,24 +430,6 @@ fn seed_is_reproducible_with_a_fixed_seed() {
|
||||
assert_eq!(csv1, csv2, "the same --seed must reproduce identical data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_writes_exactly_one_history_line() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_people(&db, &rt);
|
||||
|
||||
rt.block_on(db.seed("People".into(), None, Some(5), Vec::new(), Some(1), Some("seed People 5".into())))
|
||||
.expect("seed succeeds");
|
||||
|
||||
let history = std::fs::read_to_string(project.path().join("history.log"))
|
||||
.expect("history.log exists");
|
||||
let seed_lines = history.lines().filter(|l| l.contains("seed People 5")).count();
|
||||
assert_eq!(
|
||||
seed_lines, 1,
|
||||
"a seed of 5 rows must write exactly one history line:\n{history}"
|
||||
);
|
||||
}
|
||||
|
||||
// — FK sampling, empty-parent error, block guard (ADR-0048 D14 / D1) —
|
||||
|
||||
/// `Users(id serial pk, name text)` + `Orders(id serial pk, user_id
|
||||
|
||||
@@ -141,7 +141,7 @@ fn create_unique_index_on_duplicate_data_is_refused() {
|
||||
|
||||
#[test]
|
||||
fn if_not_exists_on_an_existing_name_is_a_noop_and_journalled() {
|
||||
let (p, db, _d) = open(false);
|
||||
let (_p, db, _d) = open(false);
|
||||
let r = rt();
|
||||
make_t(&db, &r);
|
||||
r.block_on(db.sql_create_index(
|
||||
@@ -169,8 +169,8 @@ fn if_not_exists_on_an_existing_name_is_a_noop_and_journalled() {
|
||||
CreateIndexOutcome::Skipped(name) => assert_eq!(name, "ix"),
|
||||
CreateIndexOutcome::Created(_) => panic!("expected Skipped, got Created"),
|
||||
}
|
||||
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log");
|
||||
assert!(log.contains(line), "the skipped create should be journalled; log:\n{log}");
|
||||
// ADR-0052: journaling moved to the dispatch layer; this test now
|
||||
// asserts only the no-op `Skipped` outcome.
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -590,7 +590,7 @@ fn if_not_exists_noop_is_journalled() {
|
||||
// A successful no-op is still a submission and belongs in the
|
||||
// complete journal (ADR-0034) — like read-only `show table`, and
|
||||
// unlike a *failed* duplicate-create (journalled `err`).
|
||||
let (p, db, _d) = open(false);
|
||||
let (_p, db, _d) = open(false);
|
||||
let r = rt();
|
||||
r.block_on(db.sql_create_table(
|
||||
"T".to_string(),
|
||||
@@ -617,8 +617,8 @@ fn if_not_exists_noop_is_journalled() {
|
||||
))
|
||||
.expect("no-op");
|
||||
assert!(matches!(out, CreateOutcome::Skipped(_)));
|
||||
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log");
|
||||
assert!(log.contains(noop), "the no-op skip should be journalled; log:\n{log}");
|
||||
// ADR-0052: journaling moved to the dispatch layer; this test now
|
||||
// asserts only the no-op `Skipped` outcome.
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -261,19 +261,6 @@ fn r2_cascade_with_subquery_where() {
|
||||
"only Bob's order remains: {orders_csv:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_appends_literal_line_to_history() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'x')", "t");
|
||||
let input = "delete from t where id = 1";
|
||||
run_delete(&db, &rt, input).expect("delete runs");
|
||||
let body = std::fs::read_to_string(project.path().join("history.log"))
|
||||
.expect("history.log present");
|
||||
assert!(body.contains(input), "history records the literal line: {body:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cascade_to_two_children_reports_both() {
|
||||
// DA gate (untested branch): a parent with TWO cascade children
|
||||
|
||||
@@ -84,7 +84,7 @@ fn drop_index_removes_an_existing_index_and_shows_the_table() {
|
||||
|
||||
#[test]
|
||||
fn if_exists_on_an_absent_index_is_a_noop_and_journalled() {
|
||||
let (p, db, _d) = open(false);
|
||||
let (_p, db, _d) = open(false);
|
||||
let r = rt();
|
||||
let line = "drop index if exists ghost_idx";
|
||||
let out = r
|
||||
@@ -92,8 +92,8 @@ fn if_exists_on_an_absent_index_is_a_noop_and_journalled() {
|
||||
.expect("IF EXISTS on an absent index succeeds as a no-op");
|
||||
assert!(matches!(out, DropIndexOutcome::Skipped));
|
||||
// The no-op is still journalled (ADR-0034), like the create-skip.
|
||||
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log");
|
||||
assert!(log.contains(line), "the skipped drop should be journalled; log:\n{log}");
|
||||
// ADR-0052: journaling moved to the dispatch layer; this test now
|
||||
// asserts only the no-op `Skipped` outcome.
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -59,7 +59,7 @@ fn drop_table_removes_an_existing_table() {
|
||||
|
||||
#[test]
|
||||
fn if_exists_on_an_absent_table_is_a_noop_and_journalled() {
|
||||
let (p, db, _d) = open(false);
|
||||
let (_p, db, _d) = open(false);
|
||||
let r = rt();
|
||||
let line = "drop table if exists Ghost";
|
||||
let out = r
|
||||
@@ -67,8 +67,8 @@ fn if_exists_on_an_absent_table_is_a_noop_and_journalled() {
|
||||
.expect("IF EXISTS on an absent table succeeds as a no-op");
|
||||
assert!(matches!(out, DropOutcome::Skipped));
|
||||
// The no-op is still journalled (ADR-0034), like the create-skip.
|
||||
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log");
|
||||
assert!(log.contains(line), "the skipped drop should be journalled; log:\n{log}");
|
||||
// ADR-0052: journaling moved to the dispatch layer; this test now
|
||||
// asserts only the no-op `Skipped` outcome.
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -132,30 +132,6 @@ fn no_column_list_full_arity_insert_persists() {
|
||||
assert!(csv.contains("full-arity"), "CSV reflects the row: {csv:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_appends_literal_line_to_history() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_t(&db, &rt);
|
||||
// ADR-0030 §11: the literal submitted line lands in history.log.
|
||||
let source = "insert into T (a, b) values (1, 'logged')";
|
||||
rt.block_on(db.run_sql_insert(
|
||||
"insert into T (a, b) values (1, 'logged')".to_string(),
|
||||
Some(source.to_string()),
|
||||
"T".to_string(),
|
||||
Vec::new(),
|
||||
String::new(),
|
||||
false,
|
||||
))
|
||||
.expect("insert runs");
|
||||
let body = std::fs::read_to_string(project.path().join("history.log"))
|
||||
.expect("history.log present after an INSERT");
|
||||
assert!(
|
||||
body.contains(source),
|
||||
"history.log records the literal INSERT line: {body:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_insert_rolls_back_and_does_not_repersist() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
@@ -583,26 +559,6 @@ fn combined_serial_and_shortid_autofill() {
|
||||
assert_eq!(rows[0][2], "x", "name preserved: {rows:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofill_logs_original_source_not_rewritten_sql() {
|
||||
// ADR-0030 §11: even though the worker rewrites the executed
|
||||
// statement to bind synthesised shortids, history.log records
|
||||
// the user's original line verbatim.
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
|
||||
let input = "insert into t (label) values ('x')";
|
||||
run_sqlinsert(&db, &rt, input).expect("auto-fill insert runs");
|
||||
let body = std::fs::read_to_string(project.path().join("history.log"))
|
||||
.expect("history.log present");
|
||||
assert!(body.contains(input), "original line logged: {body:?}");
|
||||
// The rewritten parameterised INSERT must not leak into history.
|
||||
assert!(
|
||||
!body.contains("INSERT INTO") && !body.contains("?1"),
|
||||
"rewritten SQL must not be logged: {body:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortid_autofill_respects_mixed_case_column_name() {
|
||||
// ADR-0009 / 3d DA gate: identifiers are case-preserving. The
|
||||
|
||||
@@ -732,23 +732,3 @@ fn database_run_select_recovers_all_ten_playground_types() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_run_select_appends_to_history_when_source_present() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let history_path = project.path().join("history.log");
|
||||
// ADR-0030 §11: the literal submitted line lands in
|
||||
// history.log so replay re-runs it.
|
||||
let _ = rt()
|
||||
.block_on(db.run_select(
|
||||
"select 1".to_string(),
|
||||
Some("select 1".to_string()),
|
||||
))
|
||||
.expect("SELECT runs");
|
||||
let body = std::fs::read_to_string(&history_path)
|
||||
.expect("history.log present after a SELECT");
|
||||
assert!(
|
||||
body.contains("select 1"),
|
||||
"history.log records the literal SELECT line: {body:?}",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -205,19 +205,6 @@ fn update_matching_no_rows_is_ok() {
|
||||
assert!(csv.contains("keep") && !csv.contains('x'), "unchanged: {csv:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_appends_literal_line_to_history() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
|
||||
seed(&db, &rt, "insert into t (id, v) values (1, 'old')", "t");
|
||||
let input = "update t set v = 'new' where id = 1";
|
||||
run_update(&db, &rt, input).expect("update runs");
|
||||
let body = std::fs::read_to_string(project.path().join("history.log"))
|
||||
.expect("history.log present");
|
||||
assert!(body.contains(input), "history records the literal line: {body:?}");
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// ADR-0036 Phase 2 — `SET` literal value validation
|
||||
// =================================================================
|
||||
|
||||
@@ -223,21 +223,30 @@ fn typing_colon_in_simple_mode_flips_prompt_to_advanced() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_bar_lists_quit_and_submit_in_all_modes() {
|
||||
fn status_bar_is_keystroke_only_and_state_aware() {
|
||||
// ADR-0051 (issue #27): the bottom strip is keystrokes-only and
|
||||
// tracks the interaction state. Typed-command words (`:` advanced
|
||||
// once, `mode advanced`/`mode simple` switch) and `Ctrl-C quit`
|
||||
// leave the strip; mode discovery moves to the hint (locked by the
|
||||
// ui.rs unit tests). This test exercises the real render path.
|
||||
let mut app = App::new();
|
||||
let theme = Theme::dark();
|
||||
|
||||
let simple = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(simple.contains("Enter"), "status bar lists Enter");
|
||||
assert!(simple.contains("Ctrl-C"), "status bar lists Ctrl-C");
|
||||
assert!(simple.contains("mode advanced"));
|
||||
// Default (empty input): nav / complete / history / run keystrokes.
|
||||
let default_view = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(default_view.contains("Ctrl-O sidebar"), "strip lists sidebar:\n{default_view}");
|
||||
assert!(default_view.contains("Enter run"), "strip lists run:\n{default_view}");
|
||||
assert!(!default_view.contains("Ctrl-C"), "quit dropped from the strip:\n{default_view}");
|
||||
assert!(
|
||||
!default_view.contains("advanced once"),
|
||||
"`:` command word dropped from the strip:\n{default_view}",
|
||||
);
|
||||
|
||||
type_str(&mut app, "mode advanced");
|
||||
submit(&mut app);
|
||||
let advanced = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(advanced.contains("Enter"));
|
||||
assert!(advanced.contains("Ctrl-C"));
|
||||
assert!(advanced.contains("mode simple"));
|
||||
// Editing (input has text): the #29 readline edit keys appear.
|
||||
type_str(&mut app, "create");
|
||||
let editing = rendered_text(&mut app, &theme, 80, 24);
|
||||
assert!(editing.contains("Esc clear"), "editing strip lists clear:\n{editing}");
|
||||
assert!(editing.contains("Ctrl-W del word"), "editing strip lists del word:\n{editing}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
@@ -652,6 +661,7 @@ fn dsl_failure_shows_friendly_error_in_output() {
|
||||
},
|
||||
facts: rdbms_playground::friendly::FailureContext::default(),
|
||||
source: String::new(),
|
||||
advanced: false,
|
||||
});
|
||||
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
|
||||
assert!(
|
||||
|
||||
Reference in New Issue
Block a user