3 Commits

Author SHA1 Message Date
claude@clouddev1 ae73a4be85 docs: handoff 69 — four issues closed (#27/#28/#29/#30) + ADRs 0049–0052; tracker empty 2026-06-14 12:08:24 +00:00
claude@clouddev1 4aeea55984 feat(history): mode-tagged history + top-of-chain journaling (#30)
Record the submission mode per history entry so advanced commands are
reusable in simple mode, and fix the bug where a ':'-one-shot command
lost its ':' across sessions (ADR-0052, closing #30).

Format: the history.log status token gains an optional ':adv' suffix
(ok / ok:adv / err / err:adv); 'source' stays last and canonical, so
replay is unaffected. The in-memory ring (still Vec<String>) stores
advanced entries ': '-prefixed; recall strips the ':' in advanced mode
and keeps it in simple; hydration reconstructs the prefix from the tag.

Journaling moved from the worker to the dispatch layer (spawn_dsl_-
dispatch / run_replay / app-command sites), where the mode is in scope
with no worker plumbing; finalize_persistence writes only yaml/csv
(commit-db-last still atomic for state). The journal write is now
best-effort (command already committed), consistent with the failure
path. App commands journal simple, so they recall bare. Journaling is
now uniform (every successful command, per ADR-0034) — closing a gap
where show tables/relationships/explain didn't journal.

Amends ADR-0034 (status tag + journaling location), ADR-0015 §6
(history.log out of the worker tx), ADR-0040 (journal-write best-effort).
15 worker-level journaling tests retired, re-covered at the new layer
(history.rs format, app.rs recall matrix, iteration6 cross-session
regression, replay). 2471 pass / 0 fail / 0 skip, clippy clean.
2026-06-14 11:20:55 +00:00
claude@clouddev1 eceedc19b7 feat(ui): context- and state-aware bottom keybinding strip (#27)
Per ADR-0051 (closing issue #27): the bottom status line is now a
keystrokes-only, state-selected strip. A pure status_bar_bindings()
picks the binding set by priority (first match wins):

  sidebar focus   → Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input
  completion live → Tab/Shift-Tab cycle · Esc cancel · Enter run
  history nav     → ↑↓ browse · Esc clear · Enter run
  editing         → Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run
  default (empty) → Ctrl-O sidebar · Tab complete · ↑ history · Enter run

The editing state surfaces the #29 readline keys (ADR-0049's deferred
advertisement). Typed-command words (mode advanced/simple, the ':'
one-shot) and Ctrl-C quit leave the strip; simple mode's empty-input
hint gains a '`mode advanced` for SQL' pointer (advanced mode shows
none — a switcher knows the way back, and help covers it).

Mechanism: status_bar_bindings + a thin renderer (unit-testable);
App::is_browsing_history() exposes the private history_cursor; the mode
pointer lives in resolve_hint_lines. Catalog: 12 new shortcut labels +
panel.hint_mode_advanced (en-US.yaml + keys.rs, validator 1:1), 5 dead
strip strings removed.

Forks user-chosen: editing state shows #29 keys; quit omitted; no
width-drop machinery (longest strip ~65 cols fits; a width-budget test
keeps it lean). Modal-aware strip is OOS (pre-existing). Tests: 9
Tier-1 unit (per-state key sets, width budget, mode pointer), 1 Tier-3
rewritten, 15 full-panel snapshots re-accepted (reviewed). 2467 pass /
0 fail / 0 skip, clippy clean.
2026-06-13 12:18:37 +00:00
46 changed files with 1651 additions and 380 deletions
+8
View File
@@ -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
+203
View File
@@ -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 (00490052), +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
**D1D3** (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/D1D3) 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.
+5
View File
@@ -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
View File
@@ -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();
+51 -64
View File
@@ -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(())
+5
View File
@@ -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
View File
@@ -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", &[]),
+21 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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
@@ -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
@@ -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
+234 -24
View File
@@ -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.
+8 -54
View File
@@ -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]
+3 -6
View File
@@ -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.
}
+32 -6
View File
@@ -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();
-18
View File
@@ -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
+3 -3
View File
@@ -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]
+3 -3
View File
@@ -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]
-13
View File
@@ -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
+3 -3
View File
@@ -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]
+3 -3
View File
@@ -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]
-44
View File
@@ -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
-20
View File
@@ -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:?}",
);
}
-13
View File
@@ -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
// =================================================================
+21 -11
View File
@@ -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!(