Merge branch 'main' into ci
ci / gate (push) Successful in 3m5s

Bring main's latest (ADRs 0049-0053 + their features) onto the CI branch so
the gate runs against current main before CI lands on main. Clean merge —
ci and main touched disjoint files.
This commit is contained in:
claude@clouddev1
2026-06-15 16:01:09 +00:00
84 changed files with 4687 additions and 870 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
+10
View File
@@ -197,6 +197,16 @@ Referenced by:
The relationship sections retain today's plain-text format
to leave room for the future relationship-rendering ADR.
> **Superseded.** ADR-0044 replaced this prose block with compact
> diagrams on relationship-subject surfaces (`show table`,
> `add`/`drop relationship`). **ADR-0050 (2026-06-12, issue #28)** then
> removed the relationship block entirely from incidental-DDL structure
> echoes (`create table`, `add`/`drop`/`rename`/`change column`,
> `add`/`drop index`) — those render structure only — and **deleted the
> prose renderer**. The `References:` / `Referenced by:` format above is
> retained here as documentation/provenance should the OOS-7
> always-prose display setting ever be built.
### 6. Theme integration
Theme colors apply to the box-drawing characters via the
@@ -772,6 +772,58 @@ invalid_ident_does_not_fire_for_column_prefix_at_sql_expr_slot}`;
`theme::function_colour_is_distinct_from_keyword_identifier_and_type`.
See ADR-0031's status note for the grammar-side anchor.
## Amendment 7 — optional positional args reach the hint panel (2026-06-12)
Issue #26. At `seed <table> ▮` the hint panel showed only the
`set` / `--seed` continuation chips and never mentioned the
**optional row count** — even though a count (`seed users 50`) is
the most common next move. The count is a bare positional
`NumberLit` with no keyword/candidate text, so the candidate ladder
can't surface it; and `seed <table>` is already a *complete*
command, so the hint resolver short-circuits (empty expected set).
The existing `IntroProse` `HintMode` (ADR-0024 §HintMode-per-node;
issue #4's CREATE-TABLE element hint) is the right tool — it shows
prose that *introduces* a position whose first-class move has no
candidate, with the keyword alternatives folded into the prose and
Tab still cycling them. But it did not reach this position: a
`Node::Hinted`'s mode lives in `pending_hint_mode`, which the very
next match clears — including the **empty** match of a skipped
`Optional`. The CREATE-TABLE element survives only because it sits
in a *required* `Repeated(min:1)`; an optional positional followed
by more optionals (the seed count) is cleared before the resolver
reads it.
### Mechanism
A small, general carry: when `walk_optional` skips its inner (the
inner didn't engage), it stashes any `IntroProse` key the inner
left in `pending_hint_mode` into a new `WalkContext` field,
`surviving_intro_hint: Option<(key, position)>`, **before** the
empty match clears `pending_hint_mode`. The trailing optionals,
which are not `IntroProse`, don't overwrite it. The hint snapshot
keeps the key **only when `position == cursor`** (the slice end),
so it shows while the cursor sits at the count slot but not once a
later clause (`set …`) consumes input past it, nor once the count
itself is supplied. The resolver returns that `IntroProse` even for
an otherwise-complete command (ahead of the empty-expected
short-circuit).
The seed grammar wraps the count in
`Hinted { IntroProse("hint.seed_count"), NumberLit }`; the prose
names the count (with its default 20) plus the `.column`
column-fill form and the `set` / `--seed` keywords (user-chosen
scope: mention every option). Only `IntroProse` is carried —
`ProseOnly` / `ForceProse` mark *active* slots and reach the
resolver through the normal path, unchanged. The CREATE-TABLE
element (in a `Repeated`, not an `Optional`) is untouched.
This is a refinement of ADR-0024 §HintMode-per-node and a sibling
of issue #4; no `AmbientHint` / renderer change. Covered by
`input_render::{seed_count_is_advertised_at_the_optional_position,
seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given,
seed_count_hint_also_fires_after_a_column_fill_target}`.
## Out of scope
Deliberately deferred to keep this ADR shippable as a single
@@ -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
@@ -103,6 +103,10 @@ Prose-retained surfaces (**unchanged** from ADR-0016 §5):
`add`/`drop index` — keep the terse `References:` /
`Referenced by:` prose. A simple `add column` on a heavily-related
table should not print a wall of diagrams.
*(**Superseded 2026-06-12 by ADR-0050** (issue #28): these incidental
DDL echoes now render **structure only** — no relationship block at
all, neither prose nor diagram. The prose renderer was deleted. The
diagram surfaces below are unchanged.)*
So this **partially supersedes ADR-0016 §5**: the prose block is
replaced by diagrams on the relationship-subject surfaces and
@@ -525,7 +525,9 @@ All tiers green, zero skips; clippy clean (nursery).
submits over a multi-logical-line buffer. DA3/DA4 keep a single
logical line; this remains a separate, deferred feature.
- **Readline shortcuts (I1b)** — Ctrl-A/E/W/K/U stay reserved-deferred;
not touched here.
not touched here. *(Superseded 2026-06-12: I1b is now in scope and
decided by **ADR-0049** — Esc-clear + Ctrl-A/E/W/K/U in the input
field, issue #29.)*
- **Cross-session sidebar persistence** — visibility is session-only
(DB1); persisting it would amend ADR-0015.
- **The output panel as a third navigation focus target** — navigation
@@ -554,3 +556,27 @@ All tiers green, zero skips; clippy clean (nursery).
and is accepted: 90 is the screencast width, real terminals sit well
to one side of it, and `Ctrl-O` peek covers the in-between case. The
`90` threshold is a tunable constant.
## Amendment 1 — focus accent is a colour, not bold (2026-06-12)
Issue #25. DC3's "accent border" on the focused sidebar panel was
first implemented as bright `theme.fg` **plus `Modifier::BOLD`** on
the box-drawing border. Bold box-drawing glyphs render as broken /
gapped line-art in the asciinema player used for the website casts
(vertical strokes don't connect to the corner glyphs) and are
fragile in some terminals.
**`panel_border_style` now marks focus with a non-bold accent
colour — `theme.mode_simple` (blue) — and never `Modifier::BOLD` on
a border.** The unfocused border stays muted `theme.border`. This
makes the ADR's "accent border (lazygit convention)" wording
literal — it is now a true accent hue rather than bold bright-fg —
and is what renders cleanly in casts. Bold remains fine on *text*
spans (titles, key hints); the constraint is specifically that
box-drawing borders carry no bold attribute.
Note: this is a pure style change. The Tier-2 snapshots are
text-only (`render_to_string` captures cell symbols, not styles),
so none needed re-accepting; the Tier-1 `panel_border_style`
assertion was updated and a render-level test now checks the actual
border cells carry the accent colour and no bold.
@@ -317,6 +317,8 @@ with the implementation):
| `url`/`website`/`homepage` · `color`/`colour` | URL / hex colour | text |
| `price`/`amount`/`cost`/`salary`/`balance`/`total` | currency-range number | numeric |
| `age` · `quantity`/`qty`/`stock`/`count` | 1880 · small int | numeric |
| `year`/`*_year`/`published`/`founded` (Amendment 1) | bounded year (birth window for `birth`/`born`/`dob`, else 19502025) | int |
| `priority`/`prio` · `severity` · `rating`/`stars` (Amendment 1) | built-in `PickFrom` value set | text/int |
| `date`/`*_date` | date, recent ~3 yr window | date |
| `dob`/`birthday` | date, adult window (1880 yr ago) | date |
| `timestamp`/`datetime` · `created_at`/`updated_at`/`*_at` | datetime, recent window (`updated_at``created_at`) | datetime |
@@ -675,3 +677,66 @@ the regression floor.
derive-`IN`-else-friendly-fail tier.
- **`set`-driven NULL / per-column report / recursive parent seed:**
deferred — see Out of scope.
## Amendment 1 — year-as-int + conventional choice sets (2026-06-12)
Two SD2-style refinements to the D7 catalogue, surfaced while writing
the website `seed` docs. Both are additive name rules; no change to D8
(type fallback), the executor, or the grammar.
### Issue #33 — year-like `int` columns
A column such as `published` or `birth_year` was just an `int`, so it
fell through to the unbounded type-based `int` path (D8) and produced
nonsense like `9419` or `1426` — implausible as years, undercutting the
"realistic data" pedagogy. Added an **`int`-gated** year rule, placed
*after* the quantity rule (so `year_count` stays a count):
- `year` / `*_year` / `published` / `founded` → **`YearRecent`**, a
bounded window of **19502025** (75 years relative to the fixed
`REF_YEAR`, wide enough for published books / founding years /
release years; matches the issue's own `between 1950 and 2020`
workaround).
- the same with a `birth` / `born` / `dob` token (e.g. `birth_year`) →
**`YearBirth`**, mirroring the existing `dob → DateAdult` adult birth
window as years (**19452007**).
Both emit a plain `int`. `published` / `founded` are included
(user-confirmed): an `int` so named is almost always a year (a flag
would be `is_published`). The generators are **not** added to the D9
named-generator vocabulary — explicit control stays with `set <col>
between <lo> and <hi>`.
### Issue #34 — built-in value sets for conventional choice names
D12 deliberately does not guess values for enum-ish names. For a few,
though, there is a near-canonical small set that reads far better than
lorem text. Added a **type-gated `PickFrom`** lookup (reusing the
existing generator — no new machinery), placed ahead of the enum-ish
fallthrough:
| Name (tokens) | text | int |
|---|---|---|
| `priority` / `prio` | `low`/`medium`/`high` | `1`/`2`/`3` |
| `severity` | `low`/`medium`/`high`/`critical` | `1`/`2`/`3`/`4` |
| `rating` / `stars` | — | `1``5` |
A user-declared `IN`-CHECK (D17) still wins — it is resolved before the
heuristics. Any name that gains a set is **removed from the enum-ish
advisory trigger** (`priority` left `ENUM_TOKENS`); since the advisory
(D13) only fires on `Generator::Generic`, a `PickFrom` name is excluded
either way, but the removal keeps `is_enum_ish` semantically "names seed
still can't guess".
**`status` is deliberately excluded** (user-confirmed on the issue): its
real values are too domain-specific (`active/inactive`,
`open/closed/pending`, `draft/published`, …), so it keeps the D12
"don't guess" stance — generic text + the advisory pointing at `set
status in (…)`. `state` stays its US-state-name generator (D7);
`type`/`kind`/`category`/`stage`/`gender` and `size`/`tier`/`plan` were
considered and left to the advisory.
**Website follow-up** (tracked on the `website` branch, not here): the
`seed` cast exercises a `tickets` table with `priority`; it should be
re-recorded so the table tightens once `priority` collapses to a short
value — likely subsumed by the pre-publication cast sweep.
@@ -0,0 +1,114 @@
# ADR-0049: Input-field readline keymap — Esc-clear + Ctrl-A/E/W/K/U (I1b)
## Status
**Accepted + implemented 2026-06-12 (issue #29).** Closes Gitea **#29**
("Command input keystroke support") and the deferred **I1b** readline
requirement in `requirements.md`. Every fork below was escalated to the
user and user-chosen before any code was written; implemented test-first
(22 new Tier-1 tests in `src/app.rs`, all green; clippy nursery clean).
This ADR **amends ADR-0046**, which explicitly listed "readline
shortcuts (I1b)" in its out-of-scope set: that item is now in scope and
decided here. It is orthogonal to ADR-0003's input-*mode* model (simple
vs advanced, the `:` sigil) — these are editing keys within the input
field, not mode or sigil changes — and it extends the single-line cursor
editing already shipped under requirement **I1a** (Left/Right/Home/End/
Backspace/Delete, `app.rs`).
## Context
The input field already supported in-line cursor editing (I1a): Left/
Right by char (UTF-8 aware), Home/End to the extremes, Backspace/Delete.
Two gaps remained, raised in issue #29:
1. No way to **clear a partly-typed command** in one keystroke — a user
who started typing the wrong thing had to hold Backspace.
2. No **readline cursor/kill shortcuts** (Ctrl-A/Ctrl-E and friends) for
keyboards without Home/End and for muscle-memory in a command-driven
workflow. This is requirement I1b, deferred by ADR-0046.
`Esc` was free in the input field except that a *live Tab-completion
memo* consumes it first (to undo the completion in one keystroke,
ADR-0022). Ctrl-A/E/W/K/U were unbound. The existing chords are Ctrl-C
(quit), Ctrl-O (nav focus cycle, ADR-0046), and Ctrl-`]` (demo caption
toggle, ADR-0047) — none collide with a/e/w/k/u.
## Decision
Bind the following in the input field (non-modal, non-navigation,
both input modes), in `App::handle_key`:
| Key | Action |
|-----------|---------------------------------------------------|
| `Esc` | Clear the input (empty buffer, cursor→0, scroll→0)|
| `Ctrl-A` | Cursor to line start (alias of Home) |
| `Ctrl-E` | Cursor to line end (alias of End) |
| `Ctrl-W` | Delete the word before the cursor |
| `Ctrl-K` | Kill from the cursor to end of line |
| `Ctrl-U` | Kill from start of line to the cursor |
Behavioural rules:
- **Esc precedence.** A live completion memo still wins: the first Esc
undoes the completion (ADR-0022), and Esc only *clears* when no memo
is alive. This is a natural progression — Esc once to back out the
completion, Esc again to clear.
- **Esc does not clear while navigating the sidebar.** When a sidebar
panel is focused (Ctrl-O, ADR-0046 DC3), `handle_key` routes every
key to the navigation handler *before* the input-field keymap, where
Esc exits navigation mode (`nav_exit`). Entering nav mode never
touched the input buffer, so Esc-to-close-the-panel returns focus to
the input with the partly-typed command intact — it cannot reach the
clear binding. Locked by a regression test.
- **Single Esc clears** (user-chosen over double-Esc). Discoverable and
fast; the trade-off (an accidental Esc wipes an unsubmitted line) was
accepted. A submitted line is always recoverable from history; only
*unsubmitted* draft text is lost.
- **Cursor-only keys don't touch history navigation.** Ctrl-A/Ctrl-E,
like Home/End, move the cursor without ending history recall.
- **Buffer-mutating keys end history navigation.** Esc-clear and
Ctrl-W/K/U call `cancel_history_navigation` (the cleared/edited line
*is* the new draft), matching Backspace/Delete.
- **Ctrl-W is readline-style and UTF-8 safe.** It eats any run of
trailing whitespace, then the preceding run of non-whitespace; word
boundaries are found on char boundaries so multi-byte words delete
cleanly. It only ever deletes back to the cursor (a mid-line Ctrl-W
leaves the suffix intact).
Helpers added: `clear_input`, `delete_prev_word`, `kill_to_end`,
`kill_to_start` (`src/app.rs`), mirroring the existing `cursor_left` /
`delete_before_cursor` style.
## Forks (all user-chosen)
- **Esc semantics:** single-Esc-clears, *not* double-Esc — discoverable
over accident-proof.
- **Scope:** the *full* I1b set (Esc-clear + Ctrl-A/E/W/K/U), not just
the issue's literal Ctrl-A/E + Esc — closes the whole I1b requirement
in one pass rather than leaving Ctrl-W/K/U for a follow-up.
- **Documentation:** a new ADR (this one), recording the input-field
keymap convention and amending ADR-0046's OOS list — over folding it
into ADR-0046 or shipping it I1a-style with no ADR.
## Consequences
- I1b is complete; `requirements.md` I1b moves to `[x]`.
- The new keys are **not yet advertised on screen.** Surfacing per-focus
keybindings in the bottom status line is issue #27's domain (a
separate, in-design UX change); this ADR makes the keys *work*, #27
will make them *discoverable*.
- **Demo-mode badges** (ADR-0047) are *not* extended to the new Ctrl-
chords here. Esc already badges as `[ESC]`; Ctrl-A/E/W/K/U are
glyph-less and would be invisible in an asciinema cast. Whether to add
`[CTRL-A]``[CTRL-U]` badges is left to ADR-0047's scope and flagged
as a follow-up — it is a cast-polish concern, not a #29 requirement.
## Out of scope
- On-screen keybinding hints for the input field (issue #27).
- Demo badges for the new chords (ADR-0047 follow-up; flagged above).
- Multi-line input (I1) and its Ctrl-Enter submit — unrelated, still
deferred.
- Word-wise *cursor motion* (Alt-B/Alt-F) and transpose/yank — not
requested; not part of I1b.
@@ -0,0 +1,119 @@
# ADR-0050: Incidental-DDL confirmations omit relationship info (structure-only)
## Status
**Accepted + implemented 2026-06-12 (issue #28).** Closes Gitea **#28**.
Both forks below were escalated to the user and user-chosen before any
code was written; implemented test-first. **Supersedes** the
incidental-DDL clause of **ADR-0044 §1** and the part of **ADR-0016 §5**
that placed a relationship block under every structure echo. The
diagram behaviour ADR-0044 introduced for relationship-subject surfaces
is unchanged.
## Context
ADR-0016 §5 rendered a structure box followed by a plain-text
`References:` / `Referenced by:` relationship block under **every**
structure echo. ADR-0044 §1 split that by surface:
- **Relationship-subject surfaces** — `show table <T>`,
`add 1:n relationship`, `drop relationship`, `show relationship <name>`
— render relationships as compact **diagrams** (the user asked for, or
acted on, a relationship).
- **Incidental DDL auto-shows** — `create table`, `add`/`drop`/`rename`/
`change column`, `add`/`drop index` — kept the terse **prose** block,
with the rationale *"a simple `add column` on a heavily-related table
should not print a wall of diagrams."*
Issue #28 reconsiders the deeper question ADR-0044 did not ask: should
an incidental-DDL confirmation show relationship information **at all**?
Owner preference: **no.** A confirmation echo should focus on the change
just made — the new / updated structure — not re-print the table's
relationships, which the user did not touch. The terse prose was the
lesser of "prose vs diagram", but the right answer for these surfaces is
**neither**.
## Decision
**Incidental-DDL confirmation echoes render the structure only** — the
table-name header, the column / type / constraints box, the `Indexes:`
section, and the constraint section — with **no relationship section**
(neither prose nor diagram).
- **Scope: all incidental DDL** (user-chosen, over "just `add column`"):
`create table`, `add column`, `drop column`, `rename column`,
`change column`, `add index`, `drop index`. The rule is uniform — a
structural edit confirms structure, never relationships. (For a
freshly `create`d table the relationship section was empty anyway; the
rule still applies for consistency of the mental model.)
- **Relationship-subject surfaces are unchanged.** `show table`,
`add`/`drop relationship`, and `show relationship <name>` still render
diagrams. Relationships appear **only** when the user asks for them
(`show table` / `show relationship`) or acts on one
(`add`/`drop relationship`).
- **No information is lost.** Anything dropped from an incidental echo is
one `show table <T>` away.
### Mechanism
The `handle_dsl_success` routing (`app.rs`) is **unchanged**: it still
sends relationship-subject commands to the diagram renderer and
everything else to `render_structure`. The change is entirely inside
`render_structure` (`output_render.rs`): it no longer appends the
relationship block — `render_structure` = structure box + indexes +
constraints. All of `render_structure`'s callers are incidental DDL
(verified), so this single edit covers the whole scope with no
per-command branching.
### Prose renderer disposition
The orphaned prose renderer (`relationship_prose_lines`, and its
sole helper `cols_disp`) is **deleted** (user-chosen, over retaining it
dormant). After this change no shipped surface renders the prose form,
so keeping it would be dead code. The prose format remains documented in
**ADR-0016 §5** and in git history; if ADR-0044's OOS-7 user-configurable
"always-prose" display setting is ever built, it re-introduces the ~30
lines from that provenance.
## Forks (all user-chosen)
- **Scope:** *all incidental DDL*, not just `add column` — the owner's
rationale ("confirm the change, not untouched relationships") applies
uniformly, gives a clean mental model, and is the simpler edit (remove
one call vs a per-command flag).
- **Prose renderer:** *delete* it — no dead code — over retaining a
public, tested-but-uncalled renderer for the speculative OOS-7 setting.
## Consequences
- Incidental confirmations are shorter and on-topic; a heavily-related
table no longer prints a relationship wall after `add column`.
- One relationship renderer (prose) leaves the codebase; the diagram
renderer (ADR-0044) is the only relationship render path that ships.
- `requirements.md` is unaffected (this is an ADR-tracked refinement of a
decided area, like ADR-0044 itself); the change is cross-referenced
from the commit + this ADR.
## Tests
- **Unit (`output_render.rs`):** the prose-asserting test
`render_structure_with_relationships` (+ its snapshot) is removed; a
new test asserts `render_structure` on a description **carrying** both
inbound and outbound relationships emits the structure box but **no**
`References:` / `Referenced by:` lines. The box/index/constraint tests
are unaffected (their descriptions have no relationships).
- **Integration (`walking_skeleton.rs`):** the misnamed
`add_relationship_flow_shows_inbound_section_on_parent` (which sends an
`AddColumn` and asserted the inbound prose) is inverted + renamed to
assert the add-column confirmation shows the structure but **omits**
the relationship prose.
- **Unchanged:** the diagram tests (`show_list.rs` `show table`,
`walking_skeleton.rs` `add relationship`) still pass — they already
assert prose is absent and diagrams are present.
## Out of scope
- The diagram form and its per-surface defaults (ADR-0044) — unchanged.
- The OOS-7 user-configurable display setting (always-prose / -diagram /
auto-by-width) — still a future follow-up; this ADR removes the prose
*renderer*, not the *idea* of a prose mode.
@@ -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,250 @@
# 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 has since been
**fully unwound** (2026-06-14 follow-up): `_source` removed from
`finalize_persistence` / `do_rebuild_from_text`; the three read-only
`*_request` wrappers inlined and deleted; and — because the cascade ran
deeper than first estimated — the now-dead `source` param dropped from
the ~30 worker handlers (leaf + composite) that only forwarded it, plus
the `source` field removed from the `DescribeTable` / `QueryData` /
`RunSelect` requests and the matching `DatabaseHandle` method parameters
(the ~164 call-site churn was mostly tests). The only `source` left in
the worker is the snapshot/undo label (`snapshot_then` /
`stage_pre_mutation` / `begin_batch`), passed at the match-arm level.
Purely mechanical, compiler-guided, no behaviour change.
- **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).
@@ -0,0 +1,404 @@
# ADR-0053: Contextual `hint` — F1 live-input keybinding + `hint` command, with a tier-3 teaching corpus (H2)
## Status
Accepted — implementation in progress. Revised after a `/runda` review
(2026-06-14): corrected the verbosity-default fact; re-keyed tier-3
content off `help_id`; split the pre-submit-diagnostic and runtime-error
paths; added a comprehensiveness coverage test. Revised again during
Phase B implementation (2026-06-15): the first exemplar showed per-*node*
keying is too coarse for multi-form commands (`add`/`drop`/`show`/
`create`), so D3 now keys tier-3 content **per form** via a
`hint_ids: &[&str]` array mirroring `usage_ids` — and **clause-concept
hints** are recorded as a deferred extension (separate tracking issue).
The parallel question of whether the in-app `help` command should
likewise distinguish advanced-SQL forms is tracked **separately** as
Gitea issue #36 (it touches shipped, ADR-backed `help` behaviour).
Decided in conversation 2026-06-14. Closes the last open piece of **A1**
(the canonical app-command set, ADR-0003): every app command is
implemented except `hint`, which ADR-0003's command table listed as
*"Request a hint for the current input (ADR pending)."* This ADR is that
pending decision. Tracked as **H2** in `docs/requirements.md`.
References ADR-0003 (app-command set + the `:` escape), ADR-0019 (the
friendly error layer / H1), ADR-0021 (per-command usage templates / H1a),
ADR-0022 (ambient typing assistance — colour + hint panel + completion),
ADR-0027 (input validity indicator), ADR-0046 (sidebar navigation +
responsive input hint), ADR-0049 (input-field readline keymap), and
ADR-0051 (context/state-aware keybinding strip).
## Context
`hint` is the only unbuilt app command. The naive reading — "show a hint" —
hides a real subtlety, and a real cost.
**The subtlety: a submitted `hint` command cannot see live input.** App
commands are submitted with Enter, which empties the input buffer. By the
time `hint` dispatches, the partial command it was meant to help with is
gone. So "a hint for the current input" cannot be served by a submitted
command alone — it needs a *keybinding* that acts on the live buffer
without submitting. ADR-0003 said "current input"; `requirements.md`
broadened it to "current input **or the most recent error**." Both are
wanted; they map to two different trigger surfaces.
**The cost: the value of `hint` is content, not plumbing.** The app
already carries two tiers of contextual text:
- **Tier 1** — terse, always-on: syntax colour (ADR-0022); the error
*headline* alone (ADR-0019, when `messages_verbosity: Short`).
- **Tier 2** — short contextual lines: the ambient typing prose /
`expected` set, shown live while typing (ADR-0022, catalogue
`hint.ambient_*` / `hint.value_slot_*`); and the error `hint:` field —
which, because `Verbosity::Verbose` is the **default**
(`src/friendly/translate.rs:46`), is shown **by default** beneath every
error headline (`messages short` is the opt-*out*, not `messages
verbose` the opt-in).
So the verbose error hint is **already on screen by default**. If `hint`
merely re-showed it, it would duplicate what the user can already see (and
the ambient panel). To justify itself, `hint` must add a **tier 3**: a
genuinely deeper, *teaching*-grade explanation — what the command/error
means, a worked example, and the underlying relational concept. That
corpus does not exist yet, and
authoring it (to the standard of a teaching tool, where "pedagogy wins
ties") is the bulk of the work.
The mechanism is small and reuses everything already present: the command
REGISTRY (`src/dsl/grammar/mod.rs`), the `AppCommand` enum
(`src/dsl/command.rs`), key dispatch (`App::handle_key`,
`src/app.rs:1155`), the `note_help`/`note_help_topic` renderers
(`src/app.rs:2982`/`3021`), the parser/walker expected-set
(`ParseError.expected`, `WalkResult.tail_expected`), the friendly
catalogue + `t!` macro + `keys.rs` validation, and the output styling
vocabulary (`OutputStyleClass::Hint`).
## Decision
### D1 — Two surfaces, no topic argument
`hint` is delivered through **two complementary surfaces**:
1. **F1 keybinding → live input.** Pressing **F1** while typing renders a
tier-3 hint for the command currently in the buffer, into the output
panel, **without submitting or altering the buffer**. This is the
primary, most-valuable path (it serves the literal "current input").
2. **`hint` command → most recent error.** Submitting `hint` renders the
tier-3 expansion of the most recent error. This is why the command
exists despite the empty-buffer problem: the thing it helps with is
the *last thing you tried*, not the now-empty buffer.
`hint` takes **no topic argument**. Explicit per-command reference is
already `help <topic>` (H3); `hint` is purely *contextual*, which keeps
the two cleanly distinct (`hint` = "help me with what I'm doing right
now"; `help insert` = "show me the insert reference").
F1 is a **read-only overlay**: it never alters the input buffer, the
cursor, or the live completion memo (ADR-0022) — it only emits a block
into the output journal. (It must therefore be handled in `handle_key`
*before* the "any other key clears the memo" fall-through.)
### D2 — Trigger matrix
| Trigger | Buffer / state | Result |
|---|---|---|
| **F1** | non-empty input | tier-3 hint for the command being typed, plus the live "expected next" (from the walker's `tail_expected` / parser `expected`) |
| **F1** | empty input, a recent error exists | tier-3 expansion of that error |
| **F1** | empty input, no recent error | a short "getting started" pointer (press F1 while typing a command; `help` for the full list) |
| **`hint`** (submitted) | a recent error exists | tier-3 expansion of that error (primary use) |
| **`hint`** (submitted) | no recent error | the same "getting started" pointer |
F1 is inert behind a modal and while a sidebar panel holds navigation
focus (consistent with the existing `handle_key` gates, ADR-0046); it is
active in the input context in both Simple and Advanced mode.
**Two error sources, one namespace.** Errors come in two kinds and reach
`hint` by different routes:
- **Pre-submit diagnostics** (the ~33 `diagnostic.*` classes — arity,
type, unknown table/column) are computed *while typing* by the walker.
The **F1 live-input path** reads the current under-cursor diagnostic
directly from the walker (the same source the ambient panel uses) and
renders its `hint.err.<class>` block — no stored state needed.
- **Runtime errors** (the 9 `translate_error` classes) occur *after*
submit. The **`hint` command / empty-input F1** path reads them via the
stored `last_error_hint_key` (D5).
Both render from the same `hint.err.*` namespace. **`:`-prefix handling:**
on the simple-mode one-shot escape (`: SELECT …`), command
identification for the F1 path strips the leading `:` first, so the
advanced form is matched.
### D3 — The tier-3 content model
Tier-3 blocks live in the friendly catalogue under the existing `hint:`
top-level namespace (where tier-2 ambient strings already live), in two
new sub-namespaces:
- **`hint.cmd.<hint_id>`** — one per command **form**, keyed by a **new
`hint_ids: &'static [&'static str]`** field on `CommandNode`
(`src/dsl/grammar/mod.rs:512`), **mirroring the existing `usage_ids`**.
The F1 live-input path resolves the current input to its form's hint key
via `hint_key_for_input_in_mode`, which reuses the same form-word
disambiguation as `usage_key_for_input_in_mode`.
**Why an array mirroring `usage_ids`, not a per-node `hint_id`**
*(`/runda`/implementation revision, 2026-06-15)*: a single per-node key
is too coarse. Several entry words are **one node spanning many forms**
`add` (column/relationship/index/constraint), `drop` (table/column/
relationship/index), `show` (data/table/tables/relationships/indexes),
`create` (table/index). A live-input hint for `add 1:n relationship` is
only useful if it is *specific to relationships*, so the content must be
**per form**, not per node. The project already solved exactly this for
usage templates (`usage_ids` is a per-form array, disambiguated by the
form word), so `hint_ids` mirrors it. Single-form nodes carry one entry;
multi-form nodes carry one per form. This also covers the advanced-SQL
forms whose `usage_ids` are empty (`SQL_INSERT/UPDATE/DELETE`,
`EXPLAIN_SQL`) — they get their own `hint_ids` directly, independent of
usage, with mode-correct SQL examples. (The `help`-list collapse of
advanced-SQL forms is a separate gap — issue #36.)
**Deferred extension — clause-concept hints** (issue #37): per-form is
the right granularity for tier-3 *teaching* (position-awareness within a
form is owned by tier-2 ambient + the live `Next:` line, D4). But some
**concepts live inside a clause**, not a form — `… on delete ⟨cascade|
set null|restrict⟩` (referential actions), the `create table` constraint
slots (`primary`/`unique`/`check`/`foreign`), `with pk`, `1:n`/`m:n`
cardinality. A learner parked in such a clause may want teaching deeper
than tier-2's candidate list but narrower than the whole-form block. v1
does **not** build this (it would multiply content for points whose value
we can't yet measure, and we don't expect to accumulate usage statistics
to drive it empirically — it will be tackled as a deliberate follow-up
job). The keying does not lock it out: a later `hint.concept.<topic>`
namespace can be surfaced when the cursor sits in a recognized clause,
layered on top of the per-form block.
- **`hint.err.<class>`** — one per error/diagnostic class, keyed by the
friendly error/diagnostic key (e.g. `hint.err.foreign_key.child_side`,
`hint.err.type_mismatch`, `hint.err.insert_arity_mismatch`). Used by
both error routes (D2).
Each tier-3 block is a **structured entry with three labelled parts**, so
the voice stays consistent and the renderer can style them uniformly:
```yaml
hint.cmd.dsl.insert:
what: "Add one or more rows to a table."
example: "insert into Customers values ('Ann', 'ann@x.io')"
concept: "A row is one record; each value lines up with a column, in
order. Columns typed `serial`/`shortid` fill themselves — leave them out."
```
- **`what`** — one or two plain sentences: what this command does / what
this error means.
- **`example`** — a single concrete, copyable line (rendered neutral, not
muted, so it stands out as runnable).
- **`concept`** — the underlying relational idea, in teaching voice; the
part that makes this tier-3 rather than tier-2.
`concept` is optional where there is genuinely no concept beyond the
mechanics (e.g. `quit`); `what` + `example` are always present.
### D4 — Rendering
Both surfaces render through one new renderer, `App::note_hint*` (sibling
of `note_help`/`note_help_topic`, `src/app.rs`), emitting a small framed
block into the `output` buffer as `OutputKind::System` with
`OutputStyleClass::Hint` on the `what`/`concept` prose and `Neutral` on
the `example` line. The block is **persistent** (scrolls in the journal),
unlike the transient ambient panel — pressing F1 is an explicit request
to *keep* the deeper guidance on screen. The bottom keybinding strip
(ADR-0051) advertises F1 in the editing/typing state.
### D5 — "Most recent (runtime) error" state
The **runtime-error route** (submitted `hint`, and empty-input F1) needs
to map the last runtime error back to its `hint.err.<class>` key. Runtime
errors today live only as rendered text in the `output` buffer. We add a
single small piece of `App` state — **`last_error_hint_key:
Option<String>`** — set at the `translate_error` call sites
(`runtime.rs:2615`, `app.rs:2424`) when a friendly error is rendered,
cleared when a later command succeeds. Absent → the "getting started"
pointer.
The **pre-submit-diagnostic route** (the F1 live-input path) needs no
stored state: it reads the current diagnostic from the walker at F1 time
(D2). This is the cleaner split the `/runda` pass surfaced — typing-time
diagnostics and post-submit runtime errors are genuinely different
sources and should not be funnelled through one stored key.
### D6 — Content scope: comprehensive for v1
v1 ships tier-3 content for the **whole inventory**, not a subset (the
graceful tier-2 fallback below is a safety net, not the plan):
- **~37 command forms** — every distinct node in `REGISTRY` gets its own
`hint.cmd.<hint_id>` block (app + DSL + DDL + advanced-mode SQL forms),
each with a **mode-correct example** (the advanced-SQL forms show SQL
syntax, their simple siblings show DSL — no sharing).
- **9 runtime error classes**`unique`, `foreign_key` (×4 sides),
`not_null`, `check`, `type_mismatch`, `not_found`, `already_exists`,
`generic`, `invalid_value` — each gets a `hint.err.*` block.
- **~33 `diagnostic.*` pre-submit classes** — arity, type, unknown
table/column, etc. — each gets a `hint.err.*` block.
The full enumerated checklist is the implementation plan's tracking
artifact (see *Content inventory*, below).
**Fallback (safety net):** if a tier-3 key is ever missing at runtime,
the surface degrades to tier 2 — the ambient prose for the command path,
or the verbose error `hint:` for the error path — never to a blank or an
error. The `keys.rs` build-time validation keeps the corpus honest, so a
missing key is caught in tests, not in front of a student.
### D7 — Authoring process: exemplars-first
Because the corpus is large and its *voice* is a pedagogical decision the
maintainer owns, content is produced in two stages:
1. This ADR carries **23 worked exemplars** (below) as the canonical
style reference. The `/runda` review of this ADR is where the voice and
depth are approved.
2. Once approved, the remaining blocks are authored to that template in
**reviewable batches** (grouped by area: DDL, DML, app commands,
error classes), not one monolithic drop.
### Exemplars (the style reference to approve)
**Command (F1 live-input), `insert`:**
```
Hint — insert
What: Add one or more rows to a table.
Example: insert into Customers values ('Ann', 'ann@x.io')
Concept: A row is one record; each value lines up with a column, in
order. Columns typed serial/shortid fill themselves — leave
them out.
Next: a value list `(...)`, or `(col, ...) values (...)` to name columns
```
(The "Next:" line is the live expected-set from the walker, shown only on
the non-empty-input F1 path.)
**Error (`hint` command), foreign-key child-side violation:**
```
Hint — no parent row to point at
What: The value you inserted into Orders.customer_id doesn't match
any Customers row, so the foreign key has nothing to point at.
Example: First insert into Customers values ('Ann', ...)
Then insert into Orders values (..., 'Ann')
Concept: A foreign key is a promise that every child points at a real
parent. The parent must exist first. To allow orphans on
delete instead, set the relationship's `on delete` to
`set null` or `cascade`.
```
**Command (F1 live-input), `add 1:n relationship`:**
```
Hint — add relationship
What: Link two tables so a parent row can own many child rows.
Example: add 1:n relationship from Customers.id to Orders.customer_id
Concept: The "1:n" means one parent, many children. The child column
holds the foreign key; `--create-fk` adds it for you if it
doesn't exist yet.
```
## Forks (all user-chosen, 2026-06-14)
- **Trigger model:** both a keybinding (live input) and a submitted
command (last error), rather than command-only or keybinding-only — the
live-input path is the most useful, but the command completes the A1
slot and serves the error case.
- **Keybinding = F1:** the universal help convention; the key is
genuinely free (no `KeyCode::F(1)` binding exists today — the `"F1"`
strings in `input_render.rs`/tests are scenario labels, not the key, and
ADR-0022 uses no `F1` requirement label). No collision with the ADR-0049
readline keys, `Ctrl-O` (ADR-0046), `Esc`-clear, or the reserved
`Ctrl-C` cancel (I5). Rejected: `?` (a typeable character — fiddly
position-dependent handling) and a Ctrl/Alt chord (less discoverable, no
advantage).
- **No topic argument:** contextual only; `help <topic>` already owns
explicit reference lookup.
- **Comprehensive content for v1:** the full inventory, not a starter
subset.
- **Exemplars-first authoring:** lock the voice on a few blocks, then
mass-author to template.
## Consequences
- **A1 closes.** With `hint` registered and built, all 15 canonical
app-level commands exist in both modes.
- **A third contextual tier exists.** Students get on-demand, teaching-
grade guidance that is deeper than the always-on colour, the headline,
the ambient one-liner, and the verbose error hint — without cluttering
those terse defaults.
- **One new keybinding (F1)** joins the keymap and the ADR-0051 strip.
- **A new `hint_ids: &[&str]` field on `CommandNode`** (mirroring
`usage_ids`) + a `hint_key_for_input_in_mode` lookup (reusing the
`usage_key_for_input_in_mode` form-disambiguation), one new field of
`App` state (`last_error_hint_key`), and one new renderer family
(`note_hint*`); the `AppCommand` enum gains `Hint`, the grammar a `HINT`
node, the REGISTRY one entry.
- **A large, durable content corpus** (~37 command blocks + ~42 error/
diagnostic blocks ≈ 80) enters the catalogue under `hint.cmd.*` /
`hint.err.*`, validated by `keys.rs`. This is ongoing surface area: new
commands/error classes should ship with their tier-3 hint (a checklist
item for future feature ADRs).
- **Testing:** Tier-1 unit tests for the trigger matrix (F1 with
empty/non-empty input; `hint` with/without a recent error;
`last_error_hint_key` set on the `translate_error` sites and cleared on
success; the pre-submit-diagnostic vs runtime-error routing; the `:`
strip), the command-identification logic, and the tier-2 fallback;
Tier-2 `insta` snapshots for a representative rendered hint block;
Tier-3 integration tests for the end-to-end flows (type a partial
command → F1 → block appears, **buffer and completion memo untouched**;
run a failing command → `hint` → error expansion). **A
comprehensiveness coverage test** (enforces D6): iterate the REGISTRY
and assert every node has a `hint_id` resolving to a `hint.cmd.*` block,
and every runtime-error/diagnostic class has a `hint.err.*` block —
`keys.rs` only checks that *referenced* keys resolve, not that every
command/error *has* one, so this test is what makes "comprehensive"
enforceable rather than aspirational.
## Out of scope
- **Per-topic `hint <topic>`** — OOS (rejected): `help <topic>` already
serves explicit lookup; a topic arg would overlap it and double the
content-authoring surface.
- **Re-showing tier-3 inline as the always-on ambient hint** — OOS
(rejected): the ambient panel stays terse by design (ADR-0022); tier-3
is on-demand. Promoting it would defeat the tiering.
- **Localised tier-3 content beyond `en-US`** — OOS (deferred): the
catalogue is structured for i18n (ADR-0019), but additional locales
follow the project's English-only-for-v1 stance (requirements X2).
- **`hint` for a *successful* command's deeper teaching** (e.g. "you just
created a table — here's what an index would add") — OOS (deferred): a
plausible future tier-3 use, but v1 scopes the command path to errors
and the F1 path to in-progress input.
- **Clause-concept hints** (`… on delete ⟨action⟩`, constraint slots,
`with pk`, cardinality) — OOS (deferred, issue #37): a
`hint.concept.<topic>` layer surfaced when the cursor sits in a
recognized clause, deeper than tier-2's candidate list but narrower than
the per-form block. Per-form keying (D3) does not lock it out. To be
tackled as a deliberate follow-up job, not gated on usage statistics.
## Content inventory (implementation tracking)
The implementation plan enumerates and checks off every block:
- **`hint.cmd.<hint_id>`** — one per distinct `REGISTRY` node (~37), each
with its own `hint_id` and a mode-correct example: app (`save`, `save
as`, `load`, `new`, `rebuild`, `export`, `import`, `replay`, `undo`,
`redo`, `mode`, `messages`, `copy`, `help`, `hint`, `quit`); DDL
(`create table`, `create m:n`, `add column`/`relationship`/`index`,
`drop`, `rename`, `change column`); DML (`insert`, `update`, `delete`,
`show`, `seed`, `explain`, `select`/`with`). The **7 advanced-mode SQL
forms** (`SQL CREATE TABLE`, `ALTER TABLE`, `CREATE/DROP INDEX`, `DROP
TABLE`, `SQL INSERT/UPDATE/DELETE`, `EXPLAIN SQL`, raw `SELECT`/`WITH`)
each get their **own** block with SQL syntax — they do **not** reuse
their simple sibling's (this is the `/runda` correction; the parallel
`help`-side gap is issue #36).
- **`hint.err.*`** — one per runtime error class (`unique`,
`foreign_key.{child,parent}_side`, `not_null`, `check`,
`type_mismatch`, `not_found`, `already_exists`, `generic`,
`invalid_value`) and per `diagnostic.*` pre-submit class.
+8 -3
View File
File diff suppressed because one or more lines are too long
+173
View File
@@ -0,0 +1,173 @@
# Session handoff — 2026-06-12 (68)
Sixty-eighth handover. Continues directly from handoff-67 (which
triaged a manual-testing pass into fixes + filed issues). This was an
**issue-burndown session**: six Gitea issues closed across five
commits, each landed with the full phased workflow + a `/runda` +
Devil's-Advocate pass before commit. Net: **six issues closed, five
commits, +29 tests, zero regressions.**
## §1. State at handoff
**Branch:** `main`. Working tree **clean**; all work committed.
**Five unpushed commits** (push is the user's step).
**Tests: 2436 passing / 0 failing / 0 skipped / 1 ignored** (the
long-standing `friendly` doctest). **Clippy clean** (nursery, all
targets). Breakdown: 1730 lib + 506 integration (`it`) + 200
typing-surface-matrix. +29 over handoff-67's 2407.
**Commits since handoff-67:**
```
ee3ccd8 feat(hint): advertise the optional seed count in the hint panel (#26)
deb0948 feat(seed): year-as-int + conventional choice-set heuristics (#33, #34)
fde50ce fix(ui): mark sidebar focus with an accent colour, not bold (#25)
3d4a0fd fix(render): trim IEEE-754 noise from displayed decimal arithmetic (#32)
7e4bc12 fix(completion): treat a bare in-scope table alias as an alias, not an unknown column (#31)
```
## §2. Issues closed this session (all committed, all tested, all `/runda`-reviewed)
Each closed on `git.lazyeval.net/oli/rdbms-playground` with a summary
comment. The `/runda` pass earned its keep on every one — see the
"DA caught" notes.
1. **#31 (`7e4bc12`) — bare table alias treated as unknown column.**
A bare in-scope table alias in a SQL expression (`… GROUP BY o`,
`o` aliasing `FROM Orders o`) got `no such column o on table …` and
zero completions. Now: completion offers each FROM source's
*qualifier* (alias-if-present-else-table) at a bare `sql_expr_ident`
slot; the `matched.len()==0` arm emits a targeted
`alias_used_as_column` / `table_used_as_column` hint after the
projection-alias check. **DA caught** two real bugs pre-commit: a
DSL leak (the hint fired for simple-mode `expr_column` refs, which
have no `table.column` syntax) and wrong advice for an
aliased-table-by-real-name — both fixed by gating on
`role == "sql_expr_ident"` + matching the *effective qualifier*.
ADR-0032 Amendment 3.
2. **#32 (`3d4a0fd`) — decimal aggregation float noise.** `decimal`
is exact TEXT, but SQLite has no decimal type, so arithmetic
coerces to IEEE-754 double; `sum(price*qty)` rendered
`298.59999999999997`. Now `format_real_display` (db.rs) rounds REAL
to 15 sig figs **for display only**, wired into `format_cell`.
**DA caught** a real regression: I'd also wired it into
`render_value`, which is a *canonical identity key* for the
uniqueness dry-runs (`dry_run_unique`, `check_uniqueness_collisions`)
— rounding there would report collisions the exact-valued engine
wouldn't. Reverted `render_value` to exact; locked with a
regression test. CSV/FK-key/EXPLAIN paths stay exact. ADR-0005
Amendment 1.
3. **#25 (`fde50ce`) — sidebar focus accent colour, not bold.** Bold
box-drawing glyphs render broken in asciinema casts.
`panel_border_style` now uses a non-bold accent colour
(`theme.mode_simple`); bold stays fine on text spans. **DA caught**
that the issue's "Tier-2 snapshots need re-accepting" was wrong —
`render_to_string` is text-only, so no snapshot changed. Added a
render-level test that inspects the actual border *cells*.
User visually confirmed. ADR-0046 Amendment 1.
4. **#33 + #34 (`deb0948`) — seed heuristics: year-as-int + choice
sets.** Two additive D7 catalogue rules. **#33:** `year`/`*_year`/
`published`/`founded` → bounded `int` year (19502025, or the
`dob`-style birth window 19452007 for `birth`/`born`/`dob`); new
`YearRecent`/`YearBirth` generators. Placed *after* the quantity
rule so `year_count` stays a count. **#34:** type-gated `PickFrom`
sets for `priority`/`prio`, `severity`, `rating`/`stars`; `status`
**deliberately excluded** (user-confirmed on the issue — values too
domain-specific). `priority` left `ENUM_TOKENS`. A user `IN`-CHECK
still wins. **DA/process caught** that I'd skipped reading the issue
*comments* (where the `status` decision + a website cast note lived)
**lesson: always read issue comments**. Also closed a
pre-existing column-fill integration-test gap. ADR-0048 Amendment 1.
5. **#26 (`ee3ccd8`) — optional `count` advertised in the hint panel.**
At `seed <table> ▮` only `set`/`--seed` chips showed; the optional
row count (a bare positional number) was invisible, and the prior
`IntroProse` attempt was reverted because `pending_hint_mode` is
cleared by the trailing optionals. Now `walk_optional` stashes a
skipped inner's `IntroProse` key into a new
`WalkContext.surviving_intro_hint` (key + position) before the empty
match clears it; a **position guard** (`pos == cursor`) stops it
leaking past a later `set …` clause or once the count is given. Tab
still cycles the keywords. Prose mentions the count, `.column`
column-fill, `set`, and `--seed` (user-chosen scope). **DA caught**
a coverage gap (advanced-mode path untested — seed runs in both
modes); added the test. ADR-0022 Amendment 7.
## §3. Open issues — next session's candidates
Four open, all on `git.lazyeval.net/oli/rdbms-playground`. **All four
are interaction/UX design changes that need a decision or two from the
user up front — none is a pure mechanical fix.** Read each issue body
**and its comments** before starting (the #33/#34 lesson).
- **#28 — Reconsider relationship prose in `add column` (incidental
DDL) confirmations** *(enhancement)*. **Revisits a decided area**
needs a **new ADR** superseding the relevant part of ADR-0016 §5 /
ADR-0044 §1. User preference (from the issue): do **not** show the
`References:` / `Referenced by:` block in the add-column
confirmation. Confirm scope with the user (just `add column`, or all
incidental DDL). The highest-ceremony of the four.
- **#27 — Bottom status line: keybindings-only, context- and
state-aware; add `mode advanced` to empty hint** *(enhancement)*.
Per-nav-focus keybindings (Input vs sidebar), **including transient
states** (Tab-cycle, history) per user preference. May warrant a
small ADR. Touches `src/ui.rs` rendering + the nav-focus model
(ADR-0046).
- **#29 — Command input keystroke support.** Esc / double-Esc to clear
a partly-typed command; possibly Ctrl-A/Ctrl-E (Home/End). Relates
to the deferred **I1b readline shortcuts** (`requirements.md`).
**Needs a key-set decision** from the user before coding.
- **#30 — History brings back all commands in both modes.**
Advanced-mode history entries can't replay in simple mode; proposal:
if we can distinguish them, prepend `:` to reuse advanced history
from simple mode. Interaction design; touches the input-history +
mode model (ADR-0003).
No strong ordering. **#28** is the only one that *must* produce an ADR.
**#29** is closest to "small once the key-set is decided." **#27** and
**#30** are medium UX work.
## §4. Carried-over follow-up (not a `main`-branch task)
- **Website `seed` cast re-record** (from #34's comment thread). The
`website` branch ships a `seed` cast exercising a `tickets` table
with `priority`; now that `priority` collapses to `low/medium/high`,
the cast should be re-recorded (`cd website && pnpm casts seed`,
needs a `../target/debug` binary) so the table tightens. The issue
comment notes it is **likely redundant** — casts get a full
re-record sweep before publication. Tracked on the `website` branch,
**not** here. `website/` is not in the `main` tree.
## §5. Other open roadmap (unchanged from handoff-67 §5)
`seed` is feature-complete (`requirements.md` SD1/SD2 `[x]`, now with
the #33/#34 catalogue refinements noted inline). User's call:
- **H2 `hint`** — the last A1 gap (its own ADR).
- **TT5 CI** — test infra exists; no CI workflow yet (the `ci` branch
exists — check its state before starting).
- **TT4 PTY (Tier-4)** — ADR-0008 specifies it; not wired.
- Larger: **V4 journal**, **tutorial/lesson system** (each needs an ADR).
## §6. How to take over
1. Read handoffs 66 → 67 → 68, `CLAUDE.md`, `docs/requirements.md`.
2. Confirm green baseline: `cargo test` (expect 2436 pass / 1 ignored)
+ `cargo clippy --all-targets` (clean).
3. Pick from §3 (#28/#27/#29/#30). **For each, read the issue body AND
its comments** before designing, and **escalate the design fork to
the user** before coding — all four have genuine UX decisions. #28
needs a new ADR.
4. Follow the project workflow: phased (requirements → divergent →
eval → execute → verify), test-first (failing test before the fix),
`/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.
5. Consider a `cargo sweep` at this milestone (`target/` grows across
sessions; see CLAUDE.md "Build hygiene").
+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.
@@ -0,0 +1,243 @@
# Plan — ADR-0053: contextual `hint` command + F1 keybinding (H2)
Implements ADR-0053. Closes the last open piece of **A1** (the canonical
app-command set) and requirements **H2**. No Gitea issue — this is
requirements-driven work; any genuine "later" item found en route gets
its own issue (cf. #36, already filed for the parallel `help`-side gap).
## 1. Goal
Give learners on-demand, **teaching-grade** contextual help — a *third*
tier beneath the existing terse always-on text (tier 1) and the
short contextual lines that are already shown (tier 2: the live ambient
prose, and the error `hint:` which is on by default since
`Verbosity::Verbose` is the default). Two surfaces:
- **F1** (read-only overlay) → a tier-3 block for the **live partial
input**, or — on empty input — for the **most recent runtime error**.
- **`hint`** (submitted app command) → the tier-3 block for the **most
recent runtime error** (the buffer is empty post-submit, so it can only
act on recent context).
The mechanism is small; the **content corpus is the feature** (~80
blocks, comprehensive for v1, authored exemplars-first per ADR-0053 D7).
## 2. The shape of the work (why this order)
The mechanism and the content are separable, and the mechanism should
land first with **graceful tier-2 fallback** so every surface works
before any tier-3 text exists. That lets us:
- build + test the trigger matrix / routing / `:`-strip / read-only-
overlay behaviour against a skeleton (TDD), then
- pour in content in reviewable batches without re-touching the wiring,
- and turn on the **comprehensiveness coverage test** only once the
corpus is complete (it is red until then — by design).
Build order: **Phase A** (mechanism skeleton, falls back to tier-2) →
**Phase B** (catalogue structure + the three approved exemplars) →
**Phase C** (comprehensive content, batched) → **Phase D** (polish:
strip advertisement, snapshots, full green).
## 3. Grammar: the `hint_ids` field + the `HINT` node
### 3a. New `CommandNode.hint_ids` (per-form — revised in Phase B)
- Add `pub hint_ids: &'static [&'static str]` to `CommandNode`
(`src/dsl/grammar/mod.rs:512`, beside `help_id` / `usage_ids`),
**mirroring `usage_ids`***not* a per-node `Option<&str>`. The Phase-B
exemplar (`add 1:n relationship`) showed per-*node* keying is too coarse:
`add`/`drop`/`show`/`create` are each one node spanning many forms, and
a live-input hint must be specific to the typed form. Compiler forces
every node literal (~37, across `grammar/app.rs`, `data.rs`, `ddl.rs`) to
set it — Phase A/B leave most `&[]` (tier-2 fallback); Phase C fills them.
**Multi-form nodes list ALL their form keys** (e.g. `add`
`["add_column", "add_relationship", "add_index", "add_constraint"]`) so
the form-word disambiguation resolves correctly and unauthored forms fall
back at render rather than mis-resolving to a sibling.
- **Lookup:** `hint_key_for_input_in_mode(source, mode)` returns the single
typed form's hint stem, reusing `pick_form_key` (factored out of
`usage_key_for_input_in_mode` — shared digit/`m:n`/suffix disambiguation).
- **Why a new field, not `help_id`** (ADR-0053 D3): `help_id` is `None` on
the 7 advanced-SQL forms purely to dedup the `help` *list*; those forms
have distinct SQL syntax and need their own block. `hint_ids` is per
form. (The parallel `help`-side gap is issue #36; clause-concept hints
are deferred — issue #37.)
### 3b. `AppCommand::Hint` + the `HINT` node
- `AppCommand::Hint` variant (no fields — no topic arg) in
`src/dsl/command.rs:544`.
- `pub static HINT: CommandNode` in `grammar/app.rs` mirroring `HELP` but
with **no topic shape** (bare keyword, like `UNDO`): `entry:
Word::keyword("hint")`, `shape: EMPTY_SEQ` (as `UNDO`,
`grammar/app.rs:333`), `ast_builder:
build_hint` (returns `Command::App(AppCommand::Hint)`), `help_id:
Some("app.hint")`, `hint_id: Some("app.hint")`, `usage_ids:
&["parse.usage.hint"]`.
- Register `(&app::HINT, CommandCategory::Simple)` in `REGISTRY`
(`grammar/mod.rs`), beside `HELP`. (App commands are available in both
modes via the existing mechanism.)
## 4. Command identification (live-input → node)
The F1 live-input path needs "which command form is being typed." **The
lookup machinery already exists** — do not rebuild entry matching:
- `command_for_entry_word(word) -> Option<(usize, &'static CommandNode)>`
(`grammar/mod.rs:811`) returns the matched node for an entry word
(Simple-first; the caller extracts the first word of the input).
- `usage_keys_for_input_in_mode(source, mode)` (`grammar/mod.rs:564`)
already performs the **mode-aware** Simple/Advanced selection the hint
path needs (advanced `create` → the SQL nodes, simple → the DSL node) —
it just returns `usage_ids` rather than the node.
- **The only new bit:** a thin `hint_id_for_input_in_mode(source, mode)`
(or a node-returning sibling of `usage_keys_for_input_in_mode`) that
applies the same mode selection and returns the chosen node's
`hint_id`. Mirror the existing function; don't duplicate its matching.
- **`:`-strip:** in Simple mode, strip a leading `:` (one-shot escape,
ADR-0003) before identification so `: SELECT …` resolves to the
advanced `SELECT` node.
- No match (empty / unrecognised entry word) → the "getting started"
pointer (D2).
## 5. F1 keybinding (read-only overlay)
In `App::handle_key` (`src/app.rs:1155`):
- Add an F1 arm (`KeyCode::F(1)`) **after** the modal gate and the
sidebar-nav gate (inert there, per D2), and **before** the
"any other key clears the completion memo" fall-through (`_ =>
self.last_completion = None`, ~line 1228) — F1 must **not** clear the
memo or touch the buffer/cursor (D1).
- Behaviour (the trigger matrix, D2):
- non-empty input → `note_hint_for_input()` (the command's `hint.cmd`
block + the live "Next:" expected-set from the walker).
- empty input + `last_error_hint_key` set → `note_hint_for_error()`.
- empty input + no recent error → `note_getting_started()`.
- Returns `Vec::new()` (pure output emission, like `help`).
- `demo_badge_label` (`app.rs:520`) gains an `F1 → "[F1]"` entry so demo
mode surfaces it (ADR-0047).
## 6. The two error routes (D2 / D5)
- **Runtime errors:** add `last_error_hint_key: Option<String>` to `App`.
Set it where friendly errors are rendered (`runtime.rs:2615`,
`app.rs:2424`) from the error's class key; clear on the next successful
command. The `hint` command and empty-input F1 read it.
- **Pre-submit diagnostics:** the F1 live-input path, when the input
carries an under-cursor diagnostic, reads it straight from the walker
(`input_diagnostics_in_mode`, the same source the ambient panel uses)
and renders that diagnostic's `hint.err.<class>` block instead of (or
alongside) the command block. No stored state.
- Both render from `hint.err.*`.
## 7. Rendering: the `note_hint*` family (D4)
- New `App::note_hint_for_input`, `note_hint_for_error`,
`note_getting_started` (siblings of `note_help`/`note_help_topic`,
`app.rs:2982`/`3021`).
- A tier-3 block is **structured** (`what` / `example` / `concept`, plus
the live `Next:` line on the input path). The catalogue stores each part
under sub-keys (`hint.cmd.<id>.what`, `.example`, `.concept`); the
renderer fetches each via `t!` and lays them out as a small framed
block.
- Styling: `OutputKind::System`; `OutputStyleClass::Hint` (muted) on
`what`/`concept`/`Next`, `Neutral` on `example` so the runnable line
stands out. Reuse `OutputLine::styled` + `push_category_three_prose`
patterns (`app.rs:3121`).
- **Fallback:** if a node's `hint_id` is `None` or a key is missing,
degrade to tier-2 (ambient prose for the input path; the verbose error
`hint:` for the error path) — never blank.
## 8. Catalogue + `keys.rs`
- New sub-namespaces under the existing top-level `hint:` in
`src/friendly/strings/en-US.yaml`: `hint.cmd.<hint_id>.{what,example,
concept}` and `hint.err.<class>.{what,example,concept}`.
- Register every key + its placeholders in `src/friendly/keys.rs`
(`KEYS_AND_PLACEHOLDERS`) so the build-time validation covers them.
- `parse.usage.hint` + `help.app.hint` strings for the command itself.
## 9. Content (Phase C — the bulk, batched per D7)
Exemplars approved in the ADR (`insert` live-input, FK child-side error,
`add relationship`) are the template. Author in reviewable batches:
1. **App commands** (~16): save/save as/load/new/rebuild/export/import/
replay/undo/redo/mode/messages/copy/help/hint/quit.
2. **DDL** (simple): create table, create m:n, add column/relationship/
index, drop, rename, change column.
3. **DML** (simple): insert, update, delete, show, seed, explain,
select/with.
4. **Advanced-mode SQL forms** (7): SQL CREATE TABLE, ALTER TABLE,
CREATE/DROP INDEX, DROP TABLE, SQL INSERT/UPDATE/DELETE, EXPLAIN SQL —
**own blocks, SQL-syntax examples**.
5. **Runtime error classes** (9): unique, foreign_key ×{child,parent},
not_null, check, type_mismatch, not_found, already_exists, generic,
invalid_value.
6. **`diagnostic.*` classes** (~33): arity/type/unknown-table-column/etc.
Each block: `what` (12 sentences), `example` (one runnable line,
mode-correct), `concept` (the relational idea — the teaching part;
optional only where genuinely none, e.g. `quit`).
## 10. Tests
Written test-first against the Phase-A skeleton where possible.
- **Tier 1 (unit, `app.rs`):**
- trigger matrix: F1 non-empty → command block; F1 empty + recent error
→ error block; F1 empty + none → getting-started; `hint` command +
error → error block; `hint` + none → getting-started.
- `last_error_hint_key` set on a failing command, cleared on the next
success.
- routing: a pre-submit diagnostic on the input drives the diagnostic
`hint.err`; a runtime error drives the stored-key route.
- `:`-strip: `: SELECT …` in Simple mode resolves to the advanced node.
- **read-only overlay:** F1 leaves `input`, `input_cursor`, and
`last_completion` unchanged.
- tier-2 fallback when `hint_id`/key absent.
- **Tier 2 (`insta`):** snapshot a representative rendered tier-3 block
(the `insert` exemplar) so the framed layout + styling spans are locked.
- **Tier 3 (integration, `tests/it/`):** type a partial command → F1 →
block appears, buffer untouched; run a failing insert → `hint` → FK
error expansion.
- **Comprehensiveness coverage test** (enforces D6, the key one): iterate
`REGISTRY` and assert every node has a `hint_id` resolving to a
`hint.cmd.*` block; assert every runtime-error + `diagnostic.*` class
has a `hint.err.*` block. **Red until Phase C completes** — enable
(un-`ignore`) as the final gate.
- `keys.rs` validation continues to guarantee every *referenced* key
resolves.
## 11. Keybinding strip + discoverability (Phase D)
- The ADR-0051 bottom strip advertises **F1 = hint** in the editing/
typing state (and on the empty-input state, since F1 still does
something there). Re-accept the affected full-panel snapshots.
## 12. ADR / docs
- ADR-0053 is committed (`e16ad50`). On completion, flip its Status from
"implementation pending" to implemented (with date), and update the
README index entry + `requirements.md` **H2 → [x]** and **A1 → [x]**
(A1 closes when `hint` lands).
## 13. Risks / watch-list
- **Command-identification reuse.** The lookup exists
(`command_for_entry_word` + the mode-aware `usage_keys_for_input_in_mode`,
`grammar/mod.rs:811`/`564`); the only new code is a thin node/`hint_id`
variant that reuses their selection. Do **not** re-implement entry-word
matching — mirror the existing functions.
- **Structured-key ergonomics.** Three sub-keys per block × ~80 blocks is
~240 catalogue keys; keep the `keys.rs` registration generation tidy
(consider a helper that registers the `{what,example,concept}` triple
for an id).
- **Content voice drift across batches.** Re-check each batch against the
approved exemplars; the `concept` line is where drift (too terse / too
advanced) creeps in. Pedagogy wins ties.
- **F1 terminal capture.** A few terminals intercept F1; acceptable
(it's the convention) but note it if testing surfaces it.
- **Snapshot churn.** The strip change re-accepts ADR-0051 snapshots;
keep that diff isolated.
- **Coverage-test timing.** It is red through Phases AC; gate it so CI
isn't broken mid-stream (e.g. `#[ignore]` until the final batch), then
make passing it the completion criterion.
```
+14 -3
View File
@@ -147,11 +147,19 @@ since ADR-0027.)
cursor editing and is complete on its own terms; the separate
**multi-line** entry goal is tracked under I1, which is
genuinely not started.)*
- [ ] **I1b** Readline-style cursor shortcuts: Ctrl-A / Ctrl-E
- [x] **I1b** Readline-style cursor shortcuts: Ctrl-A / Ctrl-E
as aliases for Home / End for users on keyboards without those
keys (and for ergonomics in command-driven workflows). Likely
followed by Ctrl-W (delete previous word), Ctrl-K (delete to
end), Ctrl-U (delete to start). Pending.
end), Ctrl-U (delete to start).
*(Done 2026-06-12 — ADR-0049, issue #29: the full set —
Esc-clear + Ctrl-A/E/W/K/U — wired in `App::handle_key`
(`src/app.rs`) with helpers `clear_input` / `delete_prev_word`
/ `kill_to_end` / `kill_to_start`; Esc clears only when no
completion memo is alive (the memo wins first, ADR-0022);
cursor-only keys leave history navigation intact, kill keys
end it; 22 Tier-1 tests. On-screen advertisement of these keys
is issue #27's bottom-status-line work.)*
- [x] **I2** Persistent navigable input history (project-scoped).
*(Implemented across Iterations 2 + 6: per-command append to
`history.log` (Iter 2); on project open, the in-memory
@@ -696,7 +704,10 @@ since ADR-0027.)
`Generator`, and full completion / highlight / validity / help /
parse-error-pedagogy wiring. Deferred SD2 increments:
user-defined custom generators, NULL injection, multi-locale,
recursive parent auto-seed.)*
recursive parent auto-seed. Later catalogue refinements:
**#33** year-as-int (`year`/`*_year`/`published`/`founded`) and
**#34** conventional choice sets (`priority`/`severity`/`rating`,
`status` excluded) — ADR-0048 Amendment 1.)*
## Query analysis
+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
+779 -24
View File
File diff suppressed because it is too large Load Diff
+137 -306
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -552,6 +552,11 @@ pub enum AppCommand {
Help {
topic: Option<String>,
},
/// Show a contextual tier-3 hint (H2 / ADR-0053). No argument:
/// when submitted, it expands on the most recent runtime error
/// (the buffer is empty post-submit). The live-input surface is
/// the F1 keybinding, handled in `App::handle_key`, not here.
Hint,
/// Rebuild `playground.db` from `project.yaml` + data/, with
/// confirmation modal.
Rebuild,
@@ -1013,6 +1018,7 @@ impl Command {
Self::App(app) => match app {
AppCommand::Quit => "quit",
AppCommand::Help { .. } => "help",
AppCommand::Hint => "hint",
AppCommand::Rebuild => "rebuild",
AppCommand::Save => "save",
AppCommand::SaveAs => "save as",
+25
View File
@@ -177,6 +177,9 @@ const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result<Command, Va
const fn build_undo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Undo))
}
const fn build_hint(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Hint))
}
const fn build_redo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Redo))
@@ -263,6 +266,7 @@ pub static QUIT: CommandNode = CommandNode {
shape: EMPTY_SEQ,
ast_builder: build_quit,
help_id: Some("app.quit"),
hint_ids: &[],
usage_ids: &["parse.usage.quit"],};
pub static HELP: CommandNode = CommandNode {
@@ -270,13 +274,24 @@ pub static HELP: CommandNode = CommandNode {
shape: HELP_TOPIC_OPT,
ast_builder: build_help,
help_id: Some("app.help"),
hint_ids: &[],
usage_ids: &["parse.usage.help"],};
pub static HINT: CommandNode = CommandNode {
entry: Word::keyword("hint"),
shape: EMPTY_SEQ,
ast_builder: build_hint,
help_id: Some("app.hint"),
// hint_id assigned in Phase C with the tier-3 corpus (ADR-0053).
hint_ids: &[],
usage_ids: &["parse.usage.hint"],};
pub static REBUILD: CommandNode = CommandNode {
entry: Word::keyword("rebuild"),
shape: EMPTY_SEQ,
ast_builder: build_rebuild,
help_id: Some("app.rebuild"),
hint_ids: &[],
usage_ids: &["parse.usage.rebuild"],};
pub static SAVE: CommandNode = CommandNode {
@@ -284,6 +299,7 @@ pub static SAVE: CommandNode = CommandNode {
shape: SAVE_AS_OPT,
ast_builder: build_save,
help_id: Some("app.save"),
hint_ids: &[],
usage_ids: &["parse.usage.save"],};
pub static NEW: CommandNode = CommandNode {
@@ -291,6 +307,7 @@ pub static NEW: CommandNode = CommandNode {
shape: EMPTY_SEQ,
ast_builder: build_new,
help_id: Some("app.new"),
hint_ids: &[],
usage_ids: &["parse.usage.new"],};
pub static LOAD: CommandNode = CommandNode {
@@ -298,6 +315,7 @@ pub static LOAD: CommandNode = CommandNode {
shape: EMPTY_SEQ,
ast_builder: build_load,
help_id: Some("app.load"),
hint_ids: &[],
usage_ids: &["parse.usage.load"],};
pub static EXPORT: CommandNode = CommandNode {
@@ -305,6 +323,7 @@ pub static EXPORT: CommandNode = CommandNode {
shape: EXPORT_PATH_OPT,
ast_builder: build_export,
help_id: Some("app.export"),
hint_ids: &[],
usage_ids: &["parse.usage.export"],};
pub static IMPORT: CommandNode = CommandNode {
@@ -312,6 +331,7 @@ pub static IMPORT: CommandNode = CommandNode {
shape: IMPORT_BODY_OPT,
ast_builder: build_import,
help_id: Some("app.import"),
hint_ids: &[],
usage_ids: &["parse.usage.import"],};
pub static MODE: CommandNode = CommandNode {
@@ -319,6 +339,7 @@ pub static MODE: CommandNode = CommandNode {
shape: MODE_VALUE,
ast_builder: build_mode,
help_id: Some("app.mode"),
hint_ids: &[],
usage_ids: &["parse.usage.mode"],};
pub static MESSAGES: CommandNode = CommandNode {
@@ -326,6 +347,7 @@ pub static MESSAGES: CommandNode = CommandNode {
shape: MESSAGES_VALUE_OPT,
ast_builder: build_messages,
help_id: Some("app.messages"),
hint_ids: &[],
usage_ids: &["parse.usage.messages"],};
pub static UNDO: CommandNode = CommandNode {
@@ -333,6 +355,7 @@ pub static UNDO: CommandNode = CommandNode {
shape: EMPTY_SEQ,
ast_builder: build_undo,
help_id: Some("app.undo"),
hint_ids: &[],
usage_ids: &["parse.usage.undo"],};
pub static REDO: CommandNode = CommandNode {
@@ -340,6 +363,7 @@ pub static REDO: CommandNode = CommandNode {
shape: EMPTY_SEQ,
ast_builder: build_redo,
help_id: Some("app.redo"),
hint_ids: &[],
usage_ids: &["parse.usage.redo"],};
pub static COPY: CommandNode = CommandNode {
@@ -347,4 +371,5 @@ pub static COPY: CommandNode = CommandNode {
shape: COPY_VALUE_OPT,
ast_builder: build_copy,
help_id: Some("app.copy"),
hint_ids: &[],
usage_ids: &["parse.usage.copy"],};
+26 -1
View File
@@ -438,6 +438,17 @@ const LIMIT_CLAUSE: Node = Node::Seq(LIMIT_CLAUSE_NODES);
const SEED_COUNT: Node = Node::NumberLit {
validator: Some(LIMIT_VALIDATOR),
};
/// Issue #26: the row count is a bare positional number, so it produces
/// no Tab candidate and was invisible in the hint panel at
/// `seed <table> ▮` (only `set` / `--seed` showed). Wrapping it in
/// `IntroProse` advertises it (and the other options) in prose; the
/// skipped-optional carry (`surviving_intro_hint`) makes the hint reach
/// the resolver despite the trailing optionals. Tab still cycles the
/// keyword candidates.
const SEED_COUNT_HINTED: Node = Node::Hinted {
mode: crate::dsl::grammar::HintMode::IntroProse("hint.seed_count"),
inner: &SEED_COUNT,
};
/// `--seed <n>` — a reproducible-generation flag carrying a numeric
/// seed (ADR-0048 D4). The only flag in the DSL that takes a value;
/// `build_seed` reads the number immediately after the flag.
@@ -567,7 +578,7 @@ const SEED_NODES: &[Node] = &[
// against this table.
TABLE_NAME_WRITES,
SEED_DOT_COLUMN,
Node::Optional(&SEED_COUNT),
Node::Optional(&SEED_COUNT_HINTED),
Node::Optional(&SEED_SET_CLAUSE),
Node::Optional(&SEED_FLAG),
];
@@ -1779,6 +1790,7 @@ pub static SHOW: CommandNode = CommandNode {
shape: SHOW_SHAPE,
ast_builder: build_show,
help_id: Some("data.show"),
hint_ids: &[],
usage_ids: &[
"parse.usage.show_data",
"parse.usage.show_table",
@@ -1794,6 +1806,7 @@ pub static SEED: CommandNode = CommandNode {
shape: SEED_SHAPE,
ast_builder: build_seed,
help_id: Some("data.seed"),
hint_ids: &[],
usage_ids: &["parse.usage.seed"],
};
@@ -1802,6 +1815,8 @@ pub static INSERT: CommandNode = CommandNode {
shape: INSERT_SHAPE,
ast_builder: build_insert,
help_id: Some("data.insert"),
// ADR-0053 Phase-B exemplar.
hint_ids: &["insert"],
usage_ids: &["parse.usage.insert"],};
pub static UPDATE: CommandNode = CommandNode {
@@ -1809,6 +1824,7 @@ pub static UPDATE: CommandNode = CommandNode {
shape: UPDATE_SHAPE,
ast_builder: build_update,
help_id: Some("data.update"),
hint_ids: &[],
usage_ids: &["parse.usage.update"],};
pub static DELETE: CommandNode = CommandNode {
@@ -1816,6 +1832,7 @@ pub static DELETE: CommandNode = CommandNode {
shape: DELETE_SHAPE,
ast_builder: build_delete,
help_id: Some("data.delete"),
hint_ids: &[],
usage_ids: &["parse.usage.delete"],};
pub static REPLAY: CommandNode = CommandNode {
@@ -1823,6 +1840,7 @@ pub static REPLAY: CommandNode = CommandNode {
shape: REPLAY_PATH,
ast_builder: build_replay,
help_id: Some("data.replay"),
hint_ids: &[],
usage_ids: &["parse.usage.replay"],};
pub static EXPLAIN: CommandNode = CommandNode {
@@ -1830,6 +1848,7 @@ pub static EXPLAIN: CommandNode = CommandNode {
shape: EXPLAIN_SHAPE,
ast_builder: build_explain,
help_id: Some("data.explain"),
hint_ids: &[],
usage_ids: &["parse.usage.explain"],};
/// `explain` over advanced-mode SQL (ADR-0039).
@@ -1849,6 +1868,7 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode {
// too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE`
// precedent; otherwise `note_help` would print `explain` twice.
help_id: None,
hint_ids: &[],
usage_ids: &[],};
/// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032).
@@ -1864,6 +1884,7 @@ pub static SELECT: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL),
ast_builder: build_select,
help_id: None,
hint_ids: &[],
usage_ids: &["parse.usage.select"],};
/// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c).
@@ -1878,6 +1899,7 @@ pub static WITH: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL),
ast_builder: build_select,
help_id: None,
hint_ids: &[],
usage_ids: &["parse.usage.with"],};
/// SQL `INSERT` — the `Advanced`-category node of the shared
@@ -1895,6 +1917,7 @@ pub static SQL_INSERT: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE),
ast_builder: build_sql_insert,
help_id: None,
hint_ids: &[],
usage_ids: &[],
};
@@ -1908,6 +1931,7 @@ pub static SQL_UPDATE: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE),
ast_builder: build_sql_update,
help_id: None,
hint_ids: &[],
usage_ids: &[],
};
@@ -1923,6 +1947,7 @@ pub static SQL_DELETE: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE),
ast_builder: build_sql_delete,
help_id: None,
hint_ids: &[],
usage_ids: &[],
};
+20
View File
@@ -968,6 +968,7 @@ pub static DROP: CommandNode = CommandNode {
shape: DROP_SHAPE,
ast_builder: build_drop,
help_id: Some("ddl.drop"),
hint_ids: &[],
usage_ids: &[
"parse.usage.drop_table",
"parse.usage.drop_column",
@@ -981,6 +982,16 @@ pub static ADD: CommandNode = CommandNode {
shape: ADD_SHAPE,
ast_builder: build_add,
help_id: Some("ddl.add"),
// Per-form (ADR-0053 D3): every form is listed so the form-word
// disambiguation resolves correctly; forms without an authored
// block yet fall back to tier-2 at render. `add_relationship` is
// authored as a Phase-B exemplar.
hint_ids: &[
"add_column",
"add_relationship",
"add_index",
"add_constraint",
],
usage_ids: &[
"parse.usage.add_column",
"parse.usage.add_relationship",
@@ -993,6 +1004,7 @@ pub static RENAME: CommandNode = CommandNode {
shape: RENAME_COLUMN,
ast_builder: build_rename_column,
help_id: Some("ddl.rename"),
hint_ids: &[],
usage_ids: &["parse.usage.rename_column"],};
pub static CHANGE: CommandNode = CommandNode {
@@ -1000,6 +1012,7 @@ pub static CHANGE: CommandNode = CommandNode {
shape: CHANGE_COLUMN,
ast_builder: build_change_column,
help_id: Some("ddl.change"),
hint_ids: &[],
usage_ids: &["parse.usage.change_column"],};
// =================================================================
@@ -1360,6 +1373,7 @@ pub static CREATE: CommandNode = CommandNode {
shape: CREATE_TABLE,
ast_builder: build_create_table,
help_id: Some("ddl.create"),
hint_ids: &[],
usage_ids: &["parse.usage.create_table"],};
// =================================================================
@@ -1428,6 +1442,7 @@ pub static CREATE_M2N: CommandNode = CommandNode {
shape: CREATE_M2N_SHAPE,
ast_builder: build_create_m2n,
help_id: Some("ddl.create_m2n"),
hint_ids: &[],
usage_ids: &["parse.usage.create_m2n"],
};
@@ -1858,6 +1873,7 @@ pub static SQL_CREATE_TABLE: CommandNode = CommandNode {
shape: Node::Subgrammar(&super::sql_create_table::SQL_CREATE_TABLE_SHAPE),
ast_builder: build_sql_create_table,
help_id: Some("ddl.sql_create_table"),
hint_ids: &[],
usage_ids: &["parse.usage.sql_create_table"],
};
@@ -1877,6 +1893,7 @@ pub static SQL_DROP_TABLE: CommandNode = CommandNode {
shape: SQL_DROP_TABLE_SHAPE,
ast_builder: build_sql_drop_table,
help_id: Some("ddl.sql_drop_table"),
hint_ids: &[],
usage_ids: &["parse.usage.sql_drop_table"],
};
@@ -1896,6 +1913,7 @@ pub static SQL_DROP_INDEX: CommandNode = CommandNode {
shape: SQL_DROP_INDEX_SHAPE,
ast_builder: build_sql_drop_index,
help_id: Some("ddl.sql_drop_index"),
hint_ids: &[],
usage_ids: &["parse.usage.sql_drop_index"],
};
@@ -1977,6 +1995,7 @@ pub static SQL_CREATE_INDEX: CommandNode = CommandNode {
shape: SQL_CREATE_INDEX_SHAPE,
ast_builder: build_sql_create_index,
help_id: Some("ddl.sql_create_index"),
hint_ids: &[],
usage_ids: &["parse.usage.sql_create_index"],
};
@@ -2535,6 +2554,7 @@ pub static SQL_ALTER_TABLE: CommandNode = CommandNode {
shape: SQL_ALTER_TABLE_SHAPE,
ast_builder: build_sql_alter_table,
help_id: Some("ddl.sql_alter_table"),
hint_ids: &[],
usage_ids: &["parse.usage.sql_alter_table"],
};
+127 -39
View File
@@ -530,6 +530,18 @@ pub struct CommandNode {
/// so a newly-registered command appears in `help`
/// automatically (ADR-0024 §help_id).
pub help_id: Option<&'static str>,
/// Catalog key stems (`hint.cmd.<id>`) for this command's
/// **tier-3** contextual hints (ADR-0053 / H2), **one per form**,
/// mirroring `usage_ids`. A single-form command carries one; a
/// multi-form command (`add`, `drop`, `show`, `create`) carries
/// one per form so a live-input hint can be specific to the form
/// being typed (`hint.cmd.add_relationship`, not a shared `add`
/// block). `hint_key_for_input_in_mode` disambiguates by the form
/// word, reusing `usage_key_for_input_in_mode`'s logic. Empty
/// until a form's tier-3 block is authored (the surface falls back
/// to tier-2 ambient/error text). Distinct from `help_id` (which is
/// `None` on advanced-SQL forms purely to dedup the `help` list).
pub hint_ids: &'static [&'static str],
/// Catalog keys under `parse.usage.*` to render in the
/// "usage:" block when a parse error fires for this command
/// (ADR-0021 §1, ADR-0024 §architecture). Multi-form families
@@ -574,32 +586,79 @@ pub fn usage_keys_for_input_in_mode(
source: &str,
mode: crate::mode::Mode,
) -> Option<(&'static str, Vec<&'static str>)> {
let pick = selected_nodes_for_input_in_mode(source, mode);
if pick.is_empty() {
return None;
}
let mut keys: Vec<&'static str> = Vec::new();
for (_, node, _) in &pick {
for k in node.usage_ids {
if !keys.contains(k) {
keys.push(*k);
}
}
}
if keys.is_empty() {
return None;
}
let entry = pick[0].1.entry.primary;
Some((entry, keys))
}
/// The single tier-3 hint key (`hint.cmd.<id>` stem) for the command
/// **form** `source` is currently typing, in `mode` (H2 / ADR-0053).
///
/// Mirrors [`usage_key_for_input_in_mode`]: the union of the
/// mode-selected nodes' `hint_ids`, disambiguated to the typed form by
/// [`pick_form_key`] — so `add 1:n relationship` resolves to the
/// relationship hint, and an advanced-SQL form resolves to its own
/// (not its simple sibling's). `None` if no entry word matches or the
/// form has no tier-3 block yet (the caller falls back to tier-2).
#[must_use]
pub fn hint_key_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> {
let nodes = selected_nodes_for_input_in_mode(source, mode);
if nodes.is_empty() {
return None;
}
let mut keys: Vec<&'static str> = Vec::new();
for (_, node, _) in &nodes {
for k in node.hint_ids {
if !keys.contains(k) {
keys.push(*k);
}
}
}
pick_form_key(source, &keys)
}
/// Shared mode-aware command-form selection for the entry word at the
/// start of `source`.
///
/// Extracted so the usage-key and hint-id lookups agree on which form
/// the user is typing.
///
/// Advanced mode: every candidate form is reachable — the SQL nodes
/// are primary, and the DSL nodes remain valid via fallback (verified:
/// `create table … with pk` and `drop column …` both run in advanced
/// mode). Mode-primary (Advanced) first, so a hint never hides input
/// that works. Simple mode: only the DSL forms — the SQL-only forms
/// hit the "this is SQL" rail and are not reachable. (ADR-0042 G3.)
/// Degenerate guard: an advanced-only word in simple mode leaves the
/// selection empty; fall back to all candidates.
fn selected_nodes_for_input_in_mode(
source: &str,
mode: crate::mode::Mode,
) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
let start = skip_whitespace(source, 0);
let (kw_start, kw_end) = consume_ident(source, start)?;
let Some((kw_start, kw_end)) = consume_ident(source, start) else {
return Vec::new();
};
let word = &source[kw_start..kw_end];
let candidates = commands_for_entry_word(word);
if candidates.is_empty() {
return None;
return Vec::new();
}
let union = |nodes: &[(usize, &'static CommandNode, CommandCategory)]| -> Vec<&'static str> {
let mut keys: Vec<&'static str> = Vec::new();
for (_, node, _) in nodes {
for k in node.usage_ids {
if !keys.contains(k) {
keys.push(*k);
}
}
}
keys
};
// Advanced mode: every candidate form is reachable — the SQL
// nodes are primary, and the DSL nodes remain valid via fallback
// (verified: `create table … with pk` and `drop column …` both
// run in advanced mode). Show them all, mode-primary (Advanced)
// first, so the usage hint never hides input that works. Simple
// mode: only the DSL forms — the SQL-only forms hit the "this is
// SQL" rail and are not reachable. (ADR-0042 G3.)
let selected: Vec<(usize, &'static CommandNode, CommandCategory)> =
if mode == crate::mode::Mode::Advanced {
let mut v: Vec<_> = candidates
@@ -621,17 +680,7 @@ pub fn usage_keys_for_input_in_mode(
.filter(|(_, _, c)| *c == CommandCategory::Simple)
.collect()
};
// Degenerate guard: an advanced-only word in simple mode (not
// normally reachable — it hits the SQL rail first) leaves
// `selected` empty; fall back to all candidates so a usage block
// still renders rather than the available-commands fallback.
let pick = if selected.is_empty() { candidates } else { selected };
let keys = union(&pick);
if keys.is_empty() {
return None;
}
let entry = pick[0].1.entry.primary;
Some((entry, keys))
if selected.is_empty() { candidates } else { selected }
}
/// The single usage template most relevant to `source`, when
@@ -658,14 +707,24 @@ pub fn usage_key_for_input_in_mode(
source: &str,
mode: crate::mode::Mode,
) -> Option<&'static str> {
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?;
pick_form_key(source, &keys)
}
/// From the form word after the entry keyword, pick the single `keys`
/// entry for the form `source` names.
///
/// A single-entry list resolves to its one key; a multi-form list
/// disambiguates by the form word (`add 1:n relationship` → the
/// `…relationship` key, `create m:n …` → the `…m2n` key, else the
/// identifier form word matched against each key's suffix). Shared by
/// the usage-template and tier-3-hint single-key lookups so they agree.
fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> {
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
let first = *keys.first()?;
if keys.len() == 1 {
return Some(first);
}
// Multi-form: the form is named by the token right after
// the entry keyword.
let start = skip_whitespace(source, 0);
let (_, entry_end) = consume_ident(source, start)?;
let after = skip_whitespace(source, entry_end);
@@ -674,14 +733,12 @@ pub fn usage_key_for_input_in_mode(
return keys.iter().copied().find(|k| k.ends_with("relationship"));
}
// The `create m:n relationship` form (ADR-0045) opens with `m:n`
// — a letter, so the digit branch misses it, and its usage key ends
// `…create_m2n` (not `relationship`).
// — a letter, so the digit branch misses it; its key ends `…m2n`.
if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) {
return keys.iter().copied().find(|k| k.ends_with("m2n"));
}
// Otherwise the form word is an identifier — `column`,
// `index`, `table`, `relationship` — matched against the
// usage key's suffix.
// Otherwise the form word is an identifier — `column`, `index`,
// `table`, `relationship` — matched against each key's suffix.
let (s, e) = consume_ident(source, after)?;
let form = source[s..e].to_ascii_lowercase();
keys.iter().copied().find(|k| k.ends_with(form.as_str()))
@@ -712,6 +769,7 @@ pub fn entry_words_alphabetised() -> Vec<&'static str> {
pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
(&app::QUIT, CommandCategory::Simple),
(&app::HELP, CommandCategory::Simple),
(&app::HINT, CommandCategory::Simple),
(&app::REBUILD, CommandCategory::Simple),
(&app::SAVE, CommandCategory::Simple),
(&app::NEW, CommandCategory::Simple),
@@ -836,6 +894,36 @@ pub fn commands_for_entry_word(
.collect()
}
#[cfg(test)]
mod hint_key_tests {
use super::hint_key_for_input_in_mode;
use crate::mode::Mode;
/// Per-form hint keying (ADR-0053 D3): a multi-form command
/// resolves the *typed* form, not the node — `add 1:n
/// relationship` → the relationship hint, `add column` → the
/// (as-yet-unauthored) column hint, never the wrong form.
#[test]
fn hint_key_resolves_the_typed_form() {
assert_eq!(
hint_key_for_input_in_mode("add 1:n relationship from A.x to B.y", Mode::Simple),
Some("add_relationship")
);
assert_eq!(
hint_key_for_input_in_mode("add column Note text to T", Mode::Simple),
Some("add_column")
);
assert_eq!(
hint_key_for_input_in_mode("insert into T values (1)", Mode::Simple),
Some("insert")
);
// A node with no hint_ids yet → None (tier-2 fallback).
assert_eq!(hint_key_for_input_in_mode("drop table T", Mode::Simple), None);
// Unknown entry word → None.
assert_eq!(hint_key_for_input_in_mode("zzz", Mode::Simple), None);
}
}
#[cfg(test)]
mod usage_key_tests {
use super::usage_key_for_input;
+13
View File
@@ -134,6 +134,17 @@ pub struct WalkContext<'a> {
/// resolver reads this directly instead of inferring the
/// slot kind from the shape of the expected set.
pub pending_hint_mode: Option<crate::dsl::grammar::HintMode>,
/// An `IntroProse` hint captured from an *optional* slot that
/// the walk skipped (issue #26). Unlike `pending_hint_mode`
/// (cleared on the very next match — including the empty match
/// of a skipped `Optional`), this survives the trailing
/// optional siblings so the hint reaches the resolver for a
/// position like `seed <table> ▮`, where the optional row
/// count is otherwise invisible. Carries the catalog key and
/// the byte position the optional was skipped at; the resolver
/// uses it only when that position is the cursor (so it doesn't
/// leak past a later-consumed clause).
pub surviving_intro_hint: Option<(&'static str, usize)>,
/// The columns the user explicitly listed in
/// `insert into <T> (col1, col2, …) values (…)` (Form A),
/// in declaration order.
@@ -232,6 +243,7 @@ impl<'a> WalkContext<'a> {
pending_value_type: None,
pending_value_column: None,
pending_hint_mode: None,
surviving_intro_hint: None,
user_listed_columns: None,
subgrammar_depth: 0,
from_scope_stack: vec![ScopeFrame::default()],
@@ -254,6 +266,7 @@ impl<'a> WalkContext<'a> {
pending_value_type: None,
pending_value_column: None,
pending_hint_mode: None,
surviving_intro_hint: None,
user_listed_columns: None,
subgrammar_depth: 0,
from_scope_stack: vec![ScopeFrame::default()],
+17
View File
@@ -990,6 +990,21 @@ fn walk_seq(
}
}
/// Issue #26: when an `Optional` is skipped (its inner didn't engage),
/// stash any `IntroProse` hint the inner left in `pending_hint_mode`
/// into the surviving slot before it is cleared by this empty match.
/// `position` is where the optional was skipped — the resolver compares
/// it to the cursor so the hint only shows while the cursor sits at that
/// optional, not after a later clause consumes input past it. Only
/// `IntroProse` is carried (it is the "introduce an optional position"
/// mode); `ProseOnly` / `ForceProse` mark active slots and reach the
/// resolver through the normal `pending_hint_mode` path.
const fn capture_skipped_intro_hint(ctx: &mut WalkContext, position: usize) {
if let Some(crate::dsl::grammar::HintMode::IntroProse(key)) = ctx.pending_hint_mode {
ctx.surviving_intro_hint = Some((key, position));
}
}
fn walk_optional(
source: &str,
position: usize,
@@ -1008,6 +1023,7 @@ fn walk_optional(
// Inner didn't engage at all — skip the Optional
// but carry the inner's expectations so the caller's
// expected-set sees them.
capture_skipped_intro_hint(ctx, position);
path.items.truncate(saved_path_len);
per_byte.truncate(saved_byte_len);
NodeWalkResult::Matched {
@@ -1019,6 +1035,7 @@ fn walk_optional(
// Inner reported Incomplete without consuming
// anything — same as NoMatch from the user's
// perspective. Roll back and skip.
capture_skipped_intro_hint(ctx, position);
path.items.truncate(saved_path_len);
per_byte.truncate(saved_byte_len);
let _ = p;
+29
View File
@@ -116,6 +116,19 @@ pub fn hint_resolution_at_input_in_mode(
use crate::dsl::grammar::HintMode;
let snap = expected_for_hint_snapshot(source, schema, mode);
// Issue #26: an optional positional slot with no candidate text
// (the `seed <table>` row count) left an `IntroProse` hint that
// survived the trailing optionals. It is shown even for an
// otherwise-complete command (empty expected set) — that is exactly
// the `seed users ▮` case where the count is invisible. Checked
// first, before the complete-command short-circuit below.
if let Some(key) = snap.surviving_intro_hint {
return Some(HintResolution {
mode: HintMode::IntroProse(key),
column: None,
form_b_autogen_skipped: Vec::new(),
});
}
// Empty expected set means the command is already complete
// (`WalkOutcome::Match`) — no slot to hint at.
if snap.expected.is_empty() {
@@ -2599,6 +2612,11 @@ struct HintWalkSnapshot {
/// The grammar-declared `HintMode` at the cursor's slot
/// (`Node::Hinted` annotation, ADR-0024 §HintMode-per-node).
pending_hint_mode: Option<crate::dsl::grammar::HintMode>,
/// An `IntroProse` catalog key for an *optional* positional slot at
/// the cursor that produced no candidate (issue #26 — `seed <table>`
/// row count). Survives the trailing optional siblings that clear
/// `pending_hint_mode`; already filtered to the cursor position.
surviving_intro_hint: Option<&'static str>,
current_table_columns: Option<Vec<crate::completion::TableColumn>>,
/// `Some` when the input used Form A's explicit column list.
/// `None` for Form B (`insert into T values …`) and for
@@ -2625,6 +2643,7 @@ fn expected_for_hint_snapshot(
pending_value_type: None,
pending_value_column: None,
pending_hint_mode: None,
surviving_intro_hint: None,
current_table_columns: None,
user_listed_columns: None,
};
@@ -2652,6 +2671,14 @@ fn expected_for_hint_snapshot(
pending_value_type: ctx.pending_value_type,
pending_value_column: ctx.pending_value_column,
pending_hint_mode: ctx.pending_hint_mode,
// Issue #26: only surface the skipped-optional hint when the
// optional was skipped *at the cursor* (the end of the walked
// slice). Captured earlier (before a later clause consumed past
// it) → stale, so drop it.
surviving_intro_hint: ctx
.surviving_intro_hint
.filter(|(_, pos)| *pos == source.len())
.map(|(key, _)| key),
current_table_columns: ctx.current_table_columns,
user_listed_columns: ctx.user_listed_columns,
}
@@ -6883,6 +6910,7 @@ mod dispatch_3a_tests {
shape: Node::Word(Word::keyword("dsltail")),
ast_builder: dsl_builder,
help_id: None,
hint_ids: &[],
usage_ids: &[],
};
static SMOKE_SQL: CommandNode = CommandNode {
@@ -6890,6 +6918,7 @@ mod dispatch_3a_tests {
shape: Node::Word(Word::keyword("sqltail")),
ast_builder: sql_builder,
help_id: None,
hint_ids: &[],
usage_ids: &[],
};
+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>),
+27 -5
View File
@@ -180,6 +180,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.unknown_topic", &["topic"]),
("help.app.quit", &[]),
("help.app.help", &[]),
("help.app.hint", &[]),
("help.app.rebuild", &[]),
("help.app.save", &[]),
("help.app.new", &[]),
@@ -222,6 +223,17 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
&["message", "usage"],
),
("hint.ambient_expected", &["expected"]),
("hint.getting_started", &[]),
// Tier-3 teaching blocks (ADR-0053 D3) — Phase-B exemplars.
("hint.cmd.insert.what", &[]),
("hint.cmd.insert.example", &[]),
("hint.cmd.insert.concept", &[]),
("hint.cmd.add_relationship.what", &[]),
("hint.cmd.add_relationship.example", &[]),
("hint.cmd.add_relationship.concept", &[]),
("hint.err.foreign_key.child_side.what", &[]),
("hint.err.foreign_key.child_side.example", &[]),
("hint.err.foreign_key.child_side.concept", &[]),
(
"hint.ambient_invalid_ident",
&["kind", "found"],
@@ -231,6 +243,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
// slot (`create table T (`) so the otherwise-invisible
// column-name role reads as the dominant first move.
("hint.create_table_element", &[]),
("hint.seed_count", &[]),
("hint.value_literal_slot", &[]),
(
"hint.ambient_typing_name_then",
@@ -298,6 +311,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.usage.rename_column", &[]),
("parse.usage.export", &[]),
("parse.usage.help", &[]),
("parse.usage.hint", &[]),
("parse.usage.import", &[]),
("parse.usage.copy", &[]),
("parse.usage.load", &[]),
@@ -445,6 +459,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", &[]),
@@ -461,18 +476,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", &[]),
+1 -1
View File
@@ -35,7 +35,7 @@ pub mod translate;
pub use error::{DiagnosticTable, FriendlyError};
pub use format::{catalog, Catalog};
pub use translate::{FailureContext, Operation, TranslateContext, Verbosity};
pub use translate::{error_hint_class, FailureContext, Operation, TranslateContext, Verbosity};
// `translate::translate` and `format::translate` are different
// callables — the former is the structured DbError → FriendlyError
+54 -5
View File
@@ -256,6 +256,8 @@ help:
help: |-
help — show this command list
help <command> — detailed help for one command (e.g. `help insert`)
hint: |-
hint — explain the most recent error (press F1 for a hint on what you're typing)
rebuild: |-
rebuild — rebuild the project database from project.yaml + data/ (with confirmation)
save: |-
@@ -386,6 +388,30 @@ hint:
ambient_complete: "Submit with Enter"
ambient_expected: "Next: {expected}"
ambient_error_with_usage: "{message} — usage: {usage}"
# H2 / ADR-0053: shown by `hint` / F1 when there is nothing specific
# to expand on (no recent error, empty input).
getting_started: "Start typing a command and press F1 for a hint, or type `help` for the full command list."
# ── Tier-3 teaching blocks (ADR-0053 D3) ──────────────────────────
# Per-form command hints (`hint.cmd.<form>`) and per-class error
# hints (`hint.err.<class>`), each a `what` (12 sentences) / `example`
# (one runnable, mode-correct line) / `concept` (the relational idea —
# the teaching part). Phase B seeds the three approved exemplars; the
# rest are authored in Phase C.
cmd:
insert:
what: "Add one or more rows to a table."
example: "insert into Customers values ('Ann', 'ann@example.io')"
concept: "A row is one record; each value lines up with a column, in order. Columns typed `serial`/`shortid` fill themselves — leave them out."
add_relationship:
what: "Link two tables so a parent row can own many child rows."
example: "add 1:n relationship from Customers.id to Orders.customer_id"
concept: "The \"1:n\" means one parent, many children. The child column holds the foreign key; add `--create-fk` to create that column if it doesn't exist yet."
err:
foreign_key:
child_side:
what: "The value you gave for the child column doesn't match any parent row, so the foreign key has nothing to point at."
example: "First insert the parent (insert into Customers …), then the child that references it."
concept: "A foreign key is a promise that every child points at a real parent, so the parent must exist first. To allow orphans on delete instead, set the relationship's `on delete` to `set null` or `cascade`."
# Invalid identifier in a schema slot (ADR-0022 stage 8e
# + the user's #5). Voice mirrors ADR-0019's "no such
# {kind}" wording for consistency with engine errors.
@@ -400,6 +426,12 @@ hint:
# at `create table T (` so the column-name role is visible
# alongside the table-level constraint keywords.
create_table_element: "Type a column name, or a table-level constraint: `primary`, `unique`, `check`, `constraint`, `foreign`"
# Issue #26: the `seed <table> ▮` position. The optional row count is
# a bare number with no Tab candidate, so it (and the `.column`
# column-fill form) would be invisible next to the `set` / `--seed`
# chips. Names every option so the most common next move (a count) is
# discoverable.
seed_count: "Optionally a row count, e.g. `50` (default 20); `.column` to fill one column on existing rows; `set` to pin a column; `--seed` to fix the RNG"
# Value-literal slot — `insert ... values (`, `update ... set
# col=`, `where col=`. Replaces the misleading "null true
# false" keyword candidate list with format guidance for all
@@ -611,6 +643,7 @@ parse:
# description.
quit: "quit"
help: "help [<command>]"
hint: "hint"
rebuild: "rebuild"
save: "save | save as"
new: "new"
@@ -877,14 +910,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"
@@ -893,10 +933,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:
+153
View File
@@ -253,6 +253,73 @@ pub fn translate(error: &DbError, ctx: &TranslateContext) -> FriendlyError {
fe
}
/// The tier-3 hint class (`hint.err.<class>`) for an error.
///
/// The same classification [`translate`] performs, surfaced as a
/// stable key for the contextual `hint` (H2 / ADR-0053 D5). Returns
/// `None` for internal / fatal errors that carry no learner-facing
/// hint (persistence, IO, worker-gone).
///
/// **Keep in sync with [`translate`] / `translate_sqlite` /
/// `translate_constraint` / `translate_foreign_key`** — the unit tests
/// below pin each class.
#[must_use]
pub fn error_hint_class(error: &DbError, ctx: &TranslateContext) -> Option<&'static str> {
match error {
DbError::Sqlite { message, kind } => sqlite_hint_class(message, *kind, ctx),
DbError::Unsupported(_) | DbError::InvalidValue(_) => Some("invalid_value"),
DbError::PersistenceFatal { .. }
| DbError::RebuildRowFailed { .. }
| DbError::Io(_)
| DbError::WorkerGone => None,
}
}
fn sqlite_hint_class(
message: &str,
kind: SqliteErrorKind,
ctx: &TranslateContext,
) -> Option<&'static str> {
if matches!(ctx.operation, Some(Operation::ChangeColumnType)) {
return Some("type_mismatch");
}
Some(match kind {
SqliteErrorKind::NoSuchTable | SqliteErrorKind::NoSuchColumn => "not_found",
SqliteErrorKind::AlreadyExists => "already_exists",
SqliteErrorKind::UniqueViolation => constraint_hint_class(message, ctx),
SqliteErrorKind::Other => "generic",
})
}
fn constraint_hint_class(message: &str, ctx: &TranslateContext) -> &'static str {
let lower = message.to_ascii_lowercase();
if lower.contains("unique constraint failed") {
"unique"
} else if lower.contains("foreign key constraint failed") {
fk_hint_class(ctx)
} else if lower.contains("not null constraint failed") {
"not_null"
} else if lower.contains("check constraint failed") {
"check"
} else {
"generic"
}
}
const fn fk_hint_class(ctx: &TranslateContext) -> &'static str {
// Mirrors `translate_foreign_key`'s side disambiguation.
if ctx.parent_table.is_some() {
return "foreign_key.child_side";
}
if ctx.child_table.is_some() {
return "foreign_key.parent_side";
}
match ctx.operation {
Some(Operation::Delete) => "foreign_key.parent_side",
_ => "foreign_key.child_side",
}
}
fn translate_sqlite(
message: &str,
kind: SqliteErrorKind,
@@ -798,6 +865,92 @@ mod tests {
}
}
// ── H2 / ADR-0053: error → tier-3 hint class ────────────────
#[test]
fn hint_class_maps_runtime_error_kinds() {
use crate::db::{DbError, SqliteErrorKind};
let sqlite = |kind, msg: &str| DbError::Sqlite {
message: msg.to_string(),
kind,
};
let d = TranslateContext::default;
assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::NoSuchTable, "no such table: X"), &d()),
Some("not_found")
);
assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::NoSuchColumn, "no such column: X"), &d()),
Some("not_found")
);
assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::AlreadyExists, "already exists"), &d()),
Some("already_exists")
);
assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::Other, "boom"), &d()),
Some("generic")
);
// Constraint-violation message splitting.
let cv = |msg: &str| sqlite(SqliteErrorKind::UniqueViolation, msg);
assert_eq!(
error_hint_class(&cv("UNIQUE constraint failed: T.c"), &d()),
Some("unique")
);
assert_eq!(
error_hint_class(&cv("NOT NULL constraint failed: T.c"), &d()),
Some("not_null")
);
assert_eq!(
error_hint_class(&cv("CHECK constraint failed: T"), &d()),
Some("check")
);
// change-column op routes any engine error to type_mismatch.
assert_eq!(
error_hint_class(
&sqlite(SqliteErrorKind::Other, "x"),
&ctx_with(Operation::ChangeColumnType)
),
Some("type_mismatch")
);
// App-level refusals and internal/fatal errors.
assert_eq!(
error_hint_class(&DbError::InvalidValue("bad".to_string()), &d()),
Some("invalid_value")
);
assert_eq!(error_hint_class(&DbError::WorkerGone, &d()), None);
}
#[test]
fn hint_class_resolves_foreign_key_sides() {
use crate::db::{DbError, SqliteErrorKind};
let fk = || DbError::Sqlite {
message: "FOREIGN KEY constraint failed".to_string(),
kind: SqliteErrorKind::UniqueViolation,
};
// Enrichment: parent_table populated → child-side.
let ctx = TranslateContext {
parent_table: Some("Parent".to_string()),
..TranslateContext::default()
};
assert_eq!(error_hint_class(&fk(), &ctx), Some("foreign_key.child_side"));
// child_table populated → parent-side.
let ctx = TranslateContext {
child_table: Some("Child".to_string()),
..TranslateContext::default()
};
assert_eq!(error_hint_class(&fk(), &ctx), Some("foreign_key.parent_side"));
// No enrichment: operation is the tiebreaker.
assert_eq!(
error_hint_class(&fk(), &ctx_with(Operation::Delete)),
Some("foreign_key.parent_side")
);
assert_eq!(
error_hint_class(&fk(), &ctx_with(Operation::Insert)),
Some("foreign_key.child_side")
);
}
fn sqlite(message: &str, kind: SqliteErrorKind) -> DbError {
DbError::Sqlite {
message: message.to_string(),
+87
View File
@@ -1356,6 +1356,93 @@ mod tests {
}
}
fn seed_cache() -> crate::completion::SchemaCache {
use crate::completion::TableColumn;
use crate::dsl::types::Type;
let mut cache = crate::completion::SchemaCache::default();
cache.tables.push("users".to_string());
cache.columns.push("email".to_string());
cache
.table_columns
.insert("users".to_string(), vec![TableColumn::new("email", Type::Text)]);
cache
}
#[test]
fn seed_count_is_advertised_at_the_optional_position() {
// Issue #26: `seed users ▮` is a complete command, so the hint
// ladder shows only the `set` / `--seed` continuation chips —
// the optional row count (a bare number with no candidate) was
// invisible. An IntroProse hint that survives the trailing
// optionals now advertises it; Tab still cycles the keywords.
let cache = seed_cache();
let input = "seed users ";
match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple) {
Some(AmbientHint::Prose(p)) => {
assert!(
p.contains("row count") && p.contains("20"),
"prose must mention the row count and the default; got: {p:?}",
);
assert!(
p.contains("set") && p.contains("--seed") && p.contains(".column"),
"prose should fold in the keyword + column-fill options; got: {p:?}",
);
}
other => panic!("expected a Prose count hint; got: {other:?}"),
}
// Tab candidates remain available (completion is independent).
let comp = crate::completion::candidates_at_cursor_in_mode(
input, input.len(), &cache, Mode::Simple,
)
.expect("completion remains available");
let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect();
assert!(
texts.contains(&"set") && texts.contains(&"--seed"),
"Tab must still cycle `set` / `--seed`; got {texts:?}",
);
// `seed` runs in both modes (ADR-0048), so the hint must fire in
// advanced mode too — not only simple.
match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) {
Some(AmbientHint::Prose(p)) => assert!(
p.contains("row count"),
"count hint must also fire in advanced mode; got: {p:?}",
),
other => panic!("expected the count hint in advanced mode; got: {other:?}"),
}
}
#[test]
fn seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given() {
// Position guard: the hint shows only while the cursor sits at
// the count slot. Once the count is supplied — or a later clause
// consumes input past it — it must not reappear.
let cache = seed_cache();
for input in ["seed users 50 ", "seed users set email = 'x' "] {
let hint = ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple);
let is_count_prose = matches!(
&hint,
Some(AmbientHint::Prose(p)) if p.contains("row count")
);
assert!(!is_count_prose, "count hint must not show for {input:?}; got {hint:?}");
}
}
#[test]
fn seed_count_hint_also_fires_after_a_column_fill_target() {
// The count is valid after `seed users.email` too, so the hint
// fires there — `.email` is a real column (no diagnostic).
let cache = seed_cache();
let input = "seed users.email ";
match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple) {
Some(AmbientHint::Prose(p)) => assert!(
p.contains("row count"),
"count hint expected after a column-fill target; got: {p:?}",
),
other => panic!("expected a Prose count hint; got: {other:?}"),
}
}
#[test]
fn genuine_column_typo_in_complete_select_still_hints_via_diagnostic() {
// Issue #6 trade-off lockdown: dropping the typing-time
+34 -63
View File
@@ -71,27 +71,22 @@ pub fn render_data_table(data: &DataResult) -> Vec<String> {
render_table(&header_cells, &body, &alignments)
}
/// Render a table-structure listing.
/// Render an incidental-DDL structure echo (ADR-0050, issue #28).
///
/// Produces a header line (`<TableName>`), the schema table
/// itself, and — for a structure that has FK relationships
/// — `References:` / `Referenced by:` blocks below as plain
/// indented text (relationship visualization is its own
/// future ADR per §5 OOS-1).
/// Display a relationship-endpoint column list (ADR-0043): the bare
/// column for a single-column FK, `(a, b)` for a compound one.
fn cols_disp(cols: &[String]) -> String {
if cols.len() == 1 {
cols[0].clone()
} else {
format!("({})", cols.join(", "))
}
}
/// Produces a header line (`<TableName>`), the schema table, the
/// `Indexes:` section, and the constraint section — **structure only**.
/// Relationship information is deliberately omitted: a confirmation
/// echo for a structural edit (`create table`, `add`/`drop`/`rename`/
/// `change column`, `add`/`drop index`) reports the change just made,
/// not the table's relationships, which the user did not touch. The
/// relationship-subject surfaces (`show table`, `add`/`drop
/// relationship`) render diagrams via [`render_structure_with_diagrams`]
/// instead; relationships are one `show table <T>` away. ADR-0050
/// supersedes ADR-0044 §1's "incidental DDL keeps prose" and the
/// relationship-block half of ADR-0016 §5.
#[must_use]
pub fn render_structure(desc: &TableDescription) -> Vec<String> {
let mut out = structure_box_lines(desc);
out.extend(relationship_prose_lines(desc));
out.extend(index_lines(desc));
out.extend(constraint_lines(desc));
out
@@ -118,41 +113,6 @@ fn structure_box_lines(desc: &TableDescription) -> Vec<String> {
out
}
/// The `References:` / `Referenced by:` prose blocks (ADR-0016 §5),
/// retained for the incidental DDL echoes (ADR-0044 §1).
fn relationship_prose_lines(desc: &TableDescription) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
if !desc.outbound_relationships.is_empty() {
out.push("References:".to_string());
for r in &desc.outbound_relationships {
out.push(format!(
" {} → {}.{} ({}, on delete {}, on update {})",
cols_disp(&r.local_columns),
r.other_table,
cols_disp(&r.other_columns),
r.name,
r.on_delete,
r.on_update,
));
}
}
if !desc.inbound_relationships.is_empty() {
out.push("Referenced by:".to_string());
for r in &desc.inbound_relationships {
out.push(format!(
" {}.{} → {} ({}, on delete {}, on update {})",
r.other_table,
cols_disp(&r.other_columns),
cols_disp(&r.local_columns),
r.name,
r.on_delete,
r.on_update,
));
}
}
out
}
/// Indexes section (ADR-0025), only when the table carries a
/// user-created index. A UNIQUE index is marked `[unique]` (ADR-0035
/// §4d).
@@ -1591,11 +1551,23 @@ mod tests {
}
#[test]
fn render_structure_with_relationships() {
fn render_structure_omits_relationship_prose() {
// ADR-0050 (issue #28): the incidental-DDL structure echo never
// carries the `References:` / `Referenced by:` block, even when
// the description carries both inbound and outbound
// relationships. (Relationship-subject surfaces render diagrams
// via `render_structure_with_diagrams`, not this function.)
let desc = TableDescription {
name: "Customers".to_string(),
columns: vec![col("id", Type::Serial, true, false)],
outbound_relationships: Vec::new(),
outbound_relationships: vec![RelationshipEnd {
name: "cust_region".to_string(),
other_table: "Regions".to_string(),
other_columns: vec!["id".to_string()],
local_columns: vec!["region_id".to_string()],
on_delete: ReferentialAction::NoAction,
on_update: ReferentialAction::NoAction,
}],
inbound_relationships: vec![RelationshipEnd {
name: "cust_orders".to_string(),
other_table: "Orders".to_string(),
@@ -1609,15 +1581,14 @@ mod tests {
check_constraints: Vec::new(),
};
let out = render_structure(&desc).join("\n");
assert!(
out.contains("Referenced by:"),
"expected inbound relationship section:\n{out}",
);
assert!(
out.contains("Orders.cust_id → id"),
"expected inbound relationship line:\n{out}",
);
assert_snapshot!(out);
// The structure box still renders.
assert!(out.contains("Customers"), "structure header:\n{out}");
assert!(out.contains("│ id"), "column row:\n{out}");
// No relationship block in either direction.
assert!(!out.contains("References:"), "no outbound prose:\n{out}");
assert!(!out.contains("Referenced by:"), "no inbound prose:\n{out}");
assert!(!out.contains("Orders.cust_id"), "no prose line:\n{out}");
assert!(!out.contains("Regions"), "no prose line:\n{out}");
}
#[test]
+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);
+61 -22
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())
@@ -1184,7 +1190,7 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac
// miss leaves that table's columns unpopulated and the
// walker falls back to the schemaless value-literal list.
for name in cache.tables.clone() {
if let Ok(desc) = database.describe_table(name.clone(), None).await {
if let Ok(desc) = database.describe_table(name.clone()).await {
// Per-table indexes for the items panel (S2, ADR-0025).
// Carry uniqueness so the panel can mark a UNIQUE index
// (ADR-0035 §4d). Captured before `desc.columns` is
@@ -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(),
}
}
};
@@ -1620,7 +1650,7 @@ async fn build_show_data_echo(
limit: Some(_),
..
} => database
.describe_table(name.clone(), None)
.describe_table(name.clone())
.await
.map(|desc| {
desc.columns
@@ -1702,7 +1732,7 @@ async fn collect_echo_lookups(
Command::DropIndex {
selector: IndexSelector::Columns { table, columns },
} => {
if let Ok(desc) = database.describe_table(table.clone(), None).await
if let Ok(desc) = database.describe_table(table.clone()).await
&& let Some(idx) = desc.indexes.iter().find(|i| i.columns == *columns)
{
out.drop_index_name = Some(idx.name.clone());
@@ -1717,7 +1747,7 @@ async fn collect_echo_lookups(
child_column,
},
} => {
if let Ok(desc) = database.describe_table(child_table.clone(), None).await
if let Ok(desc) = database.describe_table(child_table.clone()).await
&& let Some(rel) = desc.outbound_relationships.iter().find(|r| {
// The Endpoints drop selector is single-column
// (ADR-0043 keeps DROP by-endpoints single-column;
@@ -1741,7 +1771,7 @@ async fn collect_echo_lookups(
// resolver API would be the next step if schemas grow.
if let Ok(tables) = database.list_tables().await {
for table in tables {
if let Ok(desc) = database.describe_table(table.clone(), None).await
if let Ok(desc) = database.describe_table(table.clone()).await
&& desc.outbound_relationships.iter().any(|r| r.name == *name)
{
out.drop_relationship = Some((name.clone(), table.clone()));
@@ -1765,8 +1795,8 @@ async fn collect_echo_lookups(
// *before* execution to know which `ADD COLUMN` lines to
// emit. The parent columns here are the explicit DSL list,
// paired positionally with the child list.
let parent_desc = database.describe_table(parent_table.clone(), None).await;
let child_desc = database.describe_table(child_table.clone(), None).await;
let parent_desc = database.describe_table(parent_table.clone()).await;
let child_desc = database.describe_table(child_table.clone()).await;
if let (Ok(parent_desc), Ok(child_desc)) = (parent_desc, child_desc) {
let mut new_columns: Vec<(String, crate::dsl::types::Type)> = Vec::new();
for (child_col, parent_col) in child_columns.iter().zip(parent_columns) {
@@ -2034,7 +2064,7 @@ async fn enrich_check_violation(
.await
.map(|v| v.to_string());
// The rule itself — the column's compiled CHECK expression.
if let Ok(desc) = database.describe_table(table.to_string(), None).await
if let Ok(desc) = database.describe_table(table.to_string()).await
&& let Some(col) = desc.columns.iter().find(|c| c.name == column)
{
facts.check_rule.clone_from(&col.check);
@@ -2242,7 +2272,7 @@ async fn user_value_for_column_with_schema(
} = command
{
let desc = database
.describe_table(table.to_string(), None)
.describe_table(table.to_string())
.await
.ok()?;
// Build the natural-order column list the same way
@@ -2281,7 +2311,7 @@ async fn user_value_for_column_with_schema(
&& literal_rows.len() == 1
{
let desc = database
.describe_table(table.to_string(), None)
.describe_table(table.to_string())
.await
.ok()?;
let idx = desc.columns.iter().position(|c| c.name == column)?;
@@ -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 {
@@ -2891,7 +2930,7 @@ async fn execute_command_typed(
.await
.map(|d| CommandOutcome::Schema(Some(d))),
Command::ShowTable { name } => database
.describe_table(name, src)
.describe_table(name)
.await
.map(|d| CommandOutcome::Schema(Some(d))),
// ADR-0044: a named relationship renders as a diagram (App-side),
@@ -2944,14 +2983,14 @@ async fn execute_command_typed(
filter,
limit,
} => database
.query_data(name, filter, limit, src)
.query_data(name, filter, limit)
.await
.map(CommandOutcome::Query),
// A SQL `SELECT` (advanced mode; ADR-0030 §6, ADR-0031).
// The grammar walker has already validated `sql` is in
// the supported subset; the worker runs it as text.
Command::Select { sql } => database
.run_select(sql, src)
.run_select(sql)
.await
.map(CommandOutcome::Query),
// A SQL `INSERT` (advanced mode; ADR-0033 §1). Grammar-as-
+52
View File
@@ -31,6 +31,16 @@ const RECENT_WINDOW_DAYS: i64 = 3 * 365;
const ADULT_MIN_DAYS: i64 = 18 * 365;
const ADULT_MAX_DAYS: i64 = 80 * 365;
/// Year windows for the `int`-typed year heuristics (issue #33),
/// expressed relative to [`REF_YEAR`] so they advance with releases —
/// the year siblings of the `DateRecent` / `DateAdult` windows above.
/// `YearRecent` spans ~75 years (19502025 at REF_YEAR=2025), wide
/// enough for `published` / `founded` / `release_year`; `YearBirth`
/// mirrors the adult birth window (19452007).
const YEAR_RECENT_SPAN: i32 = 75;
const YEAR_BIRTH_MIN_AGE: i32 = 18;
const YEAR_BIRTH_MAX_AGE: i32 = 80;
/// Produce one value for `generator` against destination type `ty`.
#[must_use]
pub fn generate_value(generator: &Generator, ty: Type, rng: &mut SeedRng) -> Value {
@@ -71,6 +81,13 @@ pub fn generate_value(generator: &Generator, ty: Type, rng: &mut SeedRng) -> Val
Generator::CurrencyAmount => currency_amount(ty, rng),
Generator::Age => Value::Number(rng.random_range(18..=80).to_string()),
Generator::SmallInt => Value::Number(rng.random_range(1..=100).to_string()),
Generator::YearRecent => {
Value::Number(rng.random_range((REF_YEAR - YEAR_RECENT_SPAN)..=REF_YEAR).to_string())
}
Generator::YearBirth => Value::Number(
rng.random_range((REF_YEAR - YEAR_BIRTH_MAX_AGE)..=(REF_YEAR - YEAR_BIRTH_MIN_AGE))
.to_string(),
),
Generator::DateRecent => Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS))),
Generator::DateAdult => {
Value::Text(format_date(random_past_date(rng, ADULT_MIN_DAYS, ADULT_MAX_DAYS)))
@@ -489,6 +506,41 @@ mod tests {
assert!(matches!(v, Value::Number(_)), "numeric pick should be a Number: {v:?}");
}
#[test]
fn year_generators_stay_within_their_bounded_windows() {
// Issue #33: both year generators emit a plain `int` inside a
// bounded, plausible window — never the unbounded-int nonsense.
let mut rng = make_rng(Some(7));
for _ in 0..300 {
let Value::Number(s) = generate_value(&Generator::YearRecent, Type::Int, &mut rng)
else {
panic!("YearRecent must be a Number")
};
let n: i32 = s.parse().unwrap();
assert!((1950..=2025).contains(&n), "YearRecent {n} out of [1950,2025]");
}
for _ in 0..300 {
let Value::Number(s) = generate_value(&Generator::YearBirth, Type::Int, &mut rng)
else {
panic!("YearBirth must be a Number")
};
let n: i32 = s.parse().unwrap();
assert!((1945..=2007).contains(&n), "YearBirth {n} out of [1945,2007]");
}
}
#[test]
fn year_generators_are_deterministic_for_a_fixed_seed() {
assert_eq!(
gen_once(&Generator::YearRecent, Type::Int, 42),
gen_once(&Generator::YearRecent, Type::Int, 42),
);
assert_eq!(
gen_once(&Generator::YearBirth, Type::Int, 42),
gen_once(&Generator::YearBirth, Type::Int, 42),
);
}
#[test]
fn int_range_stays_within_inclusive_bounds() {
let g = Generator::Range { low: "10".into(), high: "20".into() };
+128 -2
View File
@@ -57,9 +57,14 @@ fn choose_generator_inner(table: &str, col: &ColumnSpec) -> Generator {
/// the post-seed advisory; such columns still receive generic text.
#[must_use]
pub fn is_enum_ish(name: &str) -> bool {
// `priority` is intentionally absent: issue #34 gave it a built-in
// value set (low/medium/high · 1/2/3), so it is no longer "filled
// generically" and must not trigger the D13 advisory. `severity` /
// `rating` / `stars` were never here. `status` stays — it is
// deliberately left to the advisory (no built-in set).
const ENUM_TOKENS: &[&str] = &[
"role", "status", "state", "type", "kind", "category", "level",
"tier", "stage", "priority", "gender",
"tier", "stage", "gender",
];
let toks = tokens(name);
toks.iter().any(|t| ENUM_TOKENS.contains(&t.as_str()))
@@ -150,6 +155,49 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
if numeric && has_any(toks, &["quantity", "qty", "stock", "count"]) {
return Some(Generator::SmallInt);
}
// — Year-as-int (issue #33) — bounded plausible years so the `int`
// type fallback (D8) can't emit nonsense like `9419`. `int`-gated
// (years are whole numbers) and placed *after* the quantity rule so
// `year_count` (a count of years) stays a `SmallInt`. `birth`/`born`/
// `dob` + year picks the birth window — the int sibling of the
// `dob → DateAdult` rule above — otherwise a recent window covers
// `year` / `*_year` / `published` / `founded`.
if matches!(ty, Type::Int)
&& (has_token(toks, "year") || has_any(toks, &["published", "founded"]))
{
return Some(if has_any(toks, &["birth", "born", "dob"]) {
Generator::YearBirth
} else {
Generator::YearRecent
});
}
// — Conventional choice sets (issue #34) — a few enum-ish names have
// a near-canonical small value set that reads far better than lorem
// text. Type-gated; reuses `PickFrom`. Names *without* a canonical
// set (`status`, `role`, `type`, …) stay unmatched → generic text +
// the D12/D13 advisory. `status` is deliberately excluded: its real
// values are too domain-specific (user-confirmed, issue #34). A
// user-declared `IN`-CHECK still wins — it is resolved before this.
if has_any(toks, &["priority", "prio"]) {
if text {
return Some(pick_from(&["low", "medium", "high"]));
}
if matches!(ty, Type::Int) {
return Some(pick_from(&["1", "2", "3"]));
}
}
if has_token(toks, "severity") {
if text {
return Some(pick_from(&["low", "medium", "high", "critical"]));
}
if matches!(ty, Type::Int) {
return Some(pick_from(&["1", "2", "3", "4"]));
}
}
if matches!(ty, Type::Int) && has_any(toks, &["rating", "stars"]) {
return Some(pick_from(&["1", "2", "3", "4", "5"]));
}
// — Temporal (bounded, D8) —
if matches!(ty, Type::Date) && has_any(toks, &["dob", "birthday", "birthdate"]) {
@@ -267,6 +315,14 @@ fn tokens(name: &str) -> Vec<String> {
out
}
/// A `PickFrom` generator from string-literal values (issue #34's
/// conventional choice sets). `literal_to_value` interprets each entry
/// by the destination type at generation time (an `int` column turns
/// `"1"` into a number).
fn pick_from(values: &[&str]) -> Generator {
Generator::PickFrom(values.iter().map(|s| (*s).to_string()).collect())
}
fn has_token(toks: &[String], t: &str) -> bool {
toks.iter().any(|x| x == t)
}
@@ -412,11 +468,81 @@ mod tests {
assert!(is_enum_ish("status"));
assert!(is_enum_ish("role"));
assert!(is_enum_ish("order_state"));
assert!(is_enum_ish("priority"));
// Issue #34: `priority` gained a built-in value set, so it is no
// longer advised (it is no longer "filled generically").
assert!(!is_enum_ish("priority"));
assert!(!is_enum_ish("severity"));
assert!(!is_enum_ish("rating"));
assert!(!is_enum_ish("email"));
assert!(!is_enum_ish("first_name"));
}
#[test]
fn year_like_int_columns_map_to_bounded_years() {
// Issue #33: `int`-gated year heuristics. `birth`/`born`/`dob`
// years pick the birth window; the rest a recent window.
assert_eq!(choose("authors", "birth_year", Type::Int), Generator::YearBirth);
assert_eq!(choose("authors", "birthYear", Type::Int), Generator::YearBirth);
assert_eq!(choose("u", "year_born", Type::Int), Generator::YearBirth);
assert_eq!(choose("books", "year", Type::Int), Generator::YearRecent);
assert_eq!(choose("films", "release_year", Type::Int), Generator::YearRecent);
assert_eq!(choose("books", "published", Type::Int), Generator::YearRecent);
assert_eq!(choose("companies", "founded", Type::Int), Generator::YearRecent);
// Type-gated: a text `year` is not a bounded-year int.
assert_eq!(choose("books", "year", Type::Text), Generator::Generic);
// `year_count` is a count, not a year — the quantity rule wins.
assert_eq!(choose("t", "year_count", Type::Int), Generator::SmallInt);
}
#[test]
fn conventional_choice_sets_map_to_pick_from() {
// Issue #34: type-gated built-in value sets.
assert_eq!(
choose("tickets", "priority", Type::Text),
Generator::PickFrom(vec!["low".into(), "medium".into(), "high".into()]),
);
assert_eq!(
choose("tickets", "prio", Type::Int),
Generator::PickFrom(vec!["1".into(), "2".into(), "3".into()]),
);
assert_eq!(
choose("bugs", "severity", Type::Text),
Generator::PickFrom(vec!["low".into(), "medium".into(), "high".into(), "critical".into()]),
);
assert_eq!(
choose("bugs", "severity", Type::Int),
Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into()]),
);
assert_eq!(
choose("reviews", "rating", Type::Int),
Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into(), "5".into()]),
);
assert_eq!(
choose("reviews", "stars", Type::Int),
Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into(), "5".into()]),
);
}
#[test]
fn status_is_left_to_the_advisory_not_given_a_set() {
// User-confirmed (issue #34): `status` keeps the D12 "don't
// guess" stance — generic text + the advisory, no built-in set.
assert_eq!(choose("orders", "status", Type::Text), Generator::Generic);
assert!(is_enum_ish("status"));
}
#[test]
fn a_declared_in_check_still_wins_over_a_built_in_set() {
// The CHECK is the user's explicit intent; it precedes the
// issue-#34 default set for the same name.
let mut spec = ColumnSpec::plain("priority", Type::Text);
spec.check_in_values = Some(vec!["p1".into(), "p2".into()]);
assert_eq!(
choose_generator("tickets", &spec),
Generator::PickFrom(vec!["p1".into(), "p2".into()]),
);
}
#[test]
fn enum_ish_columns_fall_through_to_generic() {
// No special generator — generic text + the advisory flags them.
+7
View File
@@ -149,6 +149,13 @@ pub enum Generator {
Age,
/// A small positive integer (quantities, counts).
SmallInt,
/// A plausible recent year as a plain `int` — `year` / `*_year` /
/// `published` / `founded` columns (issue #33). Bounded window so the
/// type-based `int` fallback can't emit nonsense like `9419`.
YearRecent,
/// A plausible birth year as a plain `int` — `birth_year` and kin
/// (issue #33), the year-typed sibling of [`Self::DateAdult`].
YearBirth,
// — Temporal (bounded windows, D8) —
/// A date within the last few years.
DateRecent,
@@ -1,12 +0,0 @@
---
source: src/output_render.rs
expression: out
---
Customers
┌──────┬────────┬─────────────┐
│ Name │ Type │ Constraints │
├──────┼────────┼─────────────┤
│ id │ serial │ PK │
└──────┴────────┴─────────────┘
Referenced by:
Orders.cust_id → id (cust_orders, on delete cascade, on update no action)
@@ -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
+304 -32
View File
@@ -275,13 +275,15 @@ fn render_nav_sidebar_overlay(app: &mut App, theme: &Theme, frame: &mut Frame<'_
render_relationships_panel(app, theme, frame, parts[1]);
}
/// Border style for a sidebar panel: an accented, bold border when it
/// holds navigation focus (ADR-0046 DC3), the muted border otherwise.
/// Border style for a sidebar panel: a non-bold **accent colour**
/// border when it holds navigation focus (ADR-0046 DC3, refined by
/// Amendment 1 / issue #25), the muted border otherwise. The focus
/// cue is the accent hue, NOT `Modifier::BOLD` — bold box-drawing
/// glyphs render as broken/gapped line-art in the asciinema player
/// and are fragile in some terminals.
fn panel_border_style(theme: &Theme, focused: bool) -> Style {
if focused {
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD)
Style::default().fg(theme.mode_simple)
} else {
Style::default().fg(theme.border)
}
@@ -1692,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)
}
}
}
@@ -1843,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)
@@ -1853,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);
}
EffectiveMode::AdvancedPersistent => {
push_shortcut(&mut spans, "mode simple", &switch);
}
EffectiveMode::AdvancedOneShot => {
push_shortcut(&mut spans, "Backspace", &cancel_one_shot);
}
spans.push(Span::styled(label, label_style));
}
push_shortcut(&mut spans, "Ctrl-C", &quit);
let paragraph = Paragraph::new(Line::from(spans)).style(bar_style);
frame.render_widget(paragraph, area);
@@ -2580,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.
@@ -3027,16 +3239,76 @@ mod tests {
#[test]
fn focused_panel_gets_an_accent_border() {
// ADR-0046 DC3: the focused sidebar panel is accent-bordered.
// ADR-0046 DC3 (Amendment 1, issue #25): the focused sidebar
// panel is marked by a non-bold accent COLOUR, not bold. Bold
// box-drawing glyphs render as broken/gapped line-art in the
// asciinema player (and are fragile in some terminals), so the
// focus cue is the accent hue against the muted unfocused
// border — never a `Modifier::BOLD` on the border.
let theme = Theme::dark();
let focused = panel_border_style(&theme, true);
let normal = panel_border_style(&theme, false);
assert_eq!(focused.fg, Some(theme.fg));
assert!(focused.add_modifier.contains(Modifier::BOLD));
assert_eq!(focused.fg, Some(theme.mode_simple));
assert!(
!focused.add_modifier.contains(Modifier::BOLD),
"the focused border must NOT be bold (issue #25)",
);
assert_eq!(normal.fg, Some(theme.border));
assert!(!normal.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn focused_panel_border_cells_are_accent_colour_not_bold() {
// Full-stack guard for issue #25: the accent colour (and the
// absence of bold) must reach the actual rendered border cells,
// not just `panel_border_style` in isolation. With the Tables
// panel focused, its box-drawing border cells carry
// `theme.mode_simple` and never `Modifier::BOLD`; with no panel
// focused, no border cell wears the accent colour.
const BOX_DRAWING: &[char] = &['╭', '╮', '╰', '╯', '─', '│'];
let is_border = |sym: &str| sym.chars().all(|c| BOX_DRAWING.contains(&c));
let theme = Theme::dark();
let mut app = App::new();
app.tables = vec!["Customers".to_string(), "Orders".to_string()];
app.nav_focus = NavFocus::SidebarTables;
let buf = render_to_buffer(&mut app, &theme, 110, 24);
let mut accent_border_cells = 0;
for y in 0..buf.area.height {
for x in 0..buf.area.width {
let cell = &buf[(x, y)];
if is_border(cell.symbol()) && cell.fg == theme.mode_simple {
accent_border_cells += 1;
assert!(
!cell.modifier.contains(Modifier::BOLD),
"focused border cell at ({x},{y}) must not be bold (issue #25)",
);
}
}
}
assert!(
accent_border_cells > 0,
"the focused Tables panel must render accent-coloured border cells",
);
// With nothing focused (Input), no border cell wears the accent.
let mut app2 = App::new();
app2.tables = vec!["Customers".to_string()];
app2.nav_focus = NavFocus::Input;
let buf2 = render_to_buffer(&mut app2, &theme, 110, 24);
for y in 0..buf2.area.height {
for x in 0..buf2.area.width {
let cell = &buf2[(x, y)];
if is_border(cell.symbol()) {
assert_ne!(
cell.fg, theme.mode_simple,
"no border cell may wear the focus accent when nothing is focused (at {x},{y})",
);
}
}
}
}
#[test]
fn focused_tables_panel_scrolls_and_clamps() {
// ADR-0046 DC3: more tables than fit → a large offset reveals the
+5 -5
View File
@@ -93,7 +93,7 @@ fn rename_column_with_case_variant_table_keeps_metadata_in_step() {
.expect("rename column via a case-variant table name");
let desc = r
.block_on(db.describe_table("Items".to_string(), None))
.block_on(db.describe_table("Items".to_string()))
.expect("describe Items");
let amount = desc
.columns
@@ -126,7 +126,7 @@ fn insert_with_case_variant_table_persists_and_survives_rebuild() {
let db = fresh_rebuild(db, &project, &r);
let rows = r
.block_on(db.query_data("Items".to_string(), None, None, None))
.block_on(db.query_data("Items".to_string(), None, None))
.expect("query")
.rows;
assert_eq!(rows.len(), 1, "the wrong-case insert survived the rebuild (no data loss)");
@@ -146,7 +146,7 @@ fn add_column_with_case_variant_table_survives_rebuild() {
);
let db = fresh_rebuild(db, &project, &r);
let desc = r.block_on(db.describe_table("Items".to_string(), None)).expect("describe");
let desc = r.block_on(db.describe_table("Items".to_string())).expect("describe");
let qty = desc.columns.iter().find(|c| c.name == "qty").expect("qty added");
assert_eq!(qty.user_type, Some(Type::Int), "qty's user-type survived the rebuild");
// The CHECK is intact too (a negative qty is refused under the real table).
@@ -224,12 +224,12 @@ fn add_relationship_with_case_variant_tables_survives_rebuild() {
add 1:n relationship from parent.id to child.parent_id\n",
);
// The parent's inbound relationship is visible under the stored case.
let p = r.block_on(db.describe_table("Parent".to_string(), None)).expect("describe Parent");
let p = r.block_on(db.describe_table("Parent".to_string())).expect("describe Parent");
assert_eq!(p.inbound_relationships.len(), 1, "relationship recorded under the stored case");
assert_eq!(p.inbound_relationships[0].other_table, "Child");
let db = fresh_rebuild(db, &project, &r);
let p = r.block_on(db.describe_table("Parent".to_string(), None)).expect("describe Parent");
let p = r.block_on(db.describe_table("Parent".to_string())).expect("describe Parent");
assert_eq!(p.inbound_relationships.len(), 1, "relationship survived the rebuild");
assert_eq!(p.inbound_relationships[0].other_table, "Child");
}
+5 -5
View File
@@ -276,7 +276,7 @@ fn compound_fk_declares_enforces_and_round_trips() {
);
// describe shows the compound endpoints symmetrically.
let city = db.describe_table("City".to_string(), None).await.unwrap();
let city = db.describe_table("City".to_string()).await.unwrap();
let outbound = &city.outbound_relationships[0];
assert_eq!(
outbound.local_columns,
@@ -329,7 +329,7 @@ fn compound_fk_create_fk_makes_both_child_columns() {
)
.await
.expect("add compound relationship with --create-fk");
let city = db.describe_table("City".to_string(), None).await.unwrap();
let city = db.describe_table("City".to_string()).await.unwrap();
for col in ["c_country", "c_code"] {
assert!(
city.columns.iter().any(|c| c.name == col),
@@ -527,7 +527,7 @@ fn compound_fk_survives_rebuild_from_text() {
.await;
assert!(bad.is_err(), "compound FK still enforced after rebuild from text");
// Endpoints survived the round-trip intact.
let city = db.describe_table("City".to_string(), None).await.unwrap();
let city = db.describe_table("City".to_string()).await.unwrap();
assert_eq!(
city.outbound_relationships[0].other_columns,
vec!["country".to_string(), "code".to_string()],
@@ -563,7 +563,7 @@ fn compound_fk_undo_removes_the_relationship() {
.await
.expect("add compound relationship");
assert_eq!(
db.describe_table("City".to_string(), None)
db.describe_table("City".to_string())
.await
.unwrap()
.outbound_relationships
@@ -573,7 +573,7 @@ fn compound_fk_undo_removes_the_relationship() {
// One undo step removes the whole relationship (ADR-0013/0006).
db.undo().await.unwrap().expect("undo applied");
assert!(
db.describe_table("City".to_string(), None)
db.describe_table("City".to_string())
.await
.unwrap()
.outbound_relationships
+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]
+4 -4
View File
@@ -76,7 +76,7 @@ fn rebuild_restores_schema_only_project() {
// Phase 4: confirm Customers exists with the right shape.
let desc = rt()
.block_on(async { db.describe_table("Customers".to_string(), None).await })
.block_on(async { db.describe_table("Customers".to_string()).await })
.expect("describe_table");
assert_eq!(desc.name, "Customers");
let cols: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect();
@@ -143,7 +143,7 @@ fn rebuild_restores_rows_from_csv() {
});
let rows = rt()
.block_on(async { db.query_data("Customers".to_string(), None, None, None).await })
.block_on(async { db.query_data("Customers".to_string(), None, None).await })
.expect("query_data");
assert_eq!(rows.rows.len(), 2);
let names: Vec<Option<String>> = rows.rows.iter().map(|r| r[1].clone()).collect();
@@ -371,7 +371,7 @@ fn rebuild_preserves_created_at_from_yaml() {
// Trigger any successful command so project.yaml is
// rewritten from the now-rebuilt db state.
rt().block_on(async {
db.describe_table("T".to_string(), Some("show table T".to_string()))
db.describe_table("T".to_string())
.await
.unwrap();
// describe is read-only; force a rewrite by adding a column.
@@ -451,7 +451,7 @@ fn rebuild_restores_indexes() {
});
let desc = rt()
.block_on(async { db.describe_table("Customers".to_string(), None).await })
.block_on(async { db.describe_table("Customers".to_string()).await })
.expect("describe_table");
assert_eq!(desc.indexes.len(), 1, "index should survive rebuild");
assert_eq!(desc.indexes[0].name, "idx_email");
+4 -7
View File
@@ -173,15 +173,12 @@ fn rebuild_against_populated_db_wipes_and_reloads() {
.expect("rebuild");
});
let rows = rt()
.block_on(async { db.query_data("Customers".to_string(), None, None, None).await })
.block_on(async { db.query_data("Customers".to_string(), None, None).await })
.unwrap();
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.
}
+1 -1
View File
@@ -362,7 +362,7 @@ fn end_to_end_export_then_import_real_project() {
// Round-trip: the inserted row is back.
let data_view = rt()
.block_on(async { imported_db.query_data("Customers".to_string(), None, None, None).await })
.block_on(async { imported_db.query_data("Customers".to_string(), None, None).await })
.expect("query data");
assert_eq!(data_view.rows.len(), 1);
// Serial id auto-filled to 1; Name was the inserted value.
+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();
+6 -6
View File
@@ -107,7 +107,7 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() {
assert!(tables.contains(&"Students_Courses".to_string()), "tables: {tables:?}");
// Two FK columns, both part of the compound PK.
let desc = db.describe_table("Students_Courses".to_string(), None).await.unwrap();
let desc = db.describe_table("Students_Courses".to_string()).await.unwrap();
let cols: Vec<(&str, bool)> =
desc.columns.iter().map(|c| (c.name.as_str(), c.primary_key)).collect();
assert_eq!(
@@ -191,7 +191,7 @@ fn compound_parent_pk_contributes_one_fk_column_each() {
.await
.expect("create m:n");
let desc = db.describe_table("Students_Sections".to_string(), None).await.unwrap();
let desc = db.describe_table("Students_Sections".to_string()).await.unwrap();
let names: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["Students_id", "Sections_course_id", "Sections_term"]);
// All three form the compound PK.
@@ -221,7 +221,7 @@ fn deleting_a_parent_cascades_to_the_junction() {
// Deleting the student cascades to the junction (ON DELETE CASCADE).
db.delete("Students".to_string(), RowFilter::AllRows, None).await.unwrap();
let rows = db.query_data("Students_Courses".to_string(), None, None, None).await.unwrap();
let rows = db.query_data("Students_Courses".to_string(), None, None).await.unwrap();
assert!(rows.rows.is_empty(), "junction rows should cascade-delete, got {:?}", rows.rows);
});
}
@@ -249,7 +249,7 @@ fn create_m2n_is_one_undo_step() {
let tables = db.list_tables().await.unwrap();
assert!(!tables.contains(&"Students_Courses".to_string()), "undo should remove the junction: {tables:?}");
// The parents' relationships are gone too (the junction held them).
let students = db.describe_table("Students".to_string(), None).await.unwrap();
let students = db.describe_table("Students".to_string()).await.unwrap();
assert!(students.inbound_relationships.is_empty(), "no leftover relationship after undo");
});
}
@@ -321,7 +321,7 @@ fn the_junction_can_be_renamed() {
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
assert!(!tables.contains(&"Students_Courses".to_string()));
// Both relationships survive the rename (rebuild-preserving).
let desc = db.describe_table("Enrollments".to_string(), None).await.unwrap();
let desc = db.describe_table("Enrollments".to_string()).await.unwrap();
assert_eq!(desc.outbound_relationships.len(), 2, "FKs preserved across rename");
});
}
@@ -362,7 +362,7 @@ fn junction_survives_save_and_rebuild() {
db.rebuild_from_text(project.path().to_path_buf(), None).await.expect("rebuild");
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Students_Courses".to_string()), "junction survived: {tables:?}");
let desc = db.describe_table("Students_Courses".to_string(), None).await.unwrap();
let desc = db.describe_table("Students_Courses".to_string()).await.unwrap();
assert_eq!(desc.outbound_relationships.len(), 2, "both FKs reconstructed");
assert!(desc.columns.iter().all(|c| c.primary_key), "compound PK reconstructed");
});
+9 -9
View File
@@ -108,13 +108,13 @@ fn replay_runs_advanced_sql_create_table_as_a_write() {
// The SQL DDL line actually created the structural table…
let desc = rt()
.block_on(async { db.describe_table("Widget".to_string(), None).await })
.block_on(async { db.describe_table("Widget".to_string()).await })
.expect("describe");
let names: Vec<String> = desc.columns.iter().map(|c| c.name.clone()).collect();
assert_eq!(names, vec!["id".to_string(), "name".to_string()]);
// …and the following insert (serial id auto-filled) ran against it.
let rows = rt()
.block_on(async { db.query_data("Widget".to_string(), None, None, None).await })
.block_on(async { db.query_data("Widget".to_string(), None, None).await })
.expect("query")
.rows;
assert_eq!(rows.len(), 1);
@@ -139,7 +139,7 @@ fn replay_three_lines_dispatches_three_commands() {
// The dispatched commands actually mutated state.
let data_result = rt()
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
.block_on(async { db.query_data("T".to_string(), None, None).await })
.expect("query_data");
assert_eq!(data_result.rows.len(), 1, "row inserted");
assert_eq!(data_result.rows[0][1].as_deref(), Some("Alice"));
@@ -174,7 +174,7 @@ fn replay_of_actual_history_log_runs_ok_commands_and_skips_err() {
assert_completed(&events, 3);
let data_result = rt()
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
.block_on(async { db.query_data("T".to_string(), None, None).await })
.expect("query_data");
assert_eq!(data_result.rows.len(), 1, "only the ok INSERT applied");
assert_eq!(data_result.rows[0][1].as_deref(), Some("alpha"));
@@ -227,7 +227,7 @@ fn replay_skips_app_lifecycle_commands_silently() {
other => panic!("expected ReplayCompleted, got {other:?}"),
}
let data_result = rt()
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
.block_on(async { db.query_data("T".to_string(), None, None).await })
.expect("query_data");
assert!(
data_result.columns.iter().any(|c| c == "v"),
@@ -401,14 +401,14 @@ fn replay_aborts_on_first_parse_failure_and_reports_line() {
// but earlier commands stayed applied (table T exists with
// the `name` column).
let desc = rt()
.block_on(async { db.describe_table("T".to_string(), None).await })
.block_on(async { db.describe_table("T".to_string()).await })
.expect("describe_table");
assert!(
desc.columns.iter().any(|c| c.name == "name"),
"earlier add column should have stayed applied"
);
let data_result = rt()
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
.block_on(async { db.query_data("T".to_string(), None, None).await })
.expect("query_data");
assert!(
data_result.rows.is_empty(),
@@ -467,7 +467,7 @@ fn replay_rejects_wrong_type_value_in_a_hand_built_script() {
// The earlier two lines stayed applied; the failing insert
// did not run — state is intact.
let data_result = rt()
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
.block_on(async { db.query_data("T".to_string(), None, None).await })
.expect("query_data");
assert!(
data_result.rows.is_empty(),
@@ -527,7 +527,7 @@ fn replay_skips_nested_replay_with_a_warning() {
other => panic!("expected ReplayCompleted (nested replay skipped), got {other:?}"),
}
// The nested file's table was NOT created (the replay was skipped).
let cols = rt().block_on(async { db.query_data("T".to_string(), None, None, None).await });
let cols = rt().block_on(async { db.query_data("T".to_string(), None, None).await });
assert!(cols.is_err(), "inner.commands' table T must not exist (nested replay skipped)");
}
+117 -18
View File
@@ -281,6 +281,123 @@ fn seed_populates_a_table_and_persists_rows() {
assert!(csv.contains('@'), "seeded emails should appear in the CSV:\n{csv}");
}
/// Parse a seeded table's CSV into per-column value lists (simple
/// comma-split — the values under test carry no commas/quotes).
fn csv_columns(csv: &str) -> (Vec<String>, Vec<Vec<String>>) {
let mut lines = csv.lines().filter(|l| !l.trim().is_empty());
let header: Vec<String> = lines.next().unwrap().split(',').map(str::to_string).collect();
let rows: Vec<Vec<String>> =
lines.map(|l| l.split(',').map(str::to_string).collect()).collect();
(header, rows)
}
fn column_values(csv: &str, col: &str) -> Vec<String> {
let (header, rows) = csv_columns(csv);
let idx = header.iter().position(|h| h == col).expect("column present");
rows.iter().map(|r| r[idx].clone()).collect()
}
#[test]
fn seed_year_and_choice_set_heuristics() {
// Issues #33 (year-like int columns) + #34 (conventional choice
// sets). A fixed `--seed` makes the values deterministic; we assert
// membership in the bounded windows / value sets rather than exact
// strings (robust to RNG-internals changes, still proves the
// heuristic fired — the type fallback would produce 9419 / lorem).
let (project, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(db.create_table(
"Records".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("birth_year", Type::Int),
ColumnSpec::new("published", Type::Int),
ColumnSpec::new("priority", Type::Text),
ColumnSpec::new("severity", Type::Text),
ColumnSpec::new("rating", Type::Int),
],
vec!["id".to_string()],
None,
))
.expect("create Records");
rt.block_on(db.seed("Records".into(), None, Some(30), Vec::new(), Some(99), Some("seed Records 30".into())))
.expect("seed succeeds");
let csv = read_csv(&project, "Records").expect("Records CSV exists");
for y in column_values(&csv, "birth_year") {
let n: i32 = y.parse().expect("birth_year is an int");
assert!((1945..=2007).contains(&n), "birth_year {n} must be a plausible birth year");
}
for y in column_values(&csv, "published") {
let n: i32 = y.parse().expect("published is an int");
assert!((1950..=2025).contains(&n), "published {n} must be a plausible recent year");
}
for p in column_values(&csv, "priority") {
assert!(["low", "medium", "high"].contains(&p.as_str()), "priority `{p}` must be low/medium/high");
}
for s in column_values(&csv, "severity") {
assert!(
["low", "medium", "high", "critical"].contains(&s.as_str()),
"severity `{s}` must be low/medium/high/critical",
);
}
for r in column_values(&csv, "rating") {
let n: i32 = r.parse().expect("rating is an int");
assert!((1..=5).contains(&n), "rating {n} must be 15");
}
}
#[test]
fn seed_column_fill_uses_choice_set_heuristic() {
// The `seed <table>.<column>` column-fill path (an UPDATE over
// existing rows) shares `choose_generator`, so issue #34's value
// sets apply there too. Insert rows with `priority` left NULL, then
// fill just that column and confirm it collapses to the set.
let (project, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(db.create_table(
"Tasks".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("title", Type::Text),
ColumnSpec::new("priority", Type::Text),
],
vec!["id".to_string()],
None,
))
.expect("create Tasks");
for t in ["a", "b", "c", "d"] {
rt.block_on(db.insert(
"Tasks".to_string(),
Some(vec!["title".to_string()]),
vec![Value::Text(t.to_string())],
None,
))
.expect("insert row");
}
rt.block_on(db.seed(
"Tasks".into(),
Some("priority".into()),
None,
Vec::new(),
Some(5),
Some("seed Tasks.priority".into()),
))
.expect("column-fill priority");
let csv = read_csv(&project, "Tasks").expect("Tasks CSV");
let priorities = column_values(&csv, "priority");
assert_eq!(priorities.len(), 4, "every existing row is filled:\n{csv}");
for p in priorities {
assert!(
["low", "medium", "high"].contains(&p.as_str()),
"column-fill priority `{p}` must be low/medium/high",
);
}
}
#[test]
fn seed_count_defaults_to_twenty() {
let (project, db, _dir) = open_project_db();
@@ -313,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
+1 -1
View File
@@ -462,7 +462,7 @@ fn app_show_table_renders_relationships_as_compact_diagrams() {
rt.block_on(seed_schema(&db));
// Orders holds the FK to Customers — an outbound relationship.
let desc = rt
.block_on(db.describe_table("Orders".to_string(), None))
.block_on(db.describe_table("Orders".to_string()))
.expect("describe Orders");
let mut app = App::new();
+17 -17
View File
@@ -111,7 +111,7 @@ fn e2e_alter_drop_compound_primary_key_member_is_refused() {
/// The current user-facing type of column `name` in table `T`.
fn col_type(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option<Type> {
r.block_on(db.describe_table("T".to_string(), None))
r.block_on(db.describe_table("T".to_string()))
.expect("describe")
.columns
.into_iter()
@@ -120,7 +120,7 @@ fn col_type(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option<Ty
}
fn column_names(db: &Database, r: &tokio::runtime::Runtime) -> Vec<String> {
r.block_on(db.describe_table("T".to_string(), None))
r.block_on(db.describe_table("T".to_string()))
.expect("describe")
.columns
.into_iter()
@@ -163,7 +163,7 @@ fn e2e_alter_table_add_rename_drop_and_raw_default_check() {
// The DEFAULT backfilled the pre-existing row to qty = 0.
let rows = r
.block_on(db.query_data("T".to_string(), None, None, None))
.block_on(db.query_data("T".to_string(), None, None))
.expect("query")
.rows;
assert_eq!(rows.len(), 1);
@@ -252,7 +252,7 @@ fn e2e_alter_column_type_clean_and_lossy_convert() {
}
let rows = r
.block_on(db.query_data("T".to_string(), None, None, None))
.block_on(db.query_data("T".to_string(), None, None))
.expect("query")
.rows;
assert_eq!(rows.len(), 1);
@@ -292,7 +292,7 @@ fn e2e_alter_column_type_int_to_serial_is_allowed() {
}
assert_eq!(col_type(&db, &r, "n"), Some(Type::Serial), "int→serial converted the column");
let rows = r
.block_on(db.query_data("T".to_string(), None, None, None))
.block_on(db.query_data("T".to_string(), None, None))
.expect("query")
.rows;
assert_eq!(rows[0][1].as_deref(), Some("100"), "the existing value is preserved");
@@ -635,7 +635,7 @@ fn e2e_drop_composite_unique_is_one_undo_step() {
.expect("write");
r.block_on(run_replay(&db, project.path(), "u.commands"));
let has_unique = || {
!r.block_on(db.describe_table("T".to_string(), None))
!r.block_on(db.describe_table("T".to_string()))
.expect("describe")
.unique_constraints
.is_empty()
@@ -878,7 +878,7 @@ fn e2e_describe_shows_table_level_constraints() {
"events: {events:?}"
);
let desc = r.block_on(db.describe_table("T".to_string(), None)).expect("describe");
let desc = r.block_on(db.describe_table("T".to_string())).expect("describe");
assert_eq!(
desc.unique_constraints,
vec![vec!["a".to_string(), "b".to_string()]],
@@ -976,7 +976,7 @@ fn e2e_rename_table_with_rows_csv_follows_and_survives_rebuild() {
assert!(!csv_path(&project, "Orders").exists(), "data/Orders.csv removed");
let rows = r
.block_on(db.query_data("Purchases".to_string(), None, None, None))
.block_on(db.query_data("Purchases".to_string(), None, None))
.expect("query")
.rows;
assert_eq!(rows.len(), 2);
@@ -991,7 +991,7 @@ fn e2e_rename_table_with_rows_csv_follows_and_survives_rebuild() {
"Purchases round-tripped through a fresh rebuild: {tables:?}"
);
let rows = r
.block_on(db.query_data("Purchases".to_string(), None, None, None))
.block_on(db.query_data("Purchases".to_string(), None, None))
.expect("query")
.rows;
assert_eq!(rows.len(), 2);
@@ -1077,7 +1077,7 @@ fn e2e_rename_fk_parent_updates_metadata_and_still_enforces() {
);
// The child's outbound relationship now points at the new parent name.
let c = r.block_on(db.describe_table("C".to_string(), None)).expect("describe C");
let c = r.block_on(db.describe_table("C".to_string())).expect("describe C");
assert_eq!(c.outbound_relationships.len(), 1);
assert_eq!(c.outbound_relationships[0].other_table, "Parent");
@@ -1129,7 +1129,7 @@ fn e2e_rename_fk_child_updates_metadata_and_still_enforces() {
);
// The parent's inbound relationship now names the renamed child.
let p = r.block_on(db.describe_table("P".to_string(), None)).expect("describe P");
let p = r.block_on(db.describe_table("P".to_string())).expect("describe P");
assert_eq!(p.inbound_relationships.len(), 1);
assert_eq!(p.inbound_relationships[0].other_table, "Child");
@@ -1168,7 +1168,7 @@ fn e2e_rename_self_referential_table_updates_both_ends() {
);
// Both ends of the self-reference now name `Tree`.
let t = r.block_on(db.describe_table("Tree".to_string(), None)).expect("describe Tree");
let t = r.block_on(db.describe_table("Tree".to_string())).expect("describe Tree");
assert_eq!(t.outbound_relationships[0].other_table, "Tree");
assert_eq!(t.inbound_relationships[0].other_table, "Tree");
@@ -1216,7 +1216,7 @@ fn e2e_rename_table_keeps_its_index_with_a_stale_name() {
"events: {events:?}"
);
let u = r.block_on(db.describe_table("Users".to_string(), None)).expect("describe Users");
let u = r.block_on(db.describe_table("Users".to_string())).expect("describe Users");
assert_eq!(u.indexes.len(), 1, "the index followed the rename");
assert_eq!(
u.indexes[0].name, "T_email_idx",
@@ -1226,7 +1226,7 @@ fn e2e_rename_table_keeps_its_index_with_a_stale_name() {
// Survives a fresh rebuild (recreated from IndexSchema on table Users).
let db = fresh_rebuild(db, &project, &r);
let u = r.block_on(db.describe_table("Users".to_string(), None)).expect("describe Users");
let u = r.block_on(db.describe_table("Users".to_string())).expect("describe Users");
assert_eq!(u.indexes.len(), 1);
assert_eq!(u.indexes[0].name, "T_email_idx");
}
@@ -1255,7 +1255,7 @@ fn e2e_rename_table_is_one_undo_step() {
"undo restored the old table name: {tables:?}"
);
assert_eq!(
r.block_on(db.query_data("Orders".to_string(), None, None, None)).expect("query").rows.len(),
r.block_on(db.query_data("Orders".to_string(), None, None)).expect("query").rows.len(),
1,
"the row is back under the old name"
);
@@ -1427,7 +1427,7 @@ fn e2e_alter_column_set_default_applies() {
))
.expect("insert omitting qty");
let rows = r
.block_on(db.query_data("T".to_string(), None, None, None))
.block_on(db.query_data("T".to_string(), None, None))
.expect("query")
.rows;
assert_eq!(
@@ -1473,7 +1473,7 @@ fn e2e_alter_column_drop_default_removes_it() {
))
.expect("insert omitting qty");
let rows = r
.block_on(db.query_data("T".to_string(), None, None, None))
.block_on(db.query_data("T".to_string(), None, None))
.expect("query")
.rows;
assert_eq!(
+4 -4
View File
@@ -55,7 +55,7 @@ fn insert_row(db: &Database, r: &tokio::runtime::Runtime, id: i64, email: &str)
}
fn index(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option<(Vec<String>, bool)> {
r.block_on(db.describe_table("T".to_string(), None))
r.block_on(db.describe_table("T".to_string()))
.expect("describe")
.indexes
.into_iter()
@@ -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]
+21 -21
View File
@@ -64,7 +64,7 @@ fn created_table_appears_with_playground_types() {
assert!(tables.contains(&"Widget".to_string()));
let desc = r
.block_on(db.describe_table("Widget".to_string(), None))
.block_on(db.describe_table("Widget".to_string()))
.expect("describe");
let types: Vec<(String, Option<Type>)> = desc
.columns
@@ -98,7 +98,7 @@ fn integer_primary_key_is_plain_int() {
))
.expect("create");
let desc = r
.block_on(db.describe_table("T".to_string(), None))
.block_on(db.describe_table("T".to_string()))
.expect("describe");
assert_eq!(desc.columns[0].user_type, Some(Type::Int));
}
@@ -137,7 +137,7 @@ fn serial_pk_autoincrements_in_multi_column_table() {
}
let data = r
.block_on(db.query_data("T".to_string(), None, None, None))
.block_on(db.query_data("T".to_string(), None, None))
.expect("query");
let id_idx = data
.columns
@@ -220,7 +220,7 @@ fn table_without_primary_key_is_allowed() {
))
.expect("insert into PK-less table");
let data = r
.block_on(db.query_data("Notes".to_string(), None, None, None))
.block_on(db.query_data("Notes".to_string(), None, None))
.expect("query");
assert_eq!(data.rows.len(), 1);
}
@@ -299,7 +299,7 @@ fn default_is_applied_when_column_omitted() {
))
.expect("insert");
let data = r
.block_on(db.query_data("T".to_string(), None, None, None))
.block_on(db.query_data("T".to_string(), None, None))
.expect("query");
let n_idx = data.columns.iter().position(|c| c == "n").expect("n column");
assert_eq!(data.rows[0][n_idx].as_deref(), Some("7"), "DEFAULT 7 applied");
@@ -381,7 +381,7 @@ fn check_default_and_composite_unique_survive_rebuild() {
// A valid row inserts; DEFAULT n=7 survived.
r.block_on(ins("1", "1", "5")).expect("valid row");
let data = r
.block_on(db.query_data("T".to_string(), None, None, None))
.block_on(db.query_data("T".to_string(), None, None))
.expect("query");
let n_idx = data.columns.iter().position(|c| c == "n").expect("n column");
assert_eq!(data.rows[0][n_idx].as_deref(), Some("7"), "DEFAULT survived rebuild");
@@ -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]
@@ -679,7 +679,7 @@ fn sql_create_table_is_one_undo_step() {
/// Sorted `id` column values of table `T`.
fn ids(db: &Database, r: &tokio::runtime::Runtime) -> Vec<Option<String>> {
let d = r
.block_on(db.query_data("T".to_string(), None, None, None))
.block_on(db.query_data("T".to_string(), None, None))
.expect("query");
let idx = d.columns.iter().position(|c| c == "id").expect("id column");
let mut v: Vec<Option<String>> = d.rows.iter().map(|row| row[idx].clone()).collect();
@@ -801,7 +801,7 @@ fn dropping_a_column_a_table_check_references_fails_cleanly() {
// The table is intact: both columns survive (rollback) ...
let desc = r
.block_on(db.describe_table("T".to_string(), None))
.block_on(db.describe_table("T".to_string()))
.expect("describe still works");
assert_eq!(
desc.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>(),
@@ -925,14 +925,14 @@ fn foreign_key_creates_named_relationship_visible_in_describe() {
.expect("create child with FK");
// The child has an outbound relationship; the parent an inbound one.
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe child");
let child = r.block_on(db.describe_table("child".to_string())).expect("describe child");
assert_eq!(child.outbound_relationships.len(), 1, "child references parent");
let rel = &child.outbound_relationships[0];
assert_eq!(rel.name, "parent_id_to_child_pid", "auto-named per ADR-0013");
assert_eq!(rel.other_table, "parent");
assert_eq!(rel.local_columns, vec!["pid".to_string()]);
let parent = r.block_on(db.describe_table("parent".to_string(), None)).expect("describe parent");
let parent = r.block_on(db.describe_table("parent".to_string())).expect("describe parent");
assert_eq!(parent.inbound_relationships.len(), 1, "parent is referenced by child");
}
@@ -954,7 +954,7 @@ fn explicit_constraint_name_is_used() {
Some("create table child (id serial primary key, pid int, constraint child_to_parent foreign key (pid) references parent(id))".to_string()),
))
.expect("create child with named FK");
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe");
let child = r.block_on(db.describe_table("child".to_string())).expect("describe");
assert_eq!(child.outbound_relationships[0].name, "child_to_parent");
}
@@ -974,7 +974,7 @@ fn bare_references_resolves_to_parent_single_column_pk() {
Some("create table child (id serial primary key, pid int references parent)".to_string()),
))
.expect("create child with bare REFERENCES");
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe");
let child = r.block_on(db.describe_table("child".to_string())).expect("describe");
assert_eq!(child.outbound_relationships[0].other_columns, vec!["id".to_string()], "resolved to parent PK");
}
@@ -1108,7 +1108,7 @@ fn create_table_with_fk_is_one_undo_step() {
// parent (now un-referenced) can be described without a dangling rel.
r.block_on(db.undo()).expect("undo").expect("a step was undone");
assert!(!r.block_on(db.list_tables()).unwrap().contains(&"child".to_string()));
let parent = r.block_on(db.describe_table("parent".to_string(), None)).expect("describe parent");
let parent = r.block_on(db.describe_table("parent".to_string())).expect("describe parent");
assert!(parent.inbound_relationships.is_empty(), "the relationship was undone with the table");
}
@@ -1152,7 +1152,7 @@ fn foreign_key_on_delete_cascade_takes_effect() {
))
.expect("delete parent");
let child_rows = r
.block_on(db.query_data("child".to_string(), None, None, None))
.block_on(db.query_data("child".to_string(), None, None))
.expect("query child");
assert!(child_rows.rows.is_empty(), "ON DELETE CASCADE removed the child row");
}
@@ -1232,7 +1232,7 @@ fn fk_survives_a_rebuild_triggering_column_add() {
.expect("add column via rebuild");
// The relationship still exists after the rebuild.
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe");
let child = r.block_on(db.describe_table("child".to_string())).expect("describe");
assert_eq!(child.outbound_relationships.len(), 1, "FK survived the column-add rebuild");
// And the engine still enforces it (now and after a fresh rebuild).
insert_parent_row(&db, &r);
@@ -1275,7 +1275,7 @@ fn fk_referential_actions_survive_rebuild() {
))
.expect("create");
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), None)).expect("rebuild");
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe");
let child = r.block_on(db.describe_table("child".to_string())).expect("describe");
let rel = &child.outbound_relationships[0];
assert_eq!(rel.on_delete, ReferentialAction::Cascade, "ON DELETE survived rebuild");
assert_eq!(rel.on_update, ReferentialAction::SetNull, "ON UPDATE survived rebuild");
@@ -1299,7 +1299,7 @@ fn dropping_the_child_clears_the_fk_relationship() {
.expect("create");
r.block_on(db.drop_table("child".to_string(), Some("drop table child".to_string())))
.expect("drop child");
let parent = r.block_on(db.describe_table("parent".to_string(), None)).expect("describe parent");
let parent = r.block_on(db.describe_table("parent".to_string())).expect("describe parent");
assert!(parent.inbound_relationships.is_empty(), "dropping the child cleared the relationship");
}
@@ -1341,7 +1341,7 @@ fn bare_self_reference_resolves_to_own_pk() {
Some("create table emp (id int primary key, mgr int references emp)".to_string()),
))
.expect("create self-referential emp with a bare reference");
let emp = r.block_on(db.describe_table("emp".to_string(), None)).expect("describe");
let emp = r.block_on(db.describe_table("emp".to_string())).expect("describe");
assert_eq!(emp.outbound_relationships[0].other_columns, vec!["id".to_string()], "bare self-ref resolved to own PK");
// Enforced: a non-existent manager is rejected.
r.block_on(db.insert(
+4 -17
View File
@@ -154,7 +154,7 @@ fn delete_without_where_runs_across_all_rows() {
let csv = read_csv(&project, "t").unwrap_or_default();
assert!(!csv.contains('a') && !csv.contains('b') && !csv.contains('c'), "no rows left: {csv:?}");
let remaining = rt
.block_on(db.query_data("t".to_string(), None, None, None))
.block_on(db.query_data("t".to_string(), None, None))
.expect("query t");
assert!(remaining.rows.is_empty(), "table empty after unfiltered delete");
}
@@ -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
@@ -315,8 +302,8 @@ fn cascade_to_two_children_reports_both() {
assert_eq!(by_child.get("Orders"), Some(&2), "two orders cascaded");
assert_eq!(by_child.get("Reviews"), Some(&1), "one review cascaded");
// Both child CSVs re-persisted to the post-cascade (empty) state.
let orders = rt.block_on(db.query_data("Orders".to_string(), None, None, None)).unwrap();
let reviews = rt.block_on(db.query_data("Reviews".to_string(), None, None, None)).unwrap();
let orders = rt.block_on(db.query_data("Orders".to_string(), None, None)).unwrap();
let reviews = rt.block_on(db.query_data("Reviews".to_string(), None, None)).unwrap();
assert!(orders.rows.is_empty() && reviews.rows.is_empty(), "both children emptied");
let _ = &project;
}
@@ -374,7 +361,7 @@ fn delete_violating_fk_fails_and_persists_nothing() {
let result = run_delete(&db, &rt, input);
assert!(result.is_err(), "delete of a referenced parent must be rejected");
// Rolled back: Alice survives.
let customers = rt.block_on(db.query_data("Customers".to_string(), None, None, None)).unwrap();
let customers = rt.block_on(db.query_data("Customers".to_string(), None, None)).unwrap();
assert_eq!(customers.rows.len(), 1, "parent row preserved after rejected delete");
// No history line for the failed statement (written only on success).
let history = std::fs::read_to_string(project.path().join("history.log")).unwrap_or_default();
+1 -1
View File
@@ -149,7 +149,7 @@ fn seed(db: &Database, rt: &tokio::runtime::Runtime, sql: &str) {
}
fn query(db: &Database, rt: &tokio::runtime::Runtime, table: &str) -> Vec<Vec<Option<String>>> {
rt.block_on(db.query_data(table.to_string(), None, None, None))
rt.block_on(db.query_data(table.to_string(), None, None))
.unwrap_or_else(|e| panic!("query_data {table}: {e:?}"))
.rows
}
+4 -4
View File
@@ -55,7 +55,7 @@ fn make_t_with_index(db: &Database, r: &tokio::runtime::Runtime) -> String {
}
fn index_names(db: &Database, r: &tokio::runtime::Runtime) -> Vec<String> {
r.block_on(db.describe_table("T".to_string(), None))
r.block_on(db.describe_table("T".to_string()))
.expect("describe")
.indexes
.into_iter()
@@ -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]
+4 -4
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]
@@ -150,7 +150,7 @@ fn drop_table_is_one_undo_step_and_restores_data() {
assert!(r.block_on(db.undo()).expect("undo").is_some(), "the drop was one undo step");
assert!(r.block_on(db.list_tables()).unwrap().contains(&"T".to_string()));
let data = r
.block_on(db.query_data("T".to_string(), None, None, None))
.block_on(db.query_data("T".to_string(), None, None))
.expect("query");
assert_eq!(data.rows.len(), 1, "the dropped row was restored by undo");
}
-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
+10 -36
View File
@@ -215,7 +215,7 @@ fn decimal_aggregation_display_trims_ieee754_noise() {
// The reported case: the aggregate no longer leaks float noise.
let agg = rt
.block_on(db.run_select("select sum(price * qty) from Products".to_string(), None))
.block_on(db.run_select("select sum(price * qty) from Products".to_string()))
.expect("aggregate select");
assert_eq!(
agg.rows[0][0].as_deref(),
@@ -226,7 +226,7 @@ fn decimal_aggregation_display_trims_ieee754_noise() {
// Raw decimal column is still exact — TEXT storage preserves
// the input string verbatim, including the trailing zero.
let raw = rt
.block_on(db.run_select("select price from Products".to_string(), None))
.block_on(db.run_select("select price from Products".to_string()))
.expect("raw decimal select");
let prices: Vec<&str> = raw.rows.iter().map(|r| r[0].as_deref().unwrap()).collect();
assert_eq!(
@@ -240,10 +240,7 @@ fn decimal_aggregation_display_trims_ieee754_noise() {
fn database_run_select_constant_returns_a_single_row() {
let (_p, db, _dir) = open_project_db();
let data = rt()
.block_on(db.run_select(
"select 1".to_string(),
Some("select 1".to_string()),
))
.block_on(db.run_select("select 1".to_string()))
.expect("`select 1` runs clean");
assert_eq!(data.rows.len(), 1, "one result row");
assert_eq!(data.rows[0].len(), 1, "one column");
@@ -288,7 +285,7 @@ fn database_run_select_from_user_table_returns_inserted_rows() {
.expect("insert row");
});
let data = rt
.block_on(db.run_select("select Name from T".to_string(), None))
.block_on(db.run_select("select Name from T".to_string()))
.expect("SELECT runs");
assert_eq!(data.rows.len(), 1);
assert_eq!(data.rows[0][0].as_deref(), Some("Ada"));
@@ -336,7 +333,7 @@ fn database_run_select_recovers_bool_column_type() {
.expect("insert row");
});
let data = rt
.block_on(db.run_select("select Active from Products".to_string(), None))
.block_on(db.run_select("select Active from Products".to_string()))
.expect("SELECT runs");
assert_eq!(data.rows.len(), 2);
assert_eq!(data.column_types, vec![Some(Type::Bool)]);
@@ -374,7 +371,7 @@ fn database_run_select_recovers_text_type_through_alias() {
// playground type is recovered.
let data = rt
.block_on(
db.run_select("select Name as n from Users".to_string(), None),
db.run_select("select Name as n from Users".to_string()),
)
.expect("SELECT runs");
assert_eq!(data.columns, vec!["n".to_string()]);
@@ -402,7 +399,7 @@ fn database_run_select_computed_expression_stays_typeless() {
.expect("insert");
});
let data = rt
.block_on(db.run_select("select Score + 1 from T".to_string(), None))
.block_on(db.run_select("select Score + 1 from T".to_string()))
.expect("SELECT runs");
assert_eq!(data.column_types, vec![None]);
}
@@ -439,7 +436,6 @@ fn engine_aggregate_in_where_routes_through_catalog() {
let err = rt
.block_on(db.run_select(
"select id from T where count(score) > 0".to_string(),
None,
))
.expect_err("engine should reject aggregate in WHERE");
let DbError::Sqlite { .. } = &err else {
@@ -512,7 +508,6 @@ fn engine_group_by_missing_routes_through_catalog() {
let _ = rt
.block_on(db.run_select(
"select category, count(*) from T group by category".to_string(),
None,
))
.expect("benign GROUP BY query runs");
// Direct unit test on the matcher: ensure a message that
@@ -574,7 +569,6 @@ fn engine_scalar_subquery_too_many_rows_routes_through_catalog() {
let _ = rt
.block_on(db.run_select(
"select (select v from T) from T".to_string(),
None,
))
.expect("benign scalar subquery query runs");
let synthetic = DbError::Sqlite {
@@ -624,13 +618,13 @@ fn database_run_select_type_recovery_works_on_empty_table() {
});
// No INSERT — the table is empty.
let data_text = rt
.block_on(db.run_select("select col_text from Empty".to_string(), None))
.block_on(db.run_select("select col_text from Empty".to_string()))
.expect("SELECT runs even on empty table");
assert!(data_text.rows.is_empty());
assert_eq!(data_text.column_types, vec![Some(Type::Text)]);
let data_blob = rt
.block_on(db.run_select("select col_blob from Empty".to_string(), None))
.block_on(db.run_select("select col_blob from Empty".to_string()))
.expect("SELECT runs even on empty table");
assert!(data_blob.rows.is_empty());
assert_eq!(
@@ -723,7 +717,7 @@ fn database_run_select_recovers_all_ten_playground_types() {
for (col, expected_type) in cases {
let sql = format!("select {col} from AllTypes");
let data = rt
.block_on(db.run_select(sql.clone(), None))
.block_on(db.run_select(sql.clone()))
.expect("SELECT runs");
assert_eq!(
data.column_types,
@@ -732,23 +726,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:?}",
);
}
+1 -14
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
// =================================================================
@@ -514,7 +501,7 @@ fn update_all_rows_flag_in_advanced_updates_every_row() {
"the --all-rows update replays through the DSL fall-back; events: {events:?}"
);
let rows = rt
.block_on(db.query_data("t".to_string(), None, None, None))
.block_on(db.query_data("t".to_string(), None, None))
.expect("query")
.rows;
assert_eq!(rows.len(), 2, "both rows present");
+3 -3
View File
@@ -63,7 +63,7 @@ async fn insert_named(db: &Database, name: &str) {
}
async fn row_count(db: &Database) -> usize {
db.query_data("Customers".to_string(), None, None, None)
db.query_data("Customers".to_string(), None, None)
.await
.unwrap()
.rows
@@ -306,7 +306,7 @@ async fn sql_delete(db: &Database, input: &str) {
}
async fn count_t(db: &Database) -> usize {
db.query_data("T".to_string(), None, None, None)
db.query_data("T".to_string(), None, None)
.await
.unwrap()
.rows
@@ -378,7 +378,7 @@ fn undo_restores_db_and_csv_consistently() {
// Both the database read model and the on-disk CSV are
// restored — the (db, csv) pair stays consistent.
assert_eq!(
db.query_data("T".to_string(), None, None, None)
db.query_data("T".to_string(), None, None)
.await
.unwrap()
.rows
+38 -14
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}");
}
// ---------------------------------------------------------------
@@ -494,7 +503,12 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() {
}
#[test]
fn add_relationship_flow_shows_inbound_section_on_parent() {
fn add_column_confirmation_omits_relationship_prose() {
// ADR-0050 (issue #28): an incidental-DDL confirmation echo (here
// `add column`) renders the structure only — never the
// `References:` / `Referenced by:` relationship block — even when
// the table carries relationships the user did not touch. The
// relationships remain one `show table` away.
let mut app = App::new();
let customers = TableDescription {
name: "Customers".to_string(),
@@ -535,8 +549,17 @@ fn add_relationship_flow_shows_inbound_section_on_parent() {
echo: None,
});
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!(rendered.contains("Referenced by:"), "{rendered}");
assert!(rendered.contains("Orders.CustId → Id"), "{rendered}");
// The structure box still renders (table name + the column box from
// the returned description).
assert!(rendered.contains("Customers"), "structure header:\n{rendered}");
assert!(rendered.contains("Constraints"), "structure box:\n{rendered}");
// The relationship block is gone — neither prose heading nor line.
assert!(!rendered.contains("Referenced by:"), "no prose heading:\n{rendered}");
assert!(!rendered.contains("References:"), "no prose heading:\n{rendered}");
assert!(
!rendered.contains("Orders.CustId → Id"),
"no prose line:\n{rendered}",
);
}
#[test]
@@ -638,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!(
+1
View File
@@ -250,6 +250,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
App(app) => match app {
AppCommand::Quit => "App(Quit)".into(),
AppCommand::Help { .. } => "App(Help)".into(),
AppCommand::Hint => "App(Hint)".into(),
AppCommand::Rebuild => "App(Rebuild)".into(),
AppCommand::Save => "App(Save)".into(),
AppCommand::SaveAs => "App(SaveAs)".into(),