diff --git a/docs/adr/0015-project-storage-runtime.md b/docs/adr/0015-project-storage-runtime.md index 7730fc5..ddadd54 100644 --- a/docs/adr/0015-project-storage-runtime.md +++ b/docs/adr/0015-project-storage-runtime.md @@ -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/.csv` file(s), and `history.log`. INV-2 from the diff --git a/docs/adr/0016-pretty-table-rendering.md b/docs/adr/0016-pretty-table-rendering.md index 3a2b3d8..26dfc10 100644 --- a/docs/adr/0016-pretty-table-rendering.md +++ b/docs/adr/0016-pretty-table-rendering.md @@ -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 diff --git a/docs/adr/0022-ambient-typing-assistance.md b/docs/adr/0022-ambient-typing-assistance.md index 254b45e..1150d34 100644 --- a/docs/adr/0022-ambient-typing-assistance.md +++ b/docs/adr/0022-ambient-typing-assistance.md @@ -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
▮` 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
` 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 diff --git a/docs/adr/0034-history-journal-and-replay-filter.md b/docs/adr/0034-history-journal-and-replay-filter.md index 744c97a..ff2ef63 100644 --- a/docs/adr/0034-history-journal-and-replay-filter.md +++ b/docs/adr/0034-history-journal-and-replay-filter.md @@ -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 diff --git a/docs/adr/0040-completion-marker-replaces-ok-summary.md b/docs/adr/0040-completion-marker-replaces-ok-summary.md index bc7cd3a..b90b4e6 100644 --- a/docs/adr/0040-completion-marker-replaces-ok-summary.md +++ b/docs/adr/0040-completion-marker-replaces-ok-summary.md @@ -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 diff --git a/docs/adr/0044-relationship-visualization.md b/docs/adr/0044-relationship-visualization.md index 1210646..dfbb203 100644 --- a/docs/adr/0044-relationship-visualization.md +++ b/docs/adr/0044-relationship-visualization.md @@ -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 diff --git a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md index 3305a11..a0573ad 100644 --- a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md +++ b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md @@ -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. diff --git a/docs/adr/0048-seed-fake-data-generation.md b/docs/adr/0048-seed-fake-data-generation.md index 9dfd1ed..b3cbe83 100644 --- a/docs/adr/0048-seed-fake-data-generation.md +++ b/docs/adr/0048-seed-fake-data-generation.md @@ -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` | 18–80 · small int | numeric | +| `year`/`*_year`/`published`/`founded` (Amendment 1) | bounded year (birth window for `birth`/`born`/`dob`, else 1950–2025) | 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 (18–80 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 **1950–2025** (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 (**1945–2007**). + +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 +between and `. + +### 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. diff --git a/docs/adr/0049-input-field-readline-keymap.md b/docs/adr/0049-input-field-readline-keymap.md new file mode 100644 index 0000000..841df2b --- /dev/null +++ b/docs/adr/0049-input-field-readline-keymap.md @@ -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. diff --git a/docs/adr/0050-incidental-ddl-confirmations-omit-relationships.md b/docs/adr/0050-incidental-ddl-confirmations-omit-relationships.md new file mode 100644 index 0000000..c67e6fc --- /dev/null +++ b/docs/adr/0050-incidental-ddl-confirmations-omit-relationships.md @@ -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 `, + `add 1:n relationship`, `drop relationship`, `show relationship ` + — 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 ` 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 ` 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. diff --git a/docs/adr/0051-context-state-aware-keybinding-strip.md b/docs/adr/0051-context-state-aware-keybinding-strip.md new file mode 100644 index 0000000..bc50aab --- /dev/null +++ b/docs/adr/0051-context-state-aware-keybinding-strip.md @@ -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` +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. diff --git a/docs/adr/0052-mode-tagged-history-cross-mode-recall.md b/docs/adr/0052-mode-tagged-history-cross-mode-recall.md new file mode 100644 index 0000000..5efdb2a --- /dev/null +++ b/docs/adr/0052-mode-tagged-history-cross-mode-recall.md @@ -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` — 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 `||` +(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`, 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`; `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 + `, 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). diff --git a/docs/adr/0053-contextual-hint-command-and-keybinding.md b/docs/adr/0053-contextual-hint-command-and-keybinding.md new file mode 100644 index 0000000..7bac135 --- /dev/null +++ b/docs/adr/0053-contextual-hint-command-and-keybinding.md @@ -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 ` (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.` 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.`** — 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.` + namespace can be surfaced when the cursor sits in a recognized clause, + layered on top of the per-form block. +- **`hint.err.`** — 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.` 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`** — 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.` 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 **2–3 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 ` 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 `** — OOS (rejected): `help ` 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.` 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.`** — 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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 2e15522..aaa1e8a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -27,7 +27,7 @@ This directory contains the project's ADRs, recorded per - [ADR-0019 — Friendly error layer (H1) and i18n message catalog](0019-friendly-error-layer-and-i18n.md) - [ADR-0020 — Tokenization layer for the DSL parser](0020-tokenization-layer-for-the-dsl-parser.md) — **Superseded by ADR-0024 (never implemented).** Specified a `chumsky`-over-tokens architecture (separate lexer, `define_keywords!`, `&[Token]` grammar). ADR-0024 adopted a scannerless hand-rolled walker and removed `chumsky` entirely; the lexer/keyword/token model here does not exist. Kept as institutional memory of the path not taken. - [ADR-0021 — Parser-as-source-of-truth for H1a (per-command usage in parse errors)](0021-parser-as-source-of-truth-for-h1a.md) — **Mechanism superseded by ADR-0024; H1a scope continued in ADR-0042.** The *intent* (show the command's grammar at the point of error) shipped — `usage_ids` on each `CommandNode`, the `parse.usage.*` templates, and the `available_commands` fallback all exist — but via grammar nodes, not the `chumsky` `UsageEntry` registry / `parse.token.*` keys described here (which were never built). -- [ADR-0022 — Ambient typing assistance: colour, hint panel, completion (I3 + I4)](0022-ambient-typing-assistance.md) — **Amendment 1 supersedes §12's simple-mode-only carve-out**: the unified mode-aware walker (ADR-0030/0031/0032) now speaks SQL, so advanced-mode ambient assistance is re-enabled. `ambient_hint_in_mode` + `hint_resolution_at_input_in_mode` + `expected_for_hint_snapshot` thread `Mode`; `render_hint_panel` calls ambient for all modes (no more advanced-mode `None`); the one-shot `:` sigil is stripped before the ambient walk. Fixes a live bug where advanced-mode SQL hinting/completion-preview were dead despite Phase 2 marking them green (validated at the engine layer, not the UI). Simple-mode gating, highlighting, and the §13 performance posture are unchanged; covered by an app-level render test plus ambient-layer regression locks; **Amendment 2 reverses the handoff-14 keywords-first candidate ordering** — schema identifiers (table/column/relationship names) now sort *before* keywords so a name the user would have to look up stays visible in the single-row, window-scrolled candidate line (keywords are learned over time; the `tok_identifier`/`tok_keyword` colour split marks the boundary); shipped with a `walk_repeated` fix that surfaces a list item's trailing optionals at a clean boundary (`order by Name ` → `asc`/`desc`, `select Name ` → `as`, `create table … Code(text) ` → `not`/`unique`/`default`/`check`; the `,` separator deliberately not surfaced); records a deferred two-line hint box for growing lists; **Amendment 3 makes the ambient-hint fallback rung schema-aware** — Amendment 1's bottom-rung `parse_command_in_mode` was schemaless while every earlier rung was not, so between-values insert hints pointed at `)` (type-blind close) instead of `,` and wrong-arity closed tuples read "submit with Enter" for an input the schema-aware parse rejects (issue #2); now uses `parse_command_with_schema_in_mode`, no extra walk, with the friendly arity diagnostic still winning at its higher rung; **Amendment 4 gives column types a dedicated highlight class** — both `Node::Ident.highlight_override` *and* the `Word.highlight_override` field were dead (driver destructured the former to `_`, `walk_word` hardcoded `Keyword`); now both wired through, with a new `HighlightClass::Type` + eighth `Theme` field `tok_type` (a pink/deep-magenta distinct from both keyword purple and identifier teal) so types no longer render identically to identifiers (issue #8); the three `IdentSource::Types` slots opt in via `Some(Type)` (advanced-mode single-word SQL aliases — `float`, `varchar`, … per ADR-0035 §3 — ride along for free), and the two-word `double precision` alias opts in via the new `Word::type_keyword` constructor so it matches its synonyms; **Amendment 5 lets the hint panel grow for long prose hints** — a fixed one-row panel clipped long field-value/usage hints past the first line (issue #12); `resolve_hint_lines` now pre-wraps prose and `render_right_column` sizes the panel to the line count (1 row default, up to `MAX_HINT_ROWS`=3, reclaimed when short) with a `clamp_wrapped` ellipsis backstop; the candidate list still scrolls horizontally on one row (Amendment 2's deferred two-line candidate box stays deferred); also shortens the 299-char `parse.usage.sql_create_table` synopsis to a terse one-liner (full grammar remains in `help.ddl.sql_create_table`); **Amendment 6 adds a curated SQL function-name list** (`src/dsl/sql_functions.rs`, `KNOWN_SQL_FUNCTIONS` — aggregates + common + broader scalars; `cast` deliberately excluded as its `CAST(x AS type)` syntax isn't a plain-call shape) as the single source of truth shared by two consumers at the `sql_expr_ident` slot (ADR-0031 §1): **issue #15** offers the functions as Tab candidates under a new `CandidateKind::Function` + ninth `Theme` colour `tok_function` (a blue distinct from keyword/identifier/type, parallel to Amendment 4's `tok_type`) so a learner discovers `sum`/`upper`/…; **issue #16** restores the typing-time column-typo flag the issue-#6 fix had dropped wholesale at this slot — `invalid_ident_at_cursor` now bails only when the partial prefix-matches a known function, else falls through to the schema-column check, so `select Agx` warns again at typing time while `select sum` does not (the issue-#6 lockdown tests + the submit-time `unknown_column` diagnostic path are untouched, and the no-validation-allowlist posture stands); see ADR-0031's status note for the grammar-side anchor +- [ADR-0022 — Ambient typing assistance: colour, hint panel, completion (I3 + I4)](0022-ambient-typing-assistance.md) — **Amendment 1 supersedes §12's simple-mode-only carve-out**: the unified mode-aware walker (ADR-0030/0031/0032) now speaks SQL, so advanced-mode ambient assistance is re-enabled. `ambient_hint_in_mode` + `hint_resolution_at_input_in_mode` + `expected_for_hint_snapshot` thread `Mode`; `render_hint_panel` calls ambient for all modes (no more advanced-mode `None`); the one-shot `:` sigil is stripped before the ambient walk. Fixes a live bug where advanced-mode SQL hinting/completion-preview were dead despite Phase 2 marking them green (validated at the engine layer, not the UI). Simple-mode gating, highlighting, and the §13 performance posture are unchanged; covered by an app-level render test plus ambient-layer regression locks; **Amendment 2 reverses the handoff-14 keywords-first candidate ordering** — schema identifiers (table/column/relationship names) now sort *before* keywords so a name the user would have to look up stays visible in the single-row, window-scrolled candidate line (keywords are learned over time; the `tok_identifier`/`tok_keyword` colour split marks the boundary); shipped with a `walk_repeated` fix that surfaces a list item's trailing optionals at a clean boundary (`order by Name ` → `asc`/`desc`, `select Name ` → `as`, `create table … Code(text) ` → `not`/`unique`/`default`/`check`; the `,` separator deliberately not surfaced); records a deferred two-line hint box for growing lists; **Amendment 3 makes the ambient-hint fallback rung schema-aware** — Amendment 1's bottom-rung `parse_command_in_mode` was schemaless while every earlier rung was not, so between-values insert hints pointed at `)` (type-blind close) instead of `,` and wrong-arity closed tuples read "submit with Enter" for an input the schema-aware parse rejects (issue #2); now uses `parse_command_with_schema_in_mode`, no extra walk, with the friendly arity diagnostic still winning at its higher rung; **Amendment 4 gives column types a dedicated highlight class** — both `Node::Ident.highlight_override` *and* the `Word.highlight_override` field were dead (driver destructured the former to `_`, `walk_word` hardcoded `Keyword`); now both wired through, with a new `HighlightClass::Type` + eighth `Theme` field `tok_type` (a pink/deep-magenta distinct from both keyword purple and identifier teal) so types no longer render identically to identifiers (issue #8); the three `IdentSource::Types` slots opt in via `Some(Type)` (advanced-mode single-word SQL aliases — `float`, `varchar`, … per ADR-0035 §3 — ride along for free), and the two-word `double precision` alias opts in via the new `Word::type_keyword` constructor so it matches its synonyms; **Amendment 5 lets the hint panel grow for long prose hints** — a fixed one-row panel clipped long field-value/usage hints past the first line (issue #12); `resolve_hint_lines` now pre-wraps prose and `render_right_column` sizes the panel to the line count (1 row default, up to `MAX_HINT_ROWS`=3, reclaimed when short) with a `clamp_wrapped` ellipsis backstop; the candidate list still scrolls horizontally on one row (Amendment 2's deferred two-line candidate box stays deferred); also shortens the 299-char `parse.usage.sql_create_table` synopsis to a terse one-liner (full grammar remains in `help.ddl.sql_create_table`); **Amendment 6 adds a curated SQL function-name list** (`src/dsl/sql_functions.rs`, `KNOWN_SQL_FUNCTIONS` — aggregates + common + broader scalars; `cast` deliberately excluded as its `CAST(x AS type)` syntax isn't a plain-call shape) as the single source of truth shared by two consumers at the `sql_expr_ident` slot (ADR-0031 §1): **issue #15** offers the functions as Tab candidates under a new `CandidateKind::Function` + ninth `Theme` colour `tok_function` (a blue distinct from keyword/identifier/type, parallel to Amendment 4's `tok_type`) so a learner discovers `sum`/`upper`/…; **issue #16** restores the typing-time column-typo flag the issue-#6 fix had dropped wholesale at this slot — `invalid_ident_at_cursor` now bails only when the partial prefix-matches a known function, else falls through to the schema-column check, so `select Agx` warns again at typing time while `select sum` does not (the issue-#6 lockdown tests + the submit-time `unknown_column` diagnostic path are untouched, and the no-validation-allowlist posture stands); see ADR-0031's status note for the grammar-side anchor; **Amendment 7 surfaces optional positional args in the hint panel** (issue #26): at `seed
▮` the optional row count (a bare `NumberLit` with no candidate) was invisible next to the `set`/`--seed` chips, and the resolver short-circuits on the already-complete command. Extends the issue-#4 `IntroProse` `HintMode` (ADR-0024 §HintMode-per-node) to survive trailing optionals: `walk_optional` stashes a skipped inner's `IntroProse` key into a new `WalkContext.surviving_intro_hint` (key + position) before the empty match clears `pending_hint_mode`, and the snapshot keeps it only when the skip position is the cursor (so it never leaks past a later-consumed `set …` clause or once the count is given); the resolver returns it ahead of the empty-expected short-circuit. The seed count is wrapped `Hinted{IntroProse("hint.seed_count")}`; prose names the count (default 20), the `.column` column-fill form, and `set`/`--seed` (user-chosen scope). Only `IntroProse` is carried; `ProseOnly`/`ForceProse` and the CREATE-TABLE element (a required `Repeated`) are untouched; no `AmbientHint`/renderer change - [ADR-0023 — Unified declarative grammar tree](0023-unified-grammar-tree.md) — direction (superseded for execution detail by ADR-0024) - [ADR-0024 — Unified grammar tree: execution plan](0024-unified-grammar-tree-execution-plan.md) — **Accepted**, the executable spec — implemented (Phases A–F; Phase F shipped "minimal", `parser.rs` retained as the router — see the ADR's Phase F implementation note) - [ADR-0025 — Indexes](0025-indexes.md) — **Accepted** (**Amendment 1, 2026-05-25**: UNIQUE indexes admitted on the **advanced-mode** surface via `CREATE UNIQUE INDEX` — ADR-0035 §4d; the `IndexSchema.unique` flag round-trips through `project.yaml` with no new metadata table since the engine reports uniqueness natively; simple-mode `add unique index` stays deferred), `add index` / `drop index`, persistence, rebuild-table preservation, and items-list display (`C3` index portion + `S2`) @@ -51,6 +51,11 @@ This directory contains the project's ADRs, recorded per - [ADR-0043 — Compound-primary-key foreign-key references (T3)](0043-compound-pk-foreign-key-references.md) — **Accepted + implemented 2026-06-09** (all four forks confirmed at the recommended option: full-PK matching, house-style uniform lists, parenthesized DSL syntax, bare-SQL-FK auto-expansion). Closes `requirements.md` **T3** `[x]` — the relationship model went list-based across six layers (single-column preserved, no migration), DSL `from P.(a,b) to C.(x,y)` + SQL `FOREIGN KEY (a,b) REFERENCES P(x,y)` parse/execute/enforce, 12 tests in `tests/it/compound_fk.rs`. Closes the open leg of `requirements.md` **T3**: a foreign key that *references* a parent's compound primary key. A 2026-06-09 audit found single-column FK woven through ~15–20 sites (metadata table, `RelationshipSchema`, `project.yaml` `RawEndpoint`, both grammar surfaces, executor FK-DDL emission, per-column type-compat, display) — earns an ADR, not an inline build. **Decision:** reference the parent's **full** compound PK, matched **positionally** to an equal-length child column list, per-pair `fk_target_type` compat (ADR-0011, element-wise); DSL `from

.(a, b) to .(x, y)` (single form unchanged), SQL `FOREIGN KEY (x, y) REFERENCES P(a, b)` (extend the existing one-cap lists; bare table-level FK auto-expands to the parent PK when arities match). **Storage — no migration (back-compat not required, user-confirmed 2026-06-09; no installed base):** the relationship endpoint joins the list convention `project.yaml` *already* uses — `columns: [a, b]` like `primary_key: [id]` and index `columns: [...]` (the endpoint was the lone scalar `column:` holdout); the metadata `TEXT` columns are unchanged and store the list **comma-joined** (`a,b`; the bare name for single — safe because identifiers are `[A-Za-z0-9_]+`). No F3 migrator, no version bump; accepted trade-off is that a pre-change `project.yaml` with relationships won't load (clean cutover). In-memory model goes list-based (`Vec`) through all six layers; the enforced FK is the rebuilt child-table DDL (`FOREIGN KEY (a,b) REFERENCES P(x,y)`), one relationship = one undo step (ADR-0013). Genuine forks escalated: matching policy (full-PK vs subset), storage (house-style uniform lists vs normalized table), DSL syntax (parenthesized vs repeated-dotted), bare-SQL-FK auto-expansion. OOS: subset/non-PK (UNIQUE-targeted) FK references; any single-column behaviour change - [ADR-0044 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09; implemented 2026-06-10** (closes `requirements.md` V1; second `/runda` pass over the implementation; §3 last-resort helper line considered and rejected). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject* — `show relationship ` (one full diagram), `show table ` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5) - [ADR-0045 — `create m:n relationship` convenience command (C4)](0045-mn-convenience.md) — **Accepted + implemented 2026-06-10** (closes `requirements.md` **C4**; all forks user-confirmed + a `/runda` DA pass that verified the `do_create_table` reuse against code and corrected the "no PK-less tables" assumption — advanced SQL `create table t (a int)` has none, so a parent-PK guard is retained). Implementation corrected a second ADR premise: "the walker already dispatches multiple nodes per entry word" held only in *advanced* mode — two simple-mode spots (dispatcher `decide`, completion continuation-merge) assumed ≤1 DSL form per entry word and were generalized **behaviour-preservingly** (dispatch reduces to the old single-candidate commit; completion merge gated on `simple_count > 1`). Junction echo wired (`render_create_m2n`, round-trips as SQL). `create m:n relationship from to [as ]` generates a junction table with one FK column per parent PK column, a **compound PK over all the FK columns** (the textbook junction — the pair is unique, no duplicate links), and **two 1:n relationships**, all in **one transaction = one undo step** (built by reusing `do_create_table`, which already takes `foreign_keys` + writes relationship metadata — no batch bracketing). Forks all user-chosen: junction PK = compound-over-FKs (vs surrogate serial / no PK); referential actions = **`CASCADE`** on delete+update (vs NO ACTION / RESTRICT); naming = auto `{T1}_{T2}` + optional `as` (vs auto-only); available in **both modes** (Simple-category DSL, like the sibling relationship commands). FK columns named `{parent_table}_{pk_column}` (disambiguates shared `id`; generalises to compound parents via ADR-0043), typed via `fk_target_type` (ADR-0011). A distinct `Command::CreateM2nRelationship` (not lowered to `CreateTable`) preserves command identity (X5) and lets the teaching echo speak in m:n terms. Cross-cutting wiring enumerated: separate `CREATE_M2N` `CommandNode` (own `help_id`/`usage_ids`), `("m","m:n")` completion composite, `HintMode`s, grammar-driven highlighting, `help`/`help create`, `parse_error_pedagogy` near-miss matrix, teaching echo. OOS: **self-referential m:n** (`from T to T`) refused outright (user-confirmed "full stop" — directional column-naming is more than this beginner convenience warrants); per-relationship action overrides; extra junction payload columns; m:n diagram echo; renaming the auto-generated relationships -- [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted + implemented 2026-06-10, phased A→B→C** (8 commits `9f5f76b`…`22bec61`; closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Two decisions landed differently from the draft (recorded inline): relationship data on **`App`** not `SchemaCache` (DB2); the nav overlay clears **only the sidebar strip + a one-column gutter**, panels staying visible behind (DC2). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). the full records live on a new **`App.relationships`** field (revised from the ADR's original `SchemaCache.relationship_details` at implementation — `SchemaCache` is walker-facing and needs only the names, kept in `relationships: Vec`; details are UI-only, so `App` mirrors `app.tables` and avoids ~23 fixture edits), delivered by `Database::read_all_relationships` + an `AppEvent::RelationshipsRefreshed`; the two left panels split vertically with the relationships panel floored at 5 rows ("(none)" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears) +- [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted + implemented 2026-06-10, phased A→B→C** (8 commits `9f5f76b`…`22bec61`; closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Two decisions landed differently from the draft (recorded inline): relationship data on **`App`** not `SchemaCache` (DB2); the nav overlay clears **only the sidebar strip + a one-column gutter**, panels staying visible behind (DC2). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). the full records live on a new **`App.relationships`** field (revised from the ADR's original `SchemaCache.relationship_details` at implementation — `SchemaCache` is walker-facing and needs only the names, kept in `relationships: Vec`; details are UI-only, so `App` mirrors `app.tables` and avoids ~23 fixture edits), delivered by `Database::read_all_relationships` + an `AppEvent::RelationshipsRefreshed`; the two left panels split vertically with the relationships panel floored at 5 rows ("(none)" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears); **Amendment 1, 2026-06-12** (issue #25): DC3's focus accent is now a **non-bold accent colour** (`theme.mode_simple`, blue) rather than bold bright-`fg` — bold box-drawing glyphs render as broken/gapped line-art in the asciinema cast player (and are fragile in some terminals), so `panel_border_style` carries no `Modifier::BOLD` on a border (bold stays fine on text spans); pure style change — the text-only Tier-2 snapshots were unaffected, the Tier-1 assertion was updated, and a render-level test now checks the focused border cells carry the accent and no bold - [ADR-0047 — Demonstration overlay layer (keystroke badges + step captions)](0047-demonstration-overlay-layer.md) — **Accepted 2026-06-10; implemented 2026-06-11, phased A→B→C (closes Gitea #22)** (commits `f879d54`→`2d0f4b2`; no `requirements.md` item — tracked by issue + ADR per convention; all forks user-confirmed + a pre-build `/runda` pass that produced 10 tightening findings and a whole-implementation `/runda` pass that returned PASS, no blockers). An in-app **demonstration mode** (`--demo` flag / `RDBMS_PLAYGROUND_DEMO` env, **off by default, zero footprint when off**) that renders two transient overlays so `autocast` screencasts — and live teaching, and a future guided-lesson system — can show otherwise-invisible interactions. **Keystroke badges** (`[TAB]`, `[ENTER]`, `[UP]`, …): **automatic, app-detected** over a fixed set of glyph-less keys (the app already sees every key, so it re-records for free), label via a pure `demo_badge_label(&KeyEvent)`; the badge **auto-expires on a ~1.5 s timer** that extends the runtime's existing time-boxed-`recv` arm condition (`debounce.is_armed() || badge_pending`; expiry `Instant` in the runtime, `App.demo_badge` the render mirror — mirroring the `input` vs `input_indicator` split). **Step captions**: a **stealth, control-code-delimited input buffer** toggled by **`Ctrl+]`** (byte `0x1D` → arrives as `Char('5')+CONTROL`, verified against crossterm 0.29 `parse.rs:110-113`; chosen over `Ctrl+!`, which is **not a single ASCII byte so autocast cannot send it** — the same wall as arrow keys, R4) — typed characters accumulate **invisibly** (prompt untouched, no echo/history), `Backspace` edits, other keys inert, a second `Ctrl+]` **commits** to the caption box (empty commit dismisses); lives in pure-sync `App::update()`, **intercepted before the modal gate** so captions/badges work **over the load picker** (the `#24` projects cast). Both render as **floating flat black-on-yellow rectangles** (solid fill, **no border glyphs** — a one-cell text margin, deliberately unlike the app's bordered panels; user decision post-build, `2d0f4b2`) **at the output panel's inner bottom-right**, drawn **last over modals**, badge **stacked above** the caption, **no layout reflow**; caption **word-wraps to ≤ 3 lines** (3–5 rows), badge fixed 3 rows; clamp/skip guard for tiny terminals; a new **`App.last_output_area: Rect`** (set in `render_output_panel`) gives the top-level draw the anchor. Caption persists **until the next keystroke**; badge suppressed while capturing. Forks all user-chosen: `--demo` activation (vs hidden command / chord); automatic badges (vs scripted); stealth buffer (vs typed-command / preloaded-file); floating bottom-right boxes (vs HUD / banner / subtitle); `Ctrl+]` trigger; wrap-to-3-line captions; ~1.5 s badge / next-keystroke caption timing. Tested test-first across Tier 1 (label fn, capture state machine incl. over-modal + demo-off gate, nearest-deadline helper), Tier 2 (insta snapshots: badge/caption/both-stacked at 90×26 light+dark, short-terminal clamp), Tier 3 (`--demo` plumbing, badge set/suppressed, caption-without-input wiring), CLI (`--demo` parse + env fallback) — with an **honest limit** noted: the `tokio` timer wiring inside `run_loop` is exercised via the pure pieces + Tier-3 plumbing, not a standalone integration test of the timeout (same posture as the existing `IndicatorDebounce`). One intentional, user-acknowledged behaviour: `Ctrl-C` is inert while capturing (every non-`Ctrl+]` key is, by spec). Final tally **2290 passing / 0 failing / 0 skipped** (1 long-standing ignored doctest), clippy clean. OOS: scripted/manual badge push; badges for glyph keys; configurable styling/placement; the guided-lesson system itself (own ADR); cross-session/-switch persistence; localised caption content; arrow-only cast interactions (output-pane scroll); wiring the overlays into the website `casts.mjs` scripts (website-branch follow-up). Implementation phased **A** (`--demo` plumbing) → **B** (badges) → **C** (captions) + a flat-rectangle restyle -- [ADR-0048 — `seed` fake-data generation command](0048-seed-fake-data-generation.md) — **Accepted 2026-06-11; Phase 1 + Phase 2 implemented 2026-06-11** (Phase 1 commits `202e25a`→`fbd219b`; design settled with the user across an extended fork dialogue, hardened by a pre-build `/runda` pass (six blockers folded in), a post-implementation `/runda` pass (eight gaps closed — FK/shortid determinism so **D4 holds with no exceptions**, plus six untested ADR decisions), and a Phase-2 pre-build `/runda` pass (which caught the no-date-literal-token reality → the D2 quoted-dates amendment), and a post-implementation `/runda` pass (which added a friendly error for a bounded override on a UNIQUE column — see D2); **2400 tests pass, clippy clean**). Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1**. **Phase 1 shipped:** whole-row `seed

[count] [--seed ]` with realistic name-aware generation (the `fake` crate + a type-gated heuristic catalogue, table-context name disambiguation, hand-rolled `product` generator, bounded dates), identifier + constraint uniqueness incl. junction distinct-combos, FK sampling from existing parent rows (empty-parent error), `IN`-CHECK derivation + complex-CHECK advisory, a required-column block guard, `--seed` reproducibility (serial/FK/shortid all deterministic), undo as one batch step, replay as a data write, a capped auto-show preview, the enum/CHECK advisory, and an O(N) single-transaction insert path. **Phase 2 shipped (2026-06-11):** the `set` override clause (D2 — fixed value / pick-list / `as ` / `between` range, **quoted** dates per the D2 amendment, type-aware, override drops the column from the advisory) and the `
.` column-fill form (D1 form 2 — an UPDATE over existing rows, refusing PK/autogen targets, empty-table no-op, FK/unique-respecting, one undo step), with the new `KNOWN_GENERATORS` vocabulary (D9), a range `Generator`, full completion/highlight (`HighlightClass::Function`)/validity (`IdentSource::Generators`)/help/pedagogy wiring, and the D13 advisory's Phase-2/3 wording. Further SD2 increments (custom generators, NULL injection, multi-locale, recursive auto-seed) out of scope. Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1** (the other being `hint`/**H2**). A dedicated `seed` command (own AST variant + `do_seed` executor, **both modes**) generating **realistic, name-aware** fake data. Two forms: **`seed
[count]`** (new rows, default **20**, capped) and **`seed
.`** (fill a column on existing rows, an UPDATE). Generation adds the **`fake` crate** (v5, English) driven by a **type-gated, token-matched name-heuristic catalogue** (~30 patterns, documented false-positive guards), with **table-context** disambiguating the `name`/`title` family (`products.name`→product, `users.name`→person, `vendors.name`→company), a **hand-rolled `product` generator** (`fake` has no commerce module), **bounded dates** (`date`/`timestamp`/`dob`/`*_at` recognised, recent windows — never "all of history"), the **identifier family** (`id`/`code`/`ref`/`number`, non-FK/non-PK) → **unique sequential**, and **enum-ish names** (`role`/`status`/`type`/…) left generic + a **post-seed Hint advisory** pointing at `set … in (…)`. A **`set` override clause** — `= value` / `in (a,b,c)` / `as ` / `between a and b` (numeric **and** date), reusing ADR-0026 operators — answers the heuristic-miss case. **`--seed `** makes runs reproducible (and enables exact-value tests). **FK** columns sampled uniformly from existing parent rows (**empty parent → friendly error**, no recursion v1); **junction/compound-PK** tables seeded with **distinct combinations**, capped + noted (SD1). A **required-column block guard** refuses rather than NULL-violate a `NOT NULL` column it can't fill (e.g. `NOT NULL blob`). Full ambient wiring (completion incl. a new generator-name vocabulary highlighted as `tok_function`, hints, `help seed`, ADR-0042 near-miss matrix, ADR-0027 validity); **no DSL→SQL teaching echo** (seed is a utility command, not a SQL twin). Honours **X5** — `do_seed` reuses insert/update *mechanics as helpers*, not by emitting `Command::Insert`. Implementation phased: (1) core whole-row seed → (2) `set` overrides → (3) column-fill. Deferred (future SD2): recursive auto-seed, NULL injection, multi-locale, user-defined custom generators, full per-column report +- [ADR-0048 — `seed` fake-data generation command](0048-seed-fake-data-generation.md) — **Accepted 2026-06-11; Phase 1 + Phase 2 implemented 2026-06-11** (Phase 1 commits `202e25a`→`fbd219b`; design settled with the user across an extended fork dialogue, hardened by a pre-build `/runda` pass (six blockers folded in), a post-implementation `/runda` pass (eight gaps closed — FK/shortid determinism so **D4 holds with no exceptions**, plus six untested ADR decisions), and a Phase-2 pre-build `/runda` pass (which caught the no-date-literal-token reality → the D2 quoted-dates amendment), and a post-implementation `/runda` pass (which added a friendly error for a bounded override on a UNIQUE column — see D2); **2400 tests pass, clippy clean**). Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1**. **Phase 1 shipped:** whole-row `seed
[count] [--seed ]` with realistic name-aware generation (the `fake` crate + a type-gated heuristic catalogue, table-context name disambiguation, hand-rolled `product` generator, bounded dates), identifier + constraint uniqueness incl. junction distinct-combos, FK sampling from existing parent rows (empty-parent error), `IN`-CHECK derivation + complex-CHECK advisory, a required-column block guard, `--seed` reproducibility (serial/FK/shortid all deterministic), undo as one batch step, replay as a data write, a capped auto-show preview, the enum/CHECK advisory, and an O(N) single-transaction insert path. **Phase 2 shipped (2026-06-11):** the `set` override clause (D2 — fixed value / pick-list / `as ` / `between` range, **quoted** dates per the D2 amendment, type-aware, override drops the column from the advisory) and the `
.` column-fill form (D1 form 2 — an UPDATE over existing rows, refusing PK/autogen targets, empty-table no-op, FK/unique-respecting, one undo step), with the new `KNOWN_GENERATORS` vocabulary (D9), a range `Generator`, full completion/highlight (`HighlightClass::Function`)/validity (`IdentSource::Generators`)/help/pedagogy wiring, and the D13 advisory's Phase-2/3 wording. Further SD2 increments (custom generators, NULL injection, multi-locale, recursive auto-seed) out of scope. Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1** (the other being `hint`/**H2**). A dedicated `seed` command (own AST variant + `do_seed` executor, **both modes**) generating **realistic, name-aware** fake data. Two forms: **`seed
[count]`** (new rows, default **20**, capped) and **`seed
.`** (fill a column on existing rows, an UPDATE). Generation adds the **`fake` crate** (v5, English) driven by a **type-gated, token-matched name-heuristic catalogue** (~30 patterns, documented false-positive guards), with **table-context** disambiguating the `name`/`title` family (`products.name`→product, `users.name`→person, `vendors.name`→company), a **hand-rolled `product` generator** (`fake` has no commerce module), **bounded dates** (`date`/`timestamp`/`dob`/`*_at` recognised, recent windows — never "all of history"), the **identifier family** (`id`/`code`/`ref`/`number`, non-FK/non-PK) → **unique sequential**, and **enum-ish names** (`role`/`status`/`type`/…) left generic + a **post-seed Hint advisory** pointing at `set … in (…)`. A **`set` override clause** — `= value` / `in (a,b,c)` / `as ` / `between a and b` (numeric **and** date), reusing ADR-0026 operators — answers the heuristic-miss case. **`--seed `** makes runs reproducible (and enables exact-value tests). **FK** columns sampled uniformly from existing parent rows (**empty parent → friendly error**, no recursion v1); **junction/compound-PK** tables seeded with **distinct combinations**, capped + noted (SD1). A **required-column block guard** refuses rather than NULL-violate a `NOT NULL` column it can't fill (e.g. `NOT NULL blob`). Full ambient wiring (completion incl. a new generator-name vocabulary highlighted as `tok_function`, hints, `help seed`, ADR-0042 near-miss matrix, ADR-0027 validity); **no DSL→SQL teaching echo** (seed is a utility command, not a SQL twin). Honours **X5** — `do_seed` reuses insert/update *mechanics as helpers*, not by emitting `Command::Insert`. Implementation phased: (1) core whole-row seed → (2) `set` overrides → (3) column-fill. Deferred (future SD2): recursive auto-seed, NULL injection, multi-locale, user-defined custom generators, full per-column report. **Amendment 1, 2026-06-12** (issues #33/#34): two additive D7 catalogue rules — **year-as-int** (`year`/`*_year`/`published`/`founded` → a bounded `int` year, 1950–2025, or the `dob`-style birth window 1945–2007 for `birth`/`born`/`dob`; fixes nonsense like `9419`; `int`-gated, after the quantity rule so `year_count` stays a count; two new `YearRecent`/`YearBirth` generators, *not* added to the D9 vocabulary) and **conventional choice sets** (`priority`/`prio`, `severity`, `rating`/`stars` → type-gated built-in `PickFrom` value sets reusing the existing generator; `priority` leaves `ENUM_TOKENS`). `status` is **deliberately excluded** (user-confirmed — values too domain-specific; keeps the D12 "don't guess" + advisory); a user `IN`-CHECK still wins. Website `seed` cast re-record tracked on the `website` branch +- [ADR-0049 — Input-field readline keymap: Esc-clear + Ctrl-A/E/W/K/U (I1b)](0049-input-field-readline-keymap.md) — **Accepted + implemented 2026-06-12 (issue #29)**, closes Gitea **#29** and the deferred **I1b** readline requirement. **Amends ADR-0046**, which listed "readline shortcuts (I1b)" as out-of-scope — that item is now in scope and decided here; orthogonal to ADR-0003's input-*mode* model and extends the I1a single-line cursor editing already shipped. Binds, in the input field (non-modal, non-nav, both modes): **`Esc`** clears a partly-typed command (empty buffer, cursor→0, scroll→0); **`Ctrl-A`/`Ctrl-E`** alias Home/End (line start/end); **`Ctrl-W`** deletes the previous word (readline-style — eats trailing whitespace then the preceding non-whitespace run, UTF-8-safe on char boundaries, only back to the cursor); **`Ctrl-K`** kills to end of line; **`Ctrl-U`** kills to start. **Esc precedence:** a live Tab-completion memo still wins (Esc undoes the completion first, ADR-0022; Esc clears only when no memo) — Esc-once backs out the completion, Esc-again clears. Forks all user-chosen: **single-Esc-clears** (not double-Esc — discoverable over accident-proof; an unsubmitted draft can be lost, a submitted line is always in history); the **full I1b set** (not just the issue's literal Ctrl-A/E + Esc); a **new ADR** (not an ADR-0046 amendment / no-ADR). Cursor-only keys (Ctrl-A/E) leave history navigation intact like Home/End; buffer-mutating keys (Esc-clear, Ctrl-W/K/U) end it like Backspace. Helpers `clear_input`/`delete_prev_word`/`kill_to_end`/`kill_to_start` in `src/app.rs`; **22 new Tier-1 tests, 2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. OOS: on-screen keybinding hints (issue #27 owns surfacing per-focus keybindings in the bottom status line — this ADR makes the keys *work*, #27 makes them *discoverable*); demo-mode badges for the new chords (ADR-0047 follow-up — Esc already badges `[ESC]`, the glyph-less Ctrl-chords are flagged but not added); multi-line input (I1); word-wise cursor motion (Alt-B/F) / transpose / yank +- [ADR-0050 — Incidental-DDL confirmations omit relationship info (structure-only)](0050-incidental-ddl-confirmations-omit-relationships.md) — **Accepted + implemented 2026-06-12 (issue #28)**, closes Gitea **#28**. **Supersedes** the incidental-DDL clause of **ADR-0044 §1** and the relationship-block half of **ADR-0016 §5**. Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/`rename`/`change column`, `add`/`drop index`) now render **structure only** — header + column box + `Indexes:` + constraints — with **no `References:` / `Referenced by:` block** (neither prose nor diagram), even when the table carries relationships the user did not touch. Rationale (owner): a confirmation echo reports the change just made, not untouched relationships; ADR-0044's terse prose was the lesser of "prose vs diagram", but the right answer for these surfaces is **neither**. **Relationship-subject surfaces are unchanged** — `show table`, `add`/`drop relationship`, `show relationship` still render ADR-0044 diagrams; relationships appear only when the user asks for (`show table`) or acts on (`add`/`drop relationship`) one, and are one `show table ` away — **no information lost**. Forks both user-chosen: **scope = all incidental DDL** (not just `add column` — the rationale is uniform, the mental model clean, and it's the simpler edit) and **delete the prose renderer** (not retain it dormant — no dead code). **Mechanism:** the `handle_dsl_success` `matches!` routing is unchanged (relationship-subject → diagrams; else → `render_structure`); the change is one line inside `render_structure` (`output_render.rs` — drop the relationship-block call) since all its callers are incidental DDL, plus deletion of the orphaned `relationship_prose_lines` + `cols_disp` helpers. The prose format survives in ADR-0016 §5 + git history for a future OOS-7 always-prose setting. **Tests:** the prose-presence unit test + its snapshot removed; a new unit test asserts `render_structure` on a description carrying **both** inbound and outbound relationships emits the box but no prose; the misnamed `add_relationship_flow_shows_inbound_section_on_parent` integration test (which sent an `AddColumn`) inverted + renamed to assert the add-column echo omits the prose; the diagram tests (`show table`, `add relationship`) unaffected. **2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. `requirements.md` unaffected (ADR-tracked refinement of a decided area, like ADR-0044 itself) +- [ADR-0051 — Bottom keybinding strip: context- and state-aware](0051-context-state-aware-keybinding-strip.md) — **Accepted + implemented 2026-06-13 (issue #27)**, closes Gitea **#27**. Repurposes the bottom status line into a **keystrokes-only, state-selected** strip (builds on ADR-0046 nav focus, ADR-0003 modes, ADR-0049 the #29 readline keys it now advertises, ADR-0022 the completion memo). A pure `status_bar_bindings(app) -> Vec<(key,label)>` chooses the strip by **priority, first match wins**: (1) **sidebar focus** → `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input`; (2) **completion memo live** (`last_completion`) → `Tab/Shift-Tab cycle · Esc cancel · Enter run`; (3) **history navigation** (new `App::is_browsing_history()` exposing the private `history_cursor`) → `↑↓ browse · Esc clear · Enter run`; (4) **editing** (input non-empty) → `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` (surfaces the #29 keys, closing ADR-0049's deferred advertisement); (5) **default** (empty) → `Ctrl-O sidebar · Tab complete · ↑ history · Enter run`. Priority is correct because Up clears the completion memo and Tab cancels history nav, so states 2/3 never co-occur, and the five are exhaustive for Input focus. **Typed-command words leave the strip** (`mode advanced`/`mode simple` switch, `:` one-shot) and **mode discovery moves to the empty-input hint** (`resolve_hint_lines`), **simple mode only**: `\`mode advanced\` for SQL` (the verb "type" omitted — the prompt implies it; advanced mode shows **no** pointer per a post-trial user decision — a switcher knows how they got there and `help` covers the way back). The one-shot's old `Backspace cancel one-shot` label is subsumed by the editing state (behaviour intact). Forks all user-chosen: **editing state shows the #29 keys** (vs unadvertised); **`Ctrl-C quit` omitted** from the strip (vs always shown); **no width-drop machinery** — the longest strip (~65 cols) fits all supported widths, so a **width-budget unit test** keeps it lean by construction instead (the user's own observation). Catalog: 12 new `shortcut.*` labels + the `panel.hint_mode_advanced` string added to `en-US.yaml`+`keys.rs` (validator-checked 1:1), 5 now-dead strip strings removed. **Modal-aware strip is OOS** (pre-existing: a modal owns the keyboard and carries its own hints; the strip under it is unchanged-in-kind, not worsened). Tests: 9 Tier-1 unit (per-state key sets — completion/history driven through real key events; width budget; mode-pointer presence/absence), 1 Tier-3 rewritten (`status_bar_is_keystroke_only_and_state_aware`), 15 full-panel snapshots re-accepted (reviewed — strip/hint only). **2467 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: modal-aware strip; a full-key cheatsheet overlay; Ctrl-K/U advertisement (editing strip shows the highest-value subset within the width budget) +- [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (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, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression). **Follow-up done 2026-06-14:** the vestigial worker `source` plumbing was fully unwound (compiler-guided, no behaviour change) — `_source` removed from `finalize_persistence`/`do_rebuild_from_text`, the three `*_request` wrappers inlined+deleted, the dead `source` param dropped from the ~30 forwarding worker handlers, and the `source` field removed from the `DescribeTable`/`QueryData`/`RunSelect` requests + their `DatabaseHandle` methods (~164 mostly-test call sites); the only worker `source` left is the snapshot/undo label (see ADR-0052 *Consequences*) +- [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implementation in progress (2026-06-14; Phase A done, Phase B underway)**. Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help ` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.` (per command form) and `hint.err.` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed per-form via a new `hint_ids: &[&str]` field on `CommandNode` mirroring `usage_ids`** (revised in Phase B): a per-*node* key proved too coarse — `add`/`drop`/`show`/`create` are each one node spanning many forms, and a live-input hint for `add 1:n relationship` must be specific to relationships; `hint_key_for_input_in_mode` reuses `usage_key_for_input_in_mode`'s form-word disambiguation, and covers the advanced-SQL forms whose `usage_ids` are empty. Not keyed off `help_id` (it is `None` on the advanced-SQL nodes purely to dedup the `help` list; that parallel gap is issue **#36**). **Clause-concept hints** (`on delete` actions, constraint slots, `with pk`, cardinality) are a recorded **deferred extension** (`hint.concept.`, issue **#37**) — per-form is the right tier-3 granularity, with position-awareness owned by tier-2 + the live `Next:` line. Two error routes share `hint.err.*`: pre-submit `diagnostic.*` read live from the walker (F1 path), runtime `translate_error` classes via stored `last_error_hint_key` (`hint` command / empty-F1). Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_ids` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): **comprehensive for v1** — ~37 command forms + 9 runtime error classes + ~33 `diagnostic.*` classes ≈ 80 teaching blocks — authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test** (every node/error class has a key), with graceful fall-back to tier-2 if a key is ever missing. Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive scope; exemplars-first. OOS: per-topic `hint ` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); the `help`-side advanced-SQL gap (issue #36) diff --git a/docs/handoff/20260612-handoff-68.md b/docs/handoff/20260612-handoff-68.md new file mode 100644 index 0000000..ec65611 --- /dev/null +++ b/docs/handoff/20260612-handoff-68.md @@ -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 (1950–2025, or the + `dob`-style birth window 1945–2007 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
▮` 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"). diff --git a/docs/handoff/20260614-handoff-69.md b/docs/handoff/20260614-handoff-69.md new file mode 100644 index 0000000..a9b037d --- /dev/null +++ b/docs/handoff/20260614-handoff-69.md @@ -0,0 +1,203 @@ +# Session handoff — 2026-06-14 (69) + +Sixty-ninth handover. Continues from handoff-68 (an issue-burndown that +closed #25/#26/#31/#32/#33/#34). This session **closed the four +remaining open issues** — #29, #28, #27, #30 — each landed with the full +phased workflow + `/runda` + Devil's-Advocate passes before commit, and +each producing a new ADR. Net: **four issues closed, four commits, four +new ADRs (0049–0052), +63 tests, zero regressions, the tracker is now +empty.** + +The four interlock: **#29** added the input-field readline keys, **#27** +advertises them in a state-aware status strip, and **#30**'s history +recall now respects modes. **#30** also turned into a real architecture +change (journaling relocation) — read §2.4 carefully before touching that +area. + +## §1. State at handoff + +**Branch:** `main`. Working tree **clean**; all work committed. The two +most recent commits are local (normal working state — push is the user's +step). + +**Tests: 2471 passing / 0 failing / 0 skipped / 1 ignored** (the +long-standing `friendly` doctest). **Clippy clean** (nursery, all +targets). Breakdown: 1771 lib + 500 integration (`it`) + 200 +typing-surface-matrix. **+35 over handoff-68's 2436** (net: #29 +22, #28 ++0, #27 +9, #30 +4 — its new history.rs/app.rs/iteration6 tests minus the +15 retired worker-journaling tests; trust the live `cargo test` count). + +**Commits this session:** +``` +4aeea55 feat(history): mode-tagged history + top-of-chain journaling (#30) +eceedc1 feat(ui): context- and state-aware bottom keybinding strip (#27) +8ac3537 feat(render): incidental-DDL confirmations show structure only, no relationships (#28) +66c8bda feat(input): readline keymap — Esc-clear + Ctrl-A/E/W/K/U (#29) +``` + +**Open Gitea issues: none.** `tea issues list --state open` is empty. + +## §2. Issues closed this session (all committed, tested, `/runda`-reviewed) + +Each closed on `git.lazyeval.net/oli/rdbms-playground` with a summary +comment. + +### 2.1 — #29 (`66c8bda`) — input-field readline keymap (ADR-0049) + +Implements the deferred **I1b** readline shortcuts: `Esc` clears a +partly-typed command (only when no completion memo is alive — the memo +wins first, ADR-0022); `Ctrl-A`/`Ctrl-E` = Home/End; `Ctrl-W` deletes +the previous word (readline-style, UTF-8 safe); `Ctrl-K`/`Ctrl-U` kill to +end/start. Cursor-only keys leave history nav intact; buffer-mutating +keys end it. **DA caught** the need for the `Ctrl-O`+`Esc` (sidebar +nav-exit) interaction not to clear the draft — locked with a regression +test. `requirements.md` I1b → `[x]`. + +### 2.2 — #28 (`8ac3537`) — incidental-DDL confirmations: structure-only (ADR-0050) + +Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/ +`rename`/`change column`, `add`/`drop index`) now render **structure +only** — no `References:` / `Referenced by:` block. Relationship-subject +surfaces (`show table`, `add`/`drop relationship`) keep their ADR-0044 +diagrams. The prose renderer (`relationship_prose_lines` + `cols_disp`) +was deleted. **Supersedes** ADR-0044 §1's incidental-DDL prose clause and +the relationship-block half of ADR-0016 §5 (both annotated). + +### 2.3 — #27 (`eceedc1`) — context- and state-aware keybinding strip (ADR-0051) + +The bottom status line is now keystrokes-only and **state-selected** by +priority (sidebar focus / completion-memo / history-nav / editing / +default). The editing state surfaces the #29 keys (closing ADR-0049's +deferred advertisement). Mode-switch advertisements left the strip; the +empty-input hint gained a simple-mode `` `mode advanced` for SQL `` pointer +(advanced mode shows none — user decision). New `App::is_browsing_history()` +exposes the private `history_cursor`. 15 full-panel snapshots re-accepted. + +### 2.4 — #30 (`4aeea55`) — mode-tagged history + top-of-chain journaling (ADR-0052) **← read before touching journaling** + +Closed both the feature (advanced history reusable in simple mode) and +the bug (the `:` one-shot prefix lost across sessions). Two halves: + +1. **Mode-tagged history.** The `history.log` status token gains an + optional `:adv` suffix (`ok` / `ok:adv` / `err` / `err:adv`); `source` + stays last + canonical so replay is unaffected. The in-memory ring + (still `Vec`) 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 `, `explain`) now do, matching + ADR-0034 §1. **User-confirmed** as the more-correct behaviour (harmless + on replay — reads/`explain` don't mutate). + + **Test migration:** 15 worker-level journaling tests were retired (the + worker no longer journals — their yaml/csv/operation assertions were + kept) and re-covered at the new layer: `history.rs` status-tag + + `:`-reconstruct; `app.rs` recall matrix; the cross-session regression + `advanced_command_journalled_then_hydrated_recalls_with_colon_in_simple` + in `iteration6_resume_history`; the replay tests cover `run_replay` + journaling. + + Plan: `docs/plans/20260613-issue-30-top-of-chain-journaling.md`. + +## §3. Next session — start here + +The user's stated plan for the next session, in order: + +1. **Pick up the ADR-0052 follow-up** (below). +2. **Check for any newly-filed open issues** (`tea issues list --state + open`) — none at handoff, but check fresh. +3. **Then** take on remaining open tasks from the general requirements + (`docs/requirements.md`) — see §5. + +### The ADR-0052 follow-up — unwind the vestigial worker `source` plumbing + +When journaling moved out of the worker, the `source` that the worker +threaded purely for journaling became dead. To avoid orphaning the param +across ~28 handlers, the refactor **left it in place** as vestigial: + +- `finalize_persistence(conn, persistence, _source, changes)` — the + `_source` param is now unused (kept so its ~28 callers still pass + `source`, which they otherwise also use for `snapshot_then`). +- `do_rebuild_from_text(conn, _persistence, _source, project_path)` — + both `_persistence` and `_source` vestigial. +- Three thin read-only wrappers in `db.rs` — + `do_describe_table_request`, `do_query_data_request`, + `do_run_select_request` — now just delegate to their non-`_request` + twin (`do_describe_table` / `do_query_data` / `do_run_select`) with + vestigial `_persistence` / `_source` params and one caller each + (`db.rs` Request arms ~2409 / ~2749 / ~2759). + +**The cleanup:** remove `_source` from `finalize_persistence` + drop the +arg at its ~28 callers (the callers keep `source` for `snapshot_then`, so +only the `finalize_persistence(...)` call loses the arg); remove the +`_persistence`/`_source` params from `do_rebuild_from_text`; and inline +the three `*_request` wrappers at their single call sites (replace +`do_describe_table_request(conn, persistence, source, name)` with +`do_describe_table(conn, &name)`, etc.), deleting the wrappers. Purely +mechanical, compiler-guided, no behaviour change. Establish the green +baseline first (`cargo test`), then verify nothing moved. + +## §4. Carried-over follow-up (website branch, not `main`) + +- **Website `seed` cast re-record** (from #34, handoff-68 §4) — still + tracked on the `website` branch, not here. Likely redundant (full + re-record sweep before publication). + +## §5. Remaining roadmap — `docs/requirements.md` (next session's §3-step 3) + +With the issue tracker empty, the next work comes from the document-based +requirements. Open / partial items worth weighing (the user picks): + +- **H2 `hint`** — the last A1 gap (contextual help for the current + command); its own ADR. (`requirements.md` H2.) +- **TT5 CI** — runs all tiers on Linux/macOS/Windows; no CI workflow yet + (a `ci` branch reportedly exists — check its state first). Couples with + **D1–D3** (cross-platform prebuilt binaries + Homebrew/Scoop). +- **TT4 PTY (Tier-4)** — ADR-0008 specifies the PTY harness + four + critical flows; still not wired (no PTY deps/tests). +- **I1 multi-line input** (Ctrl-Enter submits, Enter inserts newline) and + **I5 / B3 in-flight cancellation** (Ctrl-C cancels a running command). +- **V4 session journal** — scrollable per-session log + Markdown export + (the bigger UX project; own ADR). +- **TU1 tutorial / lesson system** — design + ADR pending (acknowledged + in scope). +- Smaller partials: **C3a** modify relationship (drop+add covers it + today), **C4** m:n convenience, **V3** ER-diagram export, the **NFR-*** + performance/visual targets (mostly unmeasured), **N4** global rolling + history (OOS for v1). + +No strong ordering — these are the user's call. Several need a new ADR +(H2, V4, TU1); CI/release (TT5/D1–D3) is the most "shippable-product" +track if that's the priority. + +## §6. How to take over + +1. Read handoffs 67 → 68 → 69, `CLAUDE.md`, `docs/requirements.md`. +2. Confirm green baseline: `cargo test` (expect **2471 pass / 1 ignored**) + + `cargo clippy --all-targets` (clean). +3. `tea issues list --state open` — pick up anything new first. +4. Then the ADR-0052 follow-up (§3), then requirements (§5). +5. Follow the project workflow: phased (requirements → divergent → eval → + execute → verify), test-first, `/runda` + DA pass before every commit, + ADR amendment for any decided-area change + the README index-upkeep + rule, and confirm the commit message with the user before committing. +6. Consider a `cargo sweep` at this milestone (`target/` grows across + sessions; see CLAUDE.md "Build hygiene"). (`sweep.timestamp` was + removed this session.) diff --git a/docs/plans/20260613-issue-30-top-of-chain-journaling.md b/docs/plans/20260613-issue-30-top-of-chain-journaling.md new file mode 100644 index 0000000..54eeeea --- /dev/null +++ b/docs/plans/20260613-issue-30-top-of-chain-journaling.md @@ -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 `||`; 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`. 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`); + 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. diff --git a/docs/plans/20260614-adr-0053-contextual-hint-H2.md b/docs/plans/20260614-adr-0053-contextual-hint-H2.md new file mode 100644 index 0000000..00391ae --- /dev/null +++ b/docs/plans/20260614-adr-0053-contextual-hint-H2.md @@ -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` 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.` 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..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..{what,example, + concept}` and `hint.err..{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` (1–2 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 A–C; gate it so CI + isn't broken mid-stream (e.g. `#[ignore]` until the final batch), then + make passing it the completion criterion. +``` diff --git a/docs/requirements.md b/docs/requirements.md index 2222f11..d3fc046 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -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 diff --git a/src/action.rs b/src/action.rs index 2ca793b..b115ddb 100644 --- a/src/action.rs +++ b/src/action.rs @@ -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 diff --git a/src/app.rs b/src/app.rs index 2863382..cf22c16 100644 --- a/src/app.rs +++ b/src/app.rs @@ -271,6 +271,13 @@ pub struct App { pub nav_focus: NavFocus, pub output: VecDeque, pub hint: Option, + /// Catalog class key of the most recent runtime error (H2 / + /// ADR-0053 D5), e.g. `foreign_key.child_side`. Set when a + /// friendly error is rendered, cleared on the next successful + /// command. The submitted `hint` command and empty-input F1 use + /// it to render that error's tier-3 `hint.err.` block. + /// `None` → no recent error → the "getting started" pointer. + pub last_error_hint_key: Option, /// The validity indicator's currently-visible verdict /// (ADR-0027). `None` means the indicator shows nothing — /// the input is clean, or it is hidden mid-typing while the @@ -521,6 +528,7 @@ pub const fn demo_badge_label(key: &KeyEvent) -> Option<&'static str> { match (key.code, key.modifiers) { (KeyCode::Tab, _) => Some("[TAB]"), (KeyCode::BackTab, _) => Some("[SHIFT-TAB]"), + (KeyCode::F(1), _) => Some("[F1]"), (KeyCode::Enter, _) => Some("[ENTER]"), (KeyCode::Esc, _) => Some("[ESC]"), (KeyCode::Up, _) => Some("[UP]"), @@ -557,6 +565,7 @@ impl App { nav_focus: NavFocus::Input, output: VecDeque::with_capacity(OUTPUT_CAPACITY), hint: None, + last_error_hint_key: None, input_indicator: None, tables: Vec::new(), relationships: Vec::new(), @@ -646,6 +655,17 @@ impl App { } } + /// Whether the user is currently browsing a recalled history entry + /// (Up/Down navigation, unedited). Exposes the private + /// `history_cursor` predicate so the context-aware status strip + /// (ADR-0051) can select its history-navigation state. Editing the + /// recalled line ends navigation (`cancel_history_navigation`), so + /// this is `false` again the moment the user types. + #[must_use] + pub const fn is_browsing_history(&self) -> bool { + self.history_cursor.is_some() + } + /// The input view the **live-feedback** walkers (completion, ambient /// hint, validity verdict, highlight overlays) should see, plus the /// byte offset stripped from the front and the cursor mapped into the @@ -863,13 +883,16 @@ impl App { error, facts, source, + advanced, } => { self.handle_dsl_failure(&command, error, facts); // ADR-0034 §1/§2: an execution failure is journalled // `err` so it is recallable across sessions (the // worker only journals successful commands). The App - // emits the intent; the runtime does the append. - vec![Action::JournalFailure { source }] + // emits the intent; the runtime does the append. The + // mode rides along (ADR-0052) so an advanced failure + // tags `err:adv`. + vec![Action::JournalFailure { source, advanced }] } AppEvent::TablesRefreshed(tables) => { trace!(count = tables.len(), "tables refreshed"); @@ -1194,6 +1217,21 @@ impl App { return self.handle_nav_key(key); } + // H2 / ADR-0053: F1 is a read-only contextual-hint overlay — + // it emits into the output journal and must NOT touch the input + // buffer, cursor, or the completion memo, so it sits ahead of + // the memo-clearing completion match below. Non-empty input → + // a hint for the command being typed; empty input → expand on + // the most recent error (or a getting-started pointer). + if key.code == KeyCode::F(1) { + if self.input.trim().is_empty() { + self.note_hint_for_recent_error(); + } else { + self.note_hint_for_input(); + } + return Vec::new(); + } + // ADR-0022 stage 8 — non-modal completion. Tab / // Shift-Tab cycle; Esc / Backspace undo the whole // last-Tab insertion in one keystroke while the memo @@ -1217,6 +1255,13 @@ impl App { match (key.code, key.modifiers) { (KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit], (KeyCode::Enter, _) => self.submit(), + // ADR-0049 (issue #29): Esc clears a partly-typed command. + // Reached only when no completion memo is alive — the memo + // block above consumes Esc first to undo a completion. + (KeyCode::Esc, _) => { + self.clear_input(); + Vec::new() + } (KeyCode::Up, _) => { self.history_back(); Vec::new() @@ -1233,11 +1278,15 @@ impl App { self.cursor_right(); Vec::new() } - (KeyCode::Home, _) => { + // ADR-0049: Ctrl-A / Ctrl-E are readline aliases for + // Home / End — line start / end — for keyboards without + // those keys. Cursor-only, so (like Home/End) they do not + // cancel history navigation. + (KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => { self.input_cursor = 0; Vec::new() } - (KeyCode::End, _) => { + (KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => { self.input_cursor = self.input.len(); Vec::new() } @@ -1251,6 +1300,23 @@ impl App { self.delete_at_cursor(); Vec::new() } + // ADR-0049: readline kill shortcuts. Each mutates the + // buffer, so each ends history navigation like Backspace. + (KeyCode::Char('w'), KeyModifiers::CONTROL) => { + self.cancel_history_navigation(); + self.delete_prev_word(); + Vec::new() + } + (KeyCode::Char('k'), KeyModifiers::CONTROL) => { + self.cancel_history_navigation(); + self.kill_to_end(); + Vec::new() + } + (KeyCode::Char('u'), KeyModifiers::CONTROL) => { + self.cancel_history_navigation(); + self.kill_to_start(); + Vec::new() + } (KeyCode::PageUp, _) => { self.scroll_output_up(); Vec::new() @@ -1545,6 +1611,54 @@ impl App { self.input.replace_range(self.input_cursor..idx, ""); } + /// Esc — clear a partly-typed command (ADR-0049). Empties the + /// buffer, parks the cursor at the start, drops any horizontal + /// scroll, and ends history navigation (the cleared line *is* the + /// new draft). Only reached when no completion memo is alive — Esc + /// undoes a live completion first (handle_key precedence). + fn clear_input(&mut self) { + self.cancel_history_navigation(); + self.input.clear(); + self.input_cursor = 0; + self.input_scroll_offset = 0; + } + + /// Ctrl-W — delete the word before the cursor (ADR-0049). Eats any + /// run of trailing whitespace, then the preceding run of + /// non-whitespace, readline-style. UTF-8 safe: word boundaries are + /// found on char boundaries, so multi-byte words delete cleanly. + fn delete_prev_word(&mut self) { + if self.input_cursor == 0 { + return; + } + let prefix = &self.input[..self.input_cursor]; + // Strip trailing whitespace, then locate the start of the + // word that now ends the prefix. + let after_ws = prefix.trim_end_matches(char::is_whitespace); + // `idx` is the byte offset of the last whitespace char before + // the word; the word starts at the next char. No whitespace at + // all → the word starts at the buffer start. + let start = after_ws.rfind(char::is_whitespace).map_or(0, |idx| { + idx + after_ws[idx..].chars().next().map_or(0, char::len_utf8) + }); + self.input.replace_range(start..self.input_cursor, ""); + self.input_cursor = start; + } + + /// Ctrl-K — kill from the cursor to the end of the line (ADR-0049). + /// The cursor is always a char boundary, so a plain truncate is + /// safe. + fn kill_to_end(&mut self) { + self.input.truncate(self.input_cursor); + } + + /// Ctrl-U — kill from the start of the line to the cursor + /// (ADR-0049). The cursor moves to the start. + fn kill_to_start(&mut self) { + self.input.replace_range(0..self.input_cursor, ""); + self.input_cursor = 0; + } + /// Move backwards in history (towards older entries). fn history_back(&mut self) { if self.history.is_empty() { @@ -1561,11 +1675,27 @@ impl App { Some(i) => i - 1, }; self.history_cursor = Some(next_index); - self.input = self.history[next_index].clone(); + let stored = self.history[next_index].clone(); + self.input = self.recall_display(&stored); self.input_cursor = self.input.len(); self.input_scroll_offset = 0; } + /// The display form of a stored history entry for the current mode + /// (ADR-0052, issue #30). An advanced entry is stored in its + /// `:`-prefixed simple-mode runnable form; in **advanced** mode the + /// `:` is stripped so it runs as bare SQL, while in **simple** mode it + /// stays prefixed and runs via the one-shot escape. A simple entry + /// (never starting with `:`) is returned unchanged in either mode. + fn recall_display(&self, stored: &str) -> String { + if self.mode == Mode::Advanced + && let Some(rest) = stored.strip_prefix(':') + { + return rest.trim_start().to_string(); + } + stored.to_string() + } + /// Move forwards in history (towards newer entries; eventually /// returning to the user's saved draft). fn history_forward(&mut self) { @@ -1574,7 +1704,8 @@ impl App { }; if i + 1 < self.history.len() { self.history_cursor = Some(i + 1); - self.input = self.history[i + 1].clone(); + let stored = self.history[i + 1].clone(); + self.input = self.recall_display(&stored); } else { // Past the most recent entry — restore the draft and // exit navigation mode. @@ -1622,10 +1753,6 @@ impl App { if trimmed.is_empty() { return Vec::new(); } - // Record the original (trimmed) line in history regardless - // of whether it parses, so users can recall and edit - // typo'd commands. - self.push_history(trimmed); // `:` one-shot escape: in simple mode, a leading `:` means // treat *this single submission* as advanced. The persistent @@ -1642,6 +1769,9 @@ impl App { }; if effective_input.is_empty() { + // A bare `:` (one-shot with nothing after it) executes + // nothing and is not recorded — the push moved below the + // strip (ADR-0052), so it no longer lands in history. return Vec::new(); } @@ -1652,16 +1782,38 @@ impl App { "submit" ); - // Parse-first: app-level commands and DSL commands now - // share the chumsky parser (per the round-5 refactor). - // App commands work in both modes — they're not gated by - // `effective_mode`. Anything that parses to a non-App - // variant falls through to the existing mode-specific - // path: simple → DSL execution; advanced → SQL placeholder. - // Anything that fails to parse falls through too — the - // simple-mode path renders the friendly parse error, the - // advanced-mode path renders the SQL placeholder. - if let Ok(Command::App(app_cmd)) = parse_command(&effective_input) { + // Parse-first: app-level commands and DSL commands share the + // parser. App commands work in both modes — they're not gated by + // `effective_mode`. Anything that parses to a non-App variant (or + // fails to parse) falls through to the mode-specific path. + let parsed = parse_command(&effective_input); + + // ADR-0052 (issue #30): record the command for cross-mode recall. + // An **advanced** (SQL) command is stored in its `:`-prefixed + // simple-mode runnable form, so it can be recalled and re-run in + // simple mode (recall strips the `:` again in advanced mode). A + // simple command — and **any app command**, which runs in either + // mode and so must not gain a `:` — is stored bare. Recorded + // regardless of whether it parses, so typo'd commands stay + // recallable. The canonical (un-prefixed) text is what reaches + // the journal via `ExecuteDsl.source`. + let is_app = matches!(&parsed, Ok(Command::App(_))); + // H2 / ADR-0053 D5: a new *DSL* command supersedes the previous + // runtime error for `hint`. App commands (incl. `hint` itself) + // and parse errors leave it intact, so `hint` still expands the + // last real error after, say, a `help` in between. + if matches!(&parsed, Ok(cmd) if !matches!(cmd, Command::App(_))) { + self.last_error_hint_key = None; + } + let advanced = submission_mode.is_advanced() && !is_app; + let ring_line = if advanced { + format!(": {effective_input}") + } else { + effective_input.clone() + }; + self.push_history(&ring_line); + + if let Ok(Command::App(app_cmd)) = parsed { return self.dispatch_app_command(app_cmd, &effective_input); } @@ -1693,6 +1845,13 @@ impl App { } Vec::new() } + // H2 / ADR-0053: a submitted `hint` acts on the most recent + // runtime error (the buffer is empty post-submit). The + // live-input surface is the F1 keybinding (handle_key). + AppCommand::Hint => { + self.note_hint_for_recent_error(); + Vec::new() + } AppCommand::Rebuild => vec![Action::PrepareRebuild], AppCommand::Save => self.handle_save_command(false), AppCommand::SaveAs => self.handle_save_command(true), @@ -1874,6 +2033,7 @@ impl App { self.note_error(note); return vec![Action::JournalFailure { source: input.to_string(), + advanced: submission_mode.is_advanced(), }]; } // Issue #17: simple-mode (DSL) counterpart. A wrong-count @@ -1901,6 +2061,7 @@ impl App { self.note_error(render_usage_block(input, mode)); return vec![Action::JournalFailure { source: input.to_string(), + advanced: submission_mode.is_advanced(), }]; } self.push_output(OutputLine::echo(input, mode)); @@ -1987,6 +2148,7 @@ impl App { // append; the App only emits the intent. vec![Action::JournalFailure { source: input.to_string(), + advanced: submission_mode.is_advanced(), }] } } @@ -2034,8 +2196,10 @@ impl App { // ADR-0044 §1 "relationship-relevant" reach: when a // relationship is the subject of the command (`show table`, // `add`/`drop relationship`), render the table's - // relationships as compact diagrams; every other DDL echo - // keeps the prose `References:` / `Referenced by:` form. + // relationships as compact diagrams. Every other (incidental + // DDL) echo renders structure only — no relationship block + // at all (ADR-0050, issue #28; supersedes ADR-0044 §1's + // prose retention for these surfaces). if matches!( command, Command::ShowTable { .. } @@ -2296,6 +2460,10 @@ impl App { // runtime built before posting the event. let ctx = self.build_translate_context(command, facts); let rendered = crate::friendly::translate_error(&error, &ctx).render(); + // H2 / ADR-0053 D5: remember this error's tier-3 class so a + // following `hint` (or empty-input F1) can expand on it. + self.last_error_hint_key = + crate::friendly::error_hint_class(&error, &ctx).map(String::from); warn!( verb = command.verb(), error = %rendered, @@ -2965,6 +3133,94 @@ impl App { } } + // ── H2 / ADR-0053: contextual `hint` ──────────────────────── + // Phase A wires the two surfaces (F1 → live input; the `hint` + // command → most recent error) plus the tier-2 fallback. The + // tier-3 corpus (`hint.cmd.*` / `hint.err.*`) is authored in later + // phases; until a block exists, `emit_tier3_block` returns false + // and the surface degrades to the ambient prose / getting-started + // pointer — never blank. + + /// F1 with a non-empty buffer: a tier-3 hint for the command form + /// being typed, else the tier-2 ambient prose (ADR-0053 D2). + /// Read-only — callers guarantee the buffer/cursor/memo are left + /// untouched. + fn note_hint_for_input(&mut self) { + // `feedback_view` strips the `:` one-shot sigil and + // `effective_mode` reflects the one-shot advanced surface, so + // the hint matches the command the user is actually typing. + let (view, cursor, _off) = self.feedback_view(); + let probe = view.to_string(); + let mode = self.effective_mode().as_mode(); + if let Some(id) = crate::dsl::grammar::hint_key_for_input_in_mode(&probe, mode) + && self.emit_tier3_block(&format!("hint.cmd.{id}")) + { + return; + } + // Tier-2 fallback: surface the ambient prose as a persistent + // line (computed exactly as the live panel does). + let ambient = crate::input_render::ambient_hint_in_mode( + &probe, + cursor, + self.last_completion.as_ref(), + &self.schema_cache, + mode, + ); + match ambient { + Some(crate::input_render::AmbientHint::Prose(text)) => { + self.push_category_three_prose(text); + } + Some(crate::input_render::AmbientHint::Candidates { items, .. }) => { + let names = items + .iter() + .map(|c| c.text.clone()) + .collect::>() + .join(", "); + self.push_category_three_prose(crate::t!("hint.ambient_expected", expected = names)); + } + None => self.note_getting_started(), + } + } + + /// The `hint` command (and empty-input F1): expand on the most + /// recent runtime error, else point the user at how to start + /// (ADR-0053 D2/D5). + fn note_hint_for_recent_error(&mut self) { + if let Some(class) = self.last_error_hint_key.clone() + && self.emit_tier3_block(&format!("hint.err.{class}")) + { + return; + } + self.note_getting_started(); + } + + fn note_getting_started(&mut self) { + self.note_system(crate::t!("hint.getting_started")); + } + + /// Render a tier-3 block (`.what` / `.example` / `.concept`) + /// when it has been authored; returns `false` if the `what` part is + /// absent so the caller can fall back to tier 2. `what` is + /// mandatory, `example`/`concept` optional (ADR-0053 D3). Styling + /// polish (the framed block) lands with the corpus. + fn emit_tier3_block(&mut self, stem: &str) -> bool { + let cat = crate::friendly::catalog(); + if cat.get(&format!("{stem}.what")).is_none() { + return false; + } + self.note_system(crate::friendly::translate(&format!("{stem}.what"), &[])); + if cat.get(&format!("{stem}.example")).is_some() { + self.note_system(crate::friendly::translate(&format!("{stem}.example"), &[])); + } + if cat.get(&format!("{stem}.concept")).is_some() { + self.push_category_three_prose(crate::friendly::translate( + &format!("{stem}.concept"), + &[], + )); + } + true + } + fn note_system(&mut self, text: impl Into) { self.push_multiline(text.into(), OutputKind::System); } @@ -5404,6 +5660,7 @@ mod tests { }, facts: crate::friendly::FailureContext::default(), source: String::new(), + advanced: false, }); let last = app.output.back().unwrap(); assert_eq!(last.kind, OutputKind::Error); @@ -5412,6 +5669,186 @@ mod tests { assert!(last.text.contains("Ghost"), "{}", last.text); } + // ── H2 / ADR-0053: contextual `hint` (Phase A skeleton) ────── + + fn f1(app: &mut App) -> Vec { + app.update(key(KeyCode::F(1))) + } + + fn no_such_table_failure() -> AppEvent { + AppEvent::DslFailed { + command: Command::DropTable { + name: "Ghost".to_string(), + }, + error: crate::db::DbError::Sqlite { + message: "no such table: Ghost".to_string(), + kind: crate::db::SqliteErrorKind::NoSuchTable, + }, + facts: crate::friendly::FailureContext::default(), + source: String::new(), + advanced: false, + } + } + + #[test] + fn hint_command_parses_to_app_hint() { + use crate::dsl::{parse_command, AppCommand, Command}; + assert!(matches!( + parse_command("hint"), + Ok(Command::App(AppCommand::Hint)) + )); + } + + #[test] + fn hint_command_with_no_recent_error_shows_getting_started() { + let mut app = App::new(); + type_str(&mut app, "hint"); + submit(&mut app); + assert!(output_contains(&app, "press F1"), "{}", error_lines(&app)); + } + + #[test] + fn f1_on_empty_input_with_no_error_shows_getting_started() { + let mut app = App::new(); + let before = app.output.len(); + f1(&mut app); + assert!(app.output.len() > before, "F1 must emit something"); + assert!(output_contains(&app, "press F1")); + } + + #[test] + fn f1_is_a_read_only_overlay() { + let mut app = App::new(); + type_str(&mut app, "insert into T"); + let input = app.input.clone(); + let cursor = app.input_cursor; + let before = app.output.len(); + f1(&mut app); + assert_eq!(app.input, input, "F1 must not change the buffer"); + assert_eq!(app.input_cursor, cursor, "F1 must not move the cursor"); + assert!(app.output.len() > before, "F1 emits a hint line"); + } + + #[test] + fn f1_preserves_the_completion_memo() { + let mut app = App::new(); + type_str(&mut app, "show "); + app.update(key(KeyCode::Tab)); + assert!(app.last_completion.is_some(), "precondition: Tab sets the memo"); + let input = app.input.clone(); + f1(&mut app); + assert!(app.last_completion.is_some(), "F1 must not clear the memo"); + assert_eq!(app.input, input, "F1 must not change the buffer"); + } + + #[test] + fn dsl_failure_sets_hint_class_and_a_later_dsl_command_clears_it() { + let mut app = App::new(); + app.update(no_such_table_failure()); + assert_eq!(app.last_error_hint_key.as_deref(), Some("not_found")); + // A new DSL command supersedes the previous error. + type_str(&mut app, "drop table Ghost"); + submit(&mut app); + assert_eq!(app.last_error_hint_key, None); + } + + #[test] + fn app_command_does_not_clear_the_hint_class() { + let mut app = App::new(); + app.update(no_such_table_failure()); + assert_eq!(app.last_error_hint_key.as_deref(), Some("not_found")); + // `help` (an app command) leaves the last error intact, so a + // following `hint` still expands on it. + type_str(&mut app, "help"); + submit(&mut app); + assert_eq!( + app.last_error_hint_key.as_deref(), + Some("not_found"), + "an app command must not clear the last error's hint class" + ); + } + + #[test] + fn hint_after_error_emits_a_hint_without_panicking() { + // Phase A: no tier-3 `hint.err.*` content exists yet, so the + // error path falls back to the getting-started pointer. (Phase C + // replaces this with the real error block.) + let mut app = App::new(); + app.update(no_such_table_failure()); + let before = app.output.len(); + type_str(&mut app, "hint"); + submit(&mut app); + assert!(app.output.len() > before, "hint must emit something"); + } + + #[test] + fn help_list_includes_hint() { + let mut app = App::new(); + type_str(&mut app, "help"); + submit(&mut app); + assert!( + output_contains(&app, "explain the most recent error"), + "help list must advertise the hint command" + ); + } + + #[test] + fn help_hint_describes_the_hint_command() { + let mut app = App::new(); + type_str(&mut app, "help hint"); + submit(&mut app); + assert!(output_contains(&app, "explain the most recent error")); + } + + // ── Phase B: tier-3 exemplar content renders ──────────────── + + #[test] + fn f1_on_insert_input_renders_the_insert_hint_block() { + let mut app = App::new(); + type_str(&mut app, "insert into Customers "); + f1(&mut app); + assert!( + output_contains(&app, "Add one or more rows to a table"), + "expected the insert tier-3 block" + ); + } + + #[test] + fn f1_on_add_relationship_renders_the_relationship_block() { + let mut app = App::new(); + type_str(&mut app, "add 1:n relationship from Customers.id to Orders.cust "); + f1(&mut app); + assert!( + output_contains(&app, "one parent, many children"), + "expected the add-relationship tier-3 block" + ); + } + + #[test] + fn f1_on_add_column_does_not_render_the_relationship_block() { + // Per-form disambiguation (ADR-0053 D3): `add column` resolves + // to `add_column` (no tier-3 block yet → tier-2 fallback), NOT + // the relationship block — proving the multi-form node keys + // per form, not per node. + let mut app = App::new(); + type_str(&mut app, "add column Note text to Customers"); + f1(&mut app); + assert!(!output_contains(&app, "one parent, many children")); + assert!(!output_contains(&app, "1:n")); + } + + #[test] + fn hint_renders_the_foreign_key_error_block() { + let mut app = App::new(); + app.last_error_hint_key = Some("foreign_key.child_side".to_string()); + type_str(&mut app, "hint"); + submit(&mut app); + assert!( + output_contains(&app, "doesn't match any parent row"), + "expected the FK child-side tier-3 block" + ); + } + #[test] fn messages_command_toggles_verbosity_and_reports() { let mut app = App::new(); @@ -5462,6 +5899,7 @@ mod tests { error: err, facts, source: String::new(), + advanced: false, }); let body = app .output @@ -5511,6 +5949,7 @@ mod tests { error: err, facts, source: String::new(), + advanced: false, }); let body = app .output @@ -5543,6 +5982,7 @@ mod tests { error: err(), facts: crate::friendly::FailureContext::default(), source: String::new(), + advanced: false, }); let verbose_text = app .output @@ -5563,6 +6003,7 @@ mod tests { error: err(), facts: crate::friendly::FailureContext::default(), source: String::new(), + advanced: false, }); let short_text = app .output @@ -5756,6 +6197,245 @@ mod tests { assert_eq!(app.input_cursor, 0); } + // ---- ADR-0049 (issue #29): input-field readline keymap ---- + + fn ctrl(c: char) -> AppEvent { + key_mod(KeyCode::Char(c), KeyModifiers::CONTROL) + } + + #[test] + fn esc_clears_a_partly_typed_command() { + let mut app = App::new(); + type_str(&mut app, "drop table T"); + app.update(key(KeyCode::Esc)); + assert_eq!(app.input, ""); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn esc_clear_resets_horizontal_scroll() { + // A long line that has been horizontally scrolled must + // reset its scroll offset on clear, exactly like submit. + let mut app = App::new(); + type_str(&mut app, "drop table T"); + app.input_scroll_offset = 5; + app.update(key(KeyCode::Esc)); + assert_eq!(app.input, ""); + assert_eq!(app.input_scroll_offset, 0); + } + + #[test] + fn esc_clear_cancels_history_navigation() { + let mut app = App::new(); + type_str(&mut app, "drop table A"); + submit(&mut app); + app.update(key(KeyCode::Up)); + assert_eq!(app.input, "drop table A"); + app.update(key(KeyCode::Esc)); + assert_eq!(app.input, ""); + assert!(app.history_cursor.is_none()); + } + + #[test] + fn esc_with_live_completion_memo_undoes_rather_than_clears() { + // Precedence: while a multi-candidate Tab memo is alive, Esc + // undoes the completion (restoring the original text), it does + // NOT clear the whole input. + let mut app = App::new(); + type_str(&mut app, "show "); + app.update(key(KeyCode::Tab)); // → "show data", memo alive + assert!(app.last_completion.is_some()); + app.update(key(KeyCode::Esc)); + assert_eq!(app.input, "show "); + } + + #[test] + fn ctrl_a_moves_cursor_to_start() { + let mut app = App::new(); + type_str(&mut app, "hello"); + app.update(ctrl('a')); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn ctrl_e_moves_cursor_to_end() { + let mut app = App::new(); + type_str(&mut app, "hello"); + app.update(key(KeyCode::Home)); + assert_eq!(app.input_cursor, 0); + app.update(ctrl('e')); + assert_eq!(app.input_cursor, 5); + } + + #[test] + fn ctrl_w_deletes_the_previous_word() { + let mut app = App::new(); + type_str(&mut app, "drop table T"); + app.update(ctrl('w')); + assert_eq!(app.input, "drop table "); + assert_eq!(app.input_cursor, "drop table ".len()); + } + + #[test] + fn ctrl_w_eats_trailing_whitespace_then_the_word() { + let mut app = App::new(); + type_str(&mut app, "foo bar "); + app.update(ctrl('w')); + assert_eq!(app.input, "foo "); + assert_eq!(app.input_cursor, 4); + } + + #[test] + fn ctrl_w_at_start_is_a_noop() { + let mut app = App::new(); + type_str(&mut app, "hello"); + app.input_cursor = 0; + app.update(ctrl('w')); + assert_eq!(app.input, "hello"); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn ctrl_w_only_deletes_back_to_the_cursor() { + // Mid-line: deletes the word before the cursor, leaving the + // suffix untouched. + let mut app = App::new(); + type_str(&mut app, "drop table T"); + app.input_cursor = "drop table".len(); // cursor right after "table" + app.update(ctrl('w')); + assert_eq!(app.input, "drop T"); + assert_eq!(app.input_cursor, "drop ".len()); + } + + #[test] + fn ctrl_w_handles_multibyte_words() { + let mut app = App::new(); + type_str(&mut app, "héllo wörld"); + app.update(ctrl('w')); + assert_eq!(app.input, "héllo "); + assert_eq!(app.input_cursor, "héllo ".len()); + } + + #[test] + fn ctrl_k_kills_to_end_of_line() { + let mut app = App::new(); + type_str(&mut app, "hello world"); + app.input_cursor = 5; // after "hello" + app.update(ctrl('k')); + assert_eq!(app.input, "hello"); + assert_eq!(app.input_cursor, 5); + } + + #[test] + fn ctrl_u_kills_to_start_of_line() { + let mut app = App::new(); + type_str(&mut app, "hello world"); + app.input_cursor = 6; // after "hello " + app.update(ctrl('u')); + assert_eq!(app.input, "world"); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn ctrl_u_cancels_history_navigation() { + let mut app = App::new(); + type_str(&mut app, "drop table A"); + submit(&mut app); + app.update(key(KeyCode::Up)); + assert_eq!(app.input, "drop table A"); + app.update(ctrl('u')); // cursor is at end after recall → clears all + assert_eq!(app.input, ""); + assert!(app.history_cursor.is_none()); + } + + #[test] + fn ctrl_w_cancels_history_navigation() { + let mut app = App::new(); + type_str(&mut app, "drop table A"); + submit(&mut app); + app.update(key(KeyCode::Up)); + assert_eq!(app.input, "drop table A"); + app.update(ctrl('w')); // deletes the recalled "A" word + assert_eq!(app.input, "drop table "); + assert!(app.history_cursor.is_none()); + } + + #[test] + fn ctrl_w_on_whitespace_only_clears_it() { + let mut app = App::new(); + type_str(&mut app, " "); + app.update(ctrl('w')); + assert_eq!(app.input, ""); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn ctrl_k_at_end_of_line_is_a_noop() { + let mut app = App::new(); + type_str(&mut app, "hello"); + // Cursor at end. + app.update(ctrl('k')); + assert_eq!(app.input, "hello"); + assert_eq!(app.input_cursor, 5); + } + + #[test] + fn ctrl_k_at_start_kills_the_whole_line() { + let mut app = App::new(); + type_str(&mut app, "hello"); + app.input_cursor = 0; + app.update(ctrl('k')); + assert_eq!(app.input, ""); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn ctrl_u_at_start_of_line_is_a_noop() { + let mut app = App::new(); + type_str(&mut app, "hello"); + app.input_cursor = 0; + app.update(ctrl('u')); + assert_eq!(app.input, "hello"); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn ctrl_u_at_end_kills_the_whole_line() { + let mut app = App::new(); + type_str(&mut app, "hello"); + // Cursor at end. + app.update(ctrl('u')); + assert_eq!(app.input, ""); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn esc_on_empty_input_is_harmless() { + let mut app = App::new(); + app.update(key(KeyCode::Esc)); + assert_eq!(app.input, ""); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn esc_exiting_nav_mode_does_not_clear_the_input() { + // ADR-0049 / ADR-0046 DC3: while a sidebar panel is focused + // (Ctrl-O), Esc exits navigation mode — the nav handler + // consumes it upstream of the input-field keymap, so the + // partly-typed command is preserved, NOT cleared. + let mut app = App::new(); + type_str(&mut app, "create table T"); + app.update(key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL)); + assert_eq!(app.nav_focus, NavFocus::SidebarTables); + // The draft survives entering nav mode. + assert_eq!(app.input, "create table T"); + app.update(key(KeyCode::Esc)); + // Esc returned focus to the input WITHOUT clearing it. + assert_eq!(app.nav_focus, NavFocus::Input); + assert_eq!(app.input, "create table T"); + assert_eq!(app.input_cursor, "create table T".len()); + } + #[test] fn relationships_refreshed_event_updates_the_field() { // ADR-0046 DB2: the runtime posts RelationshipsRefreshed; the @@ -5999,7 +6679,7 @@ mod tests { assert!( matches!( actions.as_slice(), - [Action::JournalFailure { source }] if source == "florp glorp" + [Action::JournalFailure { source, .. }] if source == "florp glorp" ), "expected JournalFailure for the typo'd line; got {actions:?}", ); @@ -6022,11 +6702,12 @@ mod tests { }, facts: crate::friendly::FailureContext::default(), source: "drop table Ghost".to_string(), + advanced: false, }); assert!( matches!( actions.as_slice(), - [Action::JournalFailure { source }] if source == "drop table Ghost" + [Action::JournalFailure { source, .. }] if source == "drop table Ghost" ), "expected JournalFailure carrying the source; got {actions:?}", ); @@ -6155,6 +6836,80 @@ mod tests { assert_eq!(app.input, "drop table AX"); } + // ---- ADR-0052 (issue #30): mode-aware history recall ---- + + #[test] + fn one_shot_advanced_command_recalls_with_colon_in_simple_mode() { + // The bug: a `:`-one-shot advanced command must recall WITH the + // `:` so it re-runs in simple mode (in-session and, via the + // `:`-prefixed ring form, across sessions too). + let mut app = App::new(); + type_str(&mut app, ": select 1"); + submit(&mut app); + app.update(key(KeyCode::Up)); + assert_eq!(app.input, ": select 1"); + } + + #[test] + fn persistent_advanced_command_recalls_with_colon_back_in_simple_mode() { + // The feature: a command typed in *persistent* advanced mode + // recalls into simple mode with a `:` so it stays runnable. + let mut app = App::new(); + app.mode = Mode::Advanced; + type_str(&mut app, "select 1"); + submit(&mut app); + // Switch back to simple and recall. + app.mode = Mode::Simple; + app.update(key(KeyCode::Up)); + assert_eq!(app.input, ": select 1"); + } + + #[test] + fn advanced_command_recalls_bare_in_advanced_mode() { + // In advanced mode the stored `:`-prefix is stripped so it runs + // as bare SQL. + let mut app = App::new(); + app.mode = Mode::Advanced; + type_str(&mut app, "select 1"); + submit(&mut app); + app.update(key(KeyCode::Up)); + assert_eq!(app.input, "select 1"); + } + + #[test] + fn simple_command_recalls_bare_in_either_mode() { + let mut app = App::new(); + type_str(&mut app, "drop table T"); + submit(&mut app); + app.update(key(KeyCode::Up)); + assert_eq!(app.input, "drop table T"); + app.mode = Mode::Advanced; + app.update(key(KeyCode::Down)); // back to draft + app.update(key(KeyCode::Up)); + assert_eq!(app.input, "drop table T"); + } + + #[test] + fn app_command_recalls_bare_even_when_typed_with_colon() { + // An app command runs in any mode, so it must NOT gain a `:` on + // recall even when entered via the one-shot escape. + let mut app = App::new(); + type_str(&mut app, ": mode advanced"); + submit(&mut app); + app.update(key(KeyCode::Up)); + assert_eq!(app.input, "mode advanced"); + } + + #[test] + fn a_bare_colon_is_not_recorded_in_history() { + let mut app = App::new(); + type_str(&mut app, ":"); + submit(&mut app); + // Nothing recallable. + app.update(key(KeyCode::Up)); + assert_eq!(app.input, ""); + } + #[test] fn add_column_with_text_type_emits_execute_action() { let mut app = App::new(); diff --git a/src/db.rs b/src/db.rs index a057ebe..80ebb64 100644 --- a/src/db.rs +++ b/src/db.rs @@ -608,7 +608,6 @@ enum Request { }, DescribeTable { name: String, - source: Option, reply: oneshot::Sender>, }, AddRelationship { @@ -748,7 +747,6 @@ enum Request { table: String, filter: Option, limit: Option, - source: Option, reply: oneshot::Sender>, }, /// Run a SQL `SELECT` typed by the user in advanced mode @@ -757,11 +755,11 @@ enum Request { /// prepares and runs the statement and returns the rows as /// a [`DataResult`] (with no playground type information per /// ADR-0030 §6 — computed columns render with neutral - /// alignment). `source` is the literal submitted line, - /// appended to `history.log` for replay (ADR-0030 §11). + /// alignment). The literal submitted line is journalled to + /// `history.log` at the dispatch layer for replay (ADR-0030 §11, + /// ADR-0052). RunSelect { sql: String, - source: Option, reply: oneshot::Sender>, }, /// Run a validated SQL `INSERT` typed in advanced mode @@ -1418,18 +1416,9 @@ impl Database { recv.await.map_err(|_| DbError::WorkerGone)? } - pub async fn describe_table( - &self, - name: String, - source: Option, - ) -> Result { + pub async fn describe_table(&self, name: String) -> Result { let (reply, recv) = oneshot::channel(); - self.send(Request::DescribeTable { - name, - source, - reply, - }) - .await?; + self.send(Request::DescribeTable { name, reply }).await?; recv.await.map_err(|_| DbError::WorkerGone)? } @@ -1608,14 +1597,12 @@ impl Database { table: String, filter: Option, limit: Option, - source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::QueryData { table, filter, limit, - source, reply, }) .await?; @@ -1624,15 +1611,11 @@ impl Database { /// Run a validated SQL `SELECT` and return the rows /// (ADR-0030 §6, ADR-0031). `sql` is the grammar-validated - /// statement text; `source` is the literal submitted line - /// for `history.log`. - pub async fn run_select( - &self, - sql: String, - source: Option, - ) -> Result { + /// statement text; the literal submitted line is journalled + /// at the dispatch layer (ADR-0052). + pub async fn run_select(&self, sql: String) -> Result { let (reply, recv) = oneshot::channel(); - self.send(Request::RunSelect { sql, source, reply }).await?; + self.send(Request::RunSelect { sql, reply }).await?; recv.await.map_err(|_| DbError::WorkerGone)? } @@ -2235,7 +2218,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_create_table( conn, persistence, - source.as_deref(), &name, &columns, &primary_key, @@ -2262,19 +2244,16 @@ fn handle_request( // (`show table`), it belongs in the complete journal // (ADR-0034). ADR-0035 §4. if if_not_exists && user_table_exists(conn, &name).unwrap_or(false) { - let result = do_describe_table(conn, &name).and_then(|desc| { - if let (Some(p), Some(text)) = (persistence, source.as_deref()) { - p.append_history(text).map_err(DbError::from_persistence)?; - } - Ok(CreateOutcome::Skipped(desc)) - }); + // ADR-0052: journaling moved to the dispatch layer; this + // no-op skip is an `Ok` outcome there and is journalled by + // the spawn like any other. + let result = do_describe_table(conn, &name).map(CreateOutcome::Skipped); let _ = reply.send(result); } else { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { do_create_table( conn, persistence, - source.as_deref(), &name, &columns, &primary_key, @@ -2292,7 +2271,7 @@ fn handle_request( reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { - do_drop_table(conn, persistence, source.as_deref(), &name) + do_drop_table(conn, persistence, &name) }); } Request::SqlDropTable { @@ -2306,16 +2285,12 @@ fn handle_request( // line is still journalled — like the `CREATE TABLE IF NOT // EXISTS` skip and other no-ops (ADR-0034). ADR-0035 §4. if if_exists && !user_table_exists(conn, &name).unwrap_or(false) { - let result = (|| { - if let (Some(p), Some(text)) = (persistence, source.as_deref()) { - p.append_history(text).map_err(DbError::from_persistence)?; - } - Ok(DropOutcome::Skipped) - })(); + // ADR-0052: journaling moved to the dispatch layer. + let result: Result = Ok(DropOutcome::Skipped); let _ = reply.send(result); } else { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { - do_drop_table(conn, persistence, source.as_deref(), &name) + do_drop_table(conn, persistence, &name) .map(|()| DropOutcome::Dropped) }); } @@ -2329,7 +2304,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_column( conn, persistence, - source.as_deref(), &table, &column, )); @@ -2344,7 +2318,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_column( conn, persistence, - source.as_deref(), &table, &column, cascade, @@ -2360,7 +2333,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_rename_column( conn, persistence, - source.as_deref(), &table, &old, &new, @@ -2375,7 +2347,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_rename_table( conn, persistence, - source.as_deref(), &table, &new, )); @@ -2391,7 +2362,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_change_column_type( conn, persistence, - source.as_deref(), &table, &column, ty, @@ -2407,17 +2377,8 @@ fn handle_request( Request::ShowRelationship { name, reply } => { let _ = reply.send(do_show_relationship(conn, &name)); } - Request::DescribeTable { - name, - source, - reply, - } => { - let _ = reply.send(do_describe_table_request( - conn, - persistence, - source.as_deref(), - &name, - )); + Request::DescribeTable { name, reply } => { + let _ = reply.send(do_describe_table(conn, &name)); } Request::AddRelationship { name, @@ -2434,7 +2395,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_relationship( conn, persistence, - source.as_deref(), name.as_deref(), &parent_table, &parent_columns, @@ -2456,7 +2416,6 @@ fn handle_request( do_create_m2n_relationship( conn, persistence, - source.as_deref(), &t1, &t2, name.as_deref(), @@ -2471,7 +2430,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_relationship( conn, persistence, - source.as_deref(), &selector, )); } @@ -2485,7 +2443,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_index( conn, persistence, - source.as_deref(), name.as_deref(), &table, &columns, @@ -2503,7 +2460,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_index( conn, persistence, - source.as_deref(), &selector, )); } @@ -2519,19 +2475,15 @@ fn handle_request( // ADR-0035 §4). Existence uses the same user-index lookup as // `do_drop_index` (`sql IS NOT NULL`). if if_exists && !index_exists(conn, &name, true).unwrap_or(false) { - let result = (|| { - if let (Some(p), Some(text)) = (persistence, source.as_deref()) { - p.append_history(text).map_err(DbError::from_persistence)?; - } - Ok(DropIndexOutcome::Skipped) - })(); + // ADR-0052: journaling moved to the dispatch layer. + let result: Result = + Ok(DropIndexOutcome::Skipped); let _ = reply.send(result); } else { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { do_drop_index( conn, persistence, - source.as_deref(), &IndexSelector::Named { name: name.clone() }, ) .map(DropIndexOutcome::Dropped) @@ -2555,19 +2507,15 @@ fn handle_request( // hits `do_add_index`'s redundant-set refusal (ADR-0025). let resolved = resolve_index_name(name.as_deref(), &table, &columns); if if_not_exists && index_exists(conn, &resolved, false).unwrap_or(false) { - let result = (|| { - if let (Some(p), Some(text)) = (persistence, source.as_deref()) { - p.append_history(text).map_err(DbError::from_persistence)?; - } - Ok(CreateIndexOutcome::Skipped(resolved.clone())) - })(); + // ADR-0052: journaling moved to the dispatch layer. + let result: Result = + Ok(CreateIndexOutcome::Skipped(resolved)); let _ = reply.send(result); } else { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { do_add_index( conn, persistence, - source.as_deref(), name.as_deref(), &table, &columns, @@ -2587,7 +2535,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_constraint( conn, persistence, - source.as_deref(), &table, &column, &constraint, @@ -2603,7 +2550,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_constraint( conn, persistence, - source.as_deref(), &table, &column, kind, @@ -2620,7 +2566,6 @@ fn handle_request( do_set_column_default( conn, persistence, - source.as_deref(), &table, &column, &default_sql, @@ -2638,7 +2583,6 @@ fn handle_request( do_alter_add_table_check( conn, persistence, - source.as_deref(), &table, name.as_deref(), &expr_sql, @@ -2652,7 +2596,7 @@ fn handle_request( reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { - do_alter_add_unique(conn, persistence, source.as_deref(), &table, &columns) + do_alter_add_unique(conn, persistence, &table, &columns) }); } Request::AlterDropConstraint { @@ -2662,7 +2606,7 @@ fn handle_request( reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { - do_drop_constraint_by_name(conn, persistence, source.as_deref(), &table, &name) + do_drop_constraint_by_name(conn, persistence, &table, &name) }); } Request::AlterAddForeignKey { @@ -2676,7 +2620,6 @@ fn handle_request( do_alter_add_foreign_key( conn, persistence, - source.as_deref(), &child_table, name.as_deref(), &fk, @@ -2693,7 +2636,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_insert( conn, persistence, - source.as_deref(), &table, columns.as_deref(), &values, @@ -2713,7 +2655,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_seed( conn, persistence, - source.as_deref(), &table, target_column.as_deref(), count, @@ -2731,7 +2672,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_update( conn, persistence, - source.as_deref(), &table, &assignments, &filter, @@ -2746,7 +2686,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_delete( conn, persistence, - source.as_deref(), &table, &filter, )); @@ -2755,25 +2694,12 @@ fn handle_request( table, filter, limit, - source, reply, } => { - let _ = reply.send(do_query_data_request( - conn, - persistence, - source.as_deref(), - &table, - filter.as_ref(), - limit, - )); + let _ = reply.send(do_query_data(conn, &table, filter.as_ref(), limit)); } - Request::RunSelect { sql, source, reply } => { - let _ = reply.send(do_run_select_request( - conn, - persistence, - source.as_deref(), - &sql, - )); + Request::RunSelect { sql, reply } => { + let _ = reply.send(do_run_select(conn, &sql)); } Request::RunSqlInsert { sql, @@ -2788,7 +2714,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_insert( conn, persistence, - source.as_deref(), &sql, &target_table, &listed_columns, @@ -2808,7 +2733,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_update( conn, persistence, - source.as_deref(), &sql, &target_table, returning, @@ -2825,7 +2749,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_delete( conn, persistence, - source.as_deref(), &sql, &target_table, returning, @@ -2836,12 +2759,9 @@ fn handle_request( source, reply, } => { - snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_rebuild_from_text( - conn, - persistence, - source.as_deref(), - &project_path, - )); + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_rebuild_from_text(conn, &project_path) + }); } Request::ExplainPlan { query, reply } => { let _ = reply.send(do_explain_plan(conn, &query)); @@ -3065,10 +2985,16 @@ struct Changes { /// Read-only requests (no schema change, no row writes, no /// drops) still use this to append `history.log` if `source` /// is set; they pass an empty `Changes`. +// Persist the **state** sources (project.yaml + data/*.csv) for a +// committed mutation, inside the worker transaction (ADR-0015 §6 +// commit-db-last). `history.log` is NOT written here — ADR-0052 moved +// journaling to the dispatch layer (runtime), so the command's mode is +// available without plumbing it through the worker, and a journal-write +// failure no longer rolls back a committed command (it is best-effort, +// like the failure path). fn finalize_persistence( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, changes: &Changes, ) -> Result<(), DbError> { let Some(p) = persistence else { @@ -3093,10 +3019,6 @@ fn finalize_persistence( p.delete_table_data(table) .map_err(DbError::from_persistence)?; } - if let Some(text) = source { - p.append_history(text) - .map_err(DbError::from_persistence)?; - } Ok(()) } @@ -3529,7 +3451,6 @@ pub enum CreateIndexOutcome { fn do_create_table( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, name: &str, columns: &[ColumnSpec], primary_key: &[String], @@ -3713,7 +3634,7 @@ fn do_create_table( rewritten_tables: vec![name.to_string()], ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(description) } @@ -3721,7 +3642,6 @@ fn do_create_table( fn do_drop_table( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, name: &str, ) -> Result<(), DbError> { debug!(table = %name, "drop_table"); @@ -3773,7 +3693,7 @@ fn do_drop_table( rewritten_tables: Vec::new(), deleted_tables: vec![name.to_string()], }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(()) } @@ -3800,7 +3720,6 @@ fn do_drop_table( fn do_add_column( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, column: &ColumnSpec, ) -> Result { @@ -3831,7 +3750,7 @@ fn do_add_column( column.name, ))); } - return do_add_auto_generated_column(conn, persistence, source, table, column); + return do_add_auto_generated_column(conn, persistence, table, column); } // SQLite's `ALTER TABLE ADD COLUMN` cannot express `UNIQUE` // or `CHECK`, and a `NOT NULL` column added that way must @@ -3844,9 +3763,9 @@ fn do_add_column( || column.check_sql.is_some() || (column.not_null && column.default.is_none() && column.default_sql.is_none()) { - do_add_constrained_column_via_rebuild(conn, persistence, source, table, column) + do_add_constrained_column_via_rebuild(conn, persistence, table, column) } else { - do_add_plain_column(conn, persistence, source, table, column) + do_add_plain_column(conn, persistence, table, column) } } @@ -3854,7 +3773,6 @@ fn do_add_column( fn do_add_plain_column( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, spec: &ColumnSpec, ) -> Result { @@ -3891,7 +3809,7 @@ fn do_add_plain_column( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(AddColumnResult { description, @@ -3907,7 +3825,6 @@ fn do_add_plain_column( fn do_add_auto_generated_column( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, spec: &ColumnSpec, ) -> Result { @@ -4014,7 +3931,7 @@ fn do_add_auto_generated_column( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) }; @@ -4039,7 +3956,6 @@ fn do_add_auto_generated_column( fn do_add_constrained_column_via_rebuild( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, spec: &ColumnSpec, ) -> Result { @@ -4114,7 +4030,7 @@ fn do_add_constrained_column_via_rebuild( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) }; @@ -4140,7 +4056,6 @@ fn do_add_constrained_column_via_rebuild( fn do_add_constraint( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, column: &str, constraint: &Constraint, @@ -4267,7 +4182,7 @@ fn do_add_constraint( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) }; @@ -4283,7 +4198,6 @@ fn do_add_constraint( fn do_drop_constraint( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, column: &str, kind: ConstraintKind, @@ -4367,7 +4281,7 @@ fn do_drop_constraint( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) }; @@ -4386,7 +4300,6 @@ fn do_drop_constraint( fn do_set_column_default( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, column: &str, default_sql: &str, @@ -4434,7 +4347,7 @@ fn do_set_column_default( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) }; @@ -4776,7 +4689,6 @@ fn format_auto_fill_add_note(ty: Type, row_count: usize) -> String { fn do_drop_column( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, column: &str, cascade: bool, @@ -4918,7 +4830,7 @@ fn do_drop_column( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(DropColumnResult { description, @@ -4936,7 +4848,6 @@ fn do_drop_column( fn do_rename_column( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, old: &str, new: &str, @@ -5028,7 +4939,7 @@ fn do_rename_column( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(description) } @@ -5060,7 +4971,6 @@ fn do_rename_column( fn do_rename_table( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, old: &str, new: &str, ) -> Result { @@ -5206,7 +5116,7 @@ fn do_rename_table( rewritten_tables: vec![new.to_string()], deleted_tables: vec![old.to_string()], }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(description) } @@ -5247,7 +5157,6 @@ fn do_rename_table( fn do_change_column_type( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, column: &str, ty: Type, @@ -5365,7 +5274,7 @@ fn do_change_column_type( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) }; @@ -7450,7 +7359,6 @@ fn resolve_create_table_fks( fn do_create_m2n_relationship( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, t1: &str, t2: &str, name: Option<&str>, @@ -7524,7 +7432,6 @@ fn do_create_m2n_relationship( do_create_table( conn, persistence, - source, &junction, &columns, &primary_key, @@ -7538,7 +7445,6 @@ fn do_create_m2n_relationship( fn do_add_relationship( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, name: Option<&str>, parent_table: &str, parent_columns: &[String], @@ -7693,7 +7599,7 @@ fn do_add_relationship( rewritten_tables: vec![child_table.to_string()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) })?; @@ -7707,7 +7613,6 @@ fn do_add_relationship( fn do_drop_relationship( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, selector: &RelationshipSelector, ) -> Result, DbError> { debug!(selector = ?selector, "drop_relationship"); @@ -7766,7 +7671,7 @@ fn do_drop_relationship( rewritten_tables: vec![child_table_for_persist.clone()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) })?; @@ -7785,7 +7690,6 @@ fn do_drop_relationship( fn do_alter_add_table_check( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, name: Option<&str>, expr_sql: &str, @@ -7878,7 +7782,7 @@ fn do_alter_add_table_check( rewritten_tables: vec![table_owned.clone()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) })?; do_describe_table(conn, table) @@ -7892,7 +7796,6 @@ fn do_alter_add_table_check( fn do_alter_add_unique( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, columns: &[String], ) -> Result { @@ -7947,7 +7850,7 @@ fn do_alter_add_unique( rewritten_tables: vec![table_owned.clone()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) })?; do_describe_table(conn, table) @@ -7960,7 +7863,6 @@ fn do_alter_add_unique( fn do_drop_constraint_by_name( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, name: &str, ) -> Result, DbError> { @@ -8000,7 +7902,7 @@ fn do_drop_constraint_by_name( rewritten_tables: vec![t.clone()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) })?; return Ok(Some(do_describe_table(conn, table)?)); @@ -8019,7 +7921,6 @@ fn do_drop_constraint_by_name( return do_drop_relationship( conn, persistence, - source, &RelationshipSelector::Named { name: name.to_string(), }, @@ -8059,7 +7960,7 @@ fn do_drop_constraint_by_name( rewritten_tables: vec![table_owned.clone()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) })?; return Ok(Some(do_describe_table(conn, table)?)); @@ -8081,7 +7982,6 @@ fn do_drop_constraint_by_name( fn do_alter_add_foreign_key( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, child_table: &str, name: Option<&str>, fk: &SqlForeignKey, @@ -8123,7 +8023,6 @@ fn do_alter_add_foreign_key( do_add_relationship( conn, persistence, - source, name, &fk.parent_table, &parent_columns, @@ -8192,7 +8091,6 @@ fn index_exists(conn: &Connection, name: &str, user_only: bool) -> Result, - source: Option<&str>, name: Option<&str>, table: &str, columns: &[String], @@ -8274,7 +8172,7 @@ fn do_add_index( schema_dirty: true, ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(description) } @@ -8284,7 +8182,6 @@ fn do_add_index( fn do_drop_index( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, selector: &IndexSelector, ) -> Result { debug!(selector = ?selector, "drop_index"); @@ -8353,28 +8250,14 @@ fn do_drop_index( schema_dirty: true, ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(description) } -/// Read-only wrapper around `do_describe_table` that runs an -/// auxiliary `history.log` append for user-issued -/// `show table` commands. -fn do_describe_table_request( - conn: &Connection, - persistence: Option<&Persistence>, - source: Option<&str>, - name: &str, -) -> Result { - let description = do_describe_table(conn, name)?; - if let (Some(p), Some(text)) = (persistence, source) { - p.append_history(text) - .map_err(DbError::from_persistence)?; - } - Ok(description) -} - +/// Reads a table's user-facing structure (`show table` / `describe`). +/// ADR-0052: journaling moved to the dispatch layer, so this read does +/// not touch `history.log` — the spawn journals the `Ok` outcome. fn do_describe_table(conn: &Connection, name: &str) -> Result { debug!(name = %name, "describe_table"); // Column info — including the ADR-0029 constraints — comes @@ -8851,11 +8734,9 @@ fn sample_parent_key_tuples( /// form 2). `overrides` carries the `set …` clause (D2): per-column /// pins that replace the heuristic generator and drop the column from the /// generic-fill advisory (D13). -#[allow(clippy::too_many_arguments)] fn do_seed( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, target_column: Option<&str>, count: Option, @@ -8871,7 +8752,7 @@ fn do_seed( // Column-fill (D1 form 2) is a distinct UPDATE path. if let Some(col) = target_column { return do_seed_column_fill( - conn, persistence, source, table, col, count, overrides, rng_seed, + conn, persistence, table, col, count, overrides, rng_seed, ); } @@ -9154,7 +9035,7 @@ fn do_seed( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; let data = if preview_rowids.is_empty() { @@ -9335,7 +9216,6 @@ fn seed_override_literal(value: &Value, column: &str) -> Result fn do_seed_column_fill( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, column: &str, count: Option, @@ -9589,7 +9469,7 @@ fn do_seed_column_fill( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; // Preview the first capped rows (D18). @@ -9727,7 +9607,6 @@ fn insert_one_row( fn do_insert( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, user_columns: Option<&[String]>, user_values: &[Value], @@ -9747,7 +9626,7 @@ fn do_insert( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(InsertResult { rows_affected, data }) } @@ -9791,7 +9670,6 @@ fn build_update_sql( fn do_update( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, assignments: &[(String, Value)], filter: &RowFilter, @@ -9844,7 +9722,7 @@ fn do_update( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(UpdateResult { rows_affected, @@ -9892,7 +9770,6 @@ fn build_delete_sql( fn do_delete( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, filter: &RowFilter, ) -> Result { @@ -9964,7 +9841,7 @@ fn do_delete( rewritten_tables, ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(DeleteResult { @@ -9981,42 +9858,6 @@ fn do_delete( }) } -/// Read-only wrapper that adds the `history.log` append for -/// `show data` user commands. -fn do_query_data_request( - conn: &Connection, - persistence: Option<&Persistence>, - source: Option<&str>, - table: &str, - filter: Option<&Expr>, - limit: Option, -) -> Result { - let data = do_query_data(conn, table, filter, limit)?; - if let (Some(p), Some(text)) = (persistence, source) { - p.append_history(text) - .map_err(DbError::from_persistence)?; - } - Ok(data) -} - -/// Worker handler for `Request::RunSelect` (ADR-0030 §6, -/// ADR-0031). Mirrors `do_query_data_request`: run the -/// statement, append the literal line to `history.log` so a -/// replay re-runs it (ADR-0030 §11). -fn do_run_select_request( - conn: &Connection, - persistence: Option<&Persistence>, - source: Option<&str>, - sql: &str, -) -> Result { - let data = do_run_select(conn, sql)?; - if let (Some(p), Some(text)) = (persistence, source) { - p.append_history(text) - .map_err(DbError::from_persistence)?; - } - Ok(data) -} - /// Currently-stored non-NULL values of one column, for shortid /// collision-avoidance (passed to `generate_shortid_batch`). fn existing_shortids( @@ -10264,7 +10105,6 @@ fn plan_autogen_autofill( fn do_sql_insert( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, sql: &str, target_table: &str, listed_columns: &[String], @@ -10354,7 +10194,7 @@ fn do_sql_insert( rewritten_tables: vec![target_table.to_string()], ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(InsertResult { rows_affected, @@ -10383,7 +10223,6 @@ fn do_sql_insert( fn do_sql_update( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, sql: &str, target_table: &str, returning: bool, @@ -10436,7 +10275,7 @@ fn do_sql_update( rewritten_tables: vec![target_table.to_string()], ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(UpdateResult { rows_affected, data }) } @@ -10474,7 +10313,6 @@ fn do_sql_update( fn do_sql_delete( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, sql: &str, target_table: &str, returning: bool, @@ -10555,7 +10393,7 @@ fn do_sql_delete( rewritten_tables, ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(DeleteResult { @@ -11117,12 +10955,7 @@ fn read_relationships_inbound( /// the end (regardless of success). A `foreign_key_check` /// before commit verifies the loaded data is consistent — any /// violation aborts with a fatal error. -fn do_rebuild_from_text( - conn: &Connection, - persistence: Option<&Persistence>, - source: Option<&str>, - project_path: &Path, -) -> Result<(), DbError> { +fn do_rebuild_from_text(conn: &Connection, project_path: &Path) -> Result<(), DbError> { debug!(path = %project_path.display(), "rebuild_from_text"); let yaml_path = project_path.join(PROJECT_YAML); let data_dir = project_path.join(DATA_DIR); @@ -11320,10 +11153,8 @@ fn do_rebuild_from_text( // 7. Append `history.log` if this rebuild was // user-initiated (the silent on-load case has // `source = None`). - if let (Some(p), Some(text)) = (persistence, source) { - p.append_history(text) - .map_err(DbError::from_persistence)?; - } + // ADR-0052: `rebuild` is journalled at the dispatch layer + // (`spawn_rebuild`), not here — journaling left the worker. tx.commit().map_err(DbError::from_rusqlite)?; Ok(()) @@ -11719,7 +11550,7 @@ mod tests { .await .unwrap(); } - let desc = db.describe_table("T".to_string(), None).await.unwrap(); + let desc = db.describe_table("T".to_string()).await.unwrap(); let id_col = desc.columns.iter().find(|c| c.name == "id").unwrap(); assert_eq!(id_col.user_type, Some(Type::Serial)); for ty in [Type::Date, Type::DateTime, Type::Decimal, Type::ShortId] { @@ -11750,7 +11581,7 @@ mod tests { None) .await .unwrap(); - let before = db.describe_table("T".to_string(), None).await.unwrap(); + let before = db.describe_table("T".to_string()).await.unwrap(); assert_eq!(before.columns[0].user_type, Some(Type::Date)); // Drop it. @@ -11767,7 +11598,7 @@ mod tests { None) .await .unwrap(); - let after = db.describe_table("T".to_string(), None).await.unwrap(); + let after = db.describe_table("T".to_string()).await.unwrap(); assert_eq!(after.columns[0].user_type, Some(Type::DateTime)); } @@ -11781,7 +11612,7 @@ mod tests { .await .unwrap_or_else(|e| panic!("type {ty} failed: {e}")); } - let desc = db.describe_table("T".to_string(), None).await.unwrap(); + let desc = db.describe_table("T".to_string()).await.unwrap(); // 5 user columns + the id PK column. assert_eq!(desc.columns.len(), 6); } @@ -11855,7 +11686,7 @@ mod tests { result.client_side_notes ); // Verify the column is populated 1..3. - let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("T".to_string(), None, None).await.unwrap(); let seq_idx = data.columns.iter().position(|c| c == "seq").unwrap(); let mut filled: Vec = data .rows @@ -11888,7 +11719,7 @@ mod tests { result.client_side_notes ); // Verify each row has a non-null shortid value. - let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("T".to_string(), None, None).await.unwrap(); let tag_idx = data.columns.iter().position(|c| c == "tag").unwrap(); for row in &data.rows { let v = row[tag_idx].as_ref().expect("non-null shortid auto-filled"); @@ -11921,7 +11752,7 @@ mod tests { .await .unwrap(); } - let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("T".to_string(), None, None).await.unwrap(); let seq_idx = data.columns.iter().position(|c| c == "seq").unwrap(); let mut values: Vec = data .rows @@ -11960,7 +11791,7 @@ mod tests { ) .await .unwrap(); - let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("T".to_string(), None, None).await.unwrap(); let seq_idx = data.columns.iter().position(|c| c == "seq").unwrap(); let mut values: Vec = data .rows @@ -12075,7 +11906,7 @@ mod tests { // Row data still accessible (id was preserved); the // dropped column is gone from the projection. - let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("T".to_string(), None, None).await.unwrap(); assert_eq!(data.columns, vec!["id".to_string()]); assert_eq!(data.rows.len(), 1); } @@ -12486,7 +12317,7 @@ mod tests { .await .unwrap(); let orders = db - .describe_table("Orders".to_string(), None) + .describe_table("Orders".to_string()) .await .unwrap(); let outbound = orders @@ -12501,7 +12332,7 @@ mod tests { // Same from the parent perspective via inbound. let customers = db - .describe_table("Customers".to_string(), None) + .describe_table("Customers".to_string()) .await .unwrap(); let inbound = customers @@ -12589,7 +12420,7 @@ mod tests { assert_eq!(note.transformed, 3); assert_eq!(note.lossy, 0); // Data preserved via the per-cell transformer. - let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("T".to_string(), None, None).await.unwrap(); assert_eq!(data.rows.len(), 3); } @@ -12870,7 +12701,7 @@ mod tests { result.client_side ); // Data preserved. - let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("T".to_string(), None, None).await.unwrap(); assert_eq!(data.rows.len(), 3); } @@ -13280,7 +13111,7 @@ mod tests { assert_eq!(note.auto_fill_kind, Some(AutoFillKind::Serial)); // Confirm the filled values: existing 5, fills are 6 // and 7 (continue sequence from MAX+1). - let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("T".to_string(), None, None).await.unwrap(); let code_idx = data.columns.iter().position(|c| c == "code").unwrap(); let mut values: Vec = data .rows @@ -13338,7 +13169,7 @@ mod tests { assert_eq!(note.auto_filled, 2); assert_eq!(note.auto_fill_kind, Some(AutoFillKind::ShortId)); // All three rows now have valid shortids. - let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("T".to_string(), None, None).await.unwrap(); let tag_idx = data.columns.iter().position(|c| c == "tag").unwrap(); for row in &data.rows { let v = row[tag_idx].as_ref().expect("non-null shortid after fill"); @@ -13405,7 +13236,7 @@ mod tests { #[tokio::test] async fn describe_missing_table_returns_no_such_table() { let db = db(); - let err = db.describe_table("Ghost".to_string(), None).await.unwrap_err(); + let err = db.describe_table("Ghost".to_string()).await.unwrap_err(); match err { DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable), other => panic!("unexpected error: {other:?}"), @@ -13450,7 +13281,7 @@ mod tests { None) .await .unwrap(); - let orders = db.describe_table("Orders".to_string(), None).await.unwrap(); + let orders = db.describe_table("Orders".to_string()).await.unwrap(); assert_eq!(orders.outbound_relationships.len(), 1); let rel = &orders.outbound_relationships[0]; assert_eq!(rel.local_columns, vec!["CustId".to_string()]); @@ -13475,7 +13306,7 @@ mod tests { None) .await .unwrap(); - let customers = db.describe_table("Customers".to_string(), None).await.unwrap(); + let customers = db.describe_table("Customers".to_string()).await.unwrap(); assert_eq!(customers.inbound_relationships.len(), 1); let rel = &customers.inbound_relationships[0]; assert_eq!(rel.local_columns, vec!["id".to_string()]); @@ -13499,7 +13330,7 @@ mod tests { None) .await .unwrap(); - let orders = db.describe_table("Orders".to_string(), None).await.unwrap(); + let orders = db.describe_table("Orders".to_string()).await.unwrap(); let rel = &orders.outbound_relationships[0]; assert_eq!(rel.name, "cust_orders"); assert_eq!(rel.on_delete, ReferentialAction::Cascade); @@ -13538,7 +13369,7 @@ mod tests { ) .await .unwrap(); - let orders = db.describe_table("Orders".to_string(), None).await.unwrap(); + let orders = db.describe_table("Orders".to_string()).await.unwrap(); // The auto-created FK column has user_type Int (Serial's // fk_target_type), not Serial. let cust = orders @@ -13688,8 +13519,8 @@ mod tests { }, None) .await .unwrap(); - let orders = db.describe_table("Orders".to_string(), None).await.unwrap(); - let customers = db.describe_table("Customers".to_string(), None).await.unwrap(); + let orders = db.describe_table("Orders".to_string()).await.unwrap(); + let customers = db.describe_table("Customers".to_string()).await.unwrap(); assert!(orders.outbound_relationships.is_empty()); assert!(customers.inbound_relationships.is_empty()); } @@ -13718,7 +13549,7 @@ mod tests { }, None) .await .unwrap(); - let orders = db.describe_table("Orders".to_string(), None).await.unwrap(); + let orders = db.describe_table("Orders".to_string()).await.unwrap(); assert!(orders.outbound_relationships.is_empty()); } @@ -13766,7 +13597,7 @@ mod tests { // Dropping the child is allowed (no inbound relationships // on Orders) and cleans the metadata. db.drop_table("Orders".to_string(), None).await.unwrap(); - let customers = db.describe_table("Customers".to_string(), None).await.unwrap(); + let customers = db.describe_table("Customers".to_string()).await.unwrap(); assert!(customers.inbound_relationships.is_empty()); } @@ -13832,7 +13663,7 @@ mod tests { // After the rebuild, the original columns are still // present with the right user types, and any extra // metadata (Name on Customers) survives. - let customers = db.describe_table("Customers".to_string(), None).await.unwrap(); + let customers = db.describe_table("Customers".to_string()).await.unwrap(); let names: Vec<&str> = customers.columns.iter().map(|c| c.name.as_str()).collect(); assert_eq!(names, vec!["id", "Name"]); let name_col = customers.columns.iter().find(|c| c.name == "Name").unwrap(); @@ -13867,7 +13698,7 @@ mod tests { // The InsertResult itself carries the just-inserted row. assert_eq!(result.data.rows.len(), 1); assert_eq!(result.data.rows[0][1], Some("Alice".to_string())); - let data = db.query_data("Customers".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("Customers".to_string(), None, None).await.unwrap(); assert_eq!(data.columns, vec!["id".to_string(), "Name".to_string()]); assert_eq!(data.rows.len(), 1); assert_eq!(data.rows[0][1], Some("Alice".to_string())); @@ -13892,7 +13723,7 @@ mod tests { None) .await .unwrap(); - let data = db.query_data("Tags".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("Tags".to_string(), None, None).await.unwrap(); let id = data.rows[0][0].as_ref().expect("auto-generated id"); assert!( id.len() >= 10 && id.len() <= 12, @@ -13911,7 +13742,7 @@ mod tests { None) .await .unwrap(); - let data = db.query_data("Customers".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("Customers".to_string(), None, None).await.unwrap(); assert_eq!(data.rows[0][0], Some("99".to_string())); assert_eq!(data.rows[0][1], Some("Bob".to_string())); } @@ -13957,7 +13788,7 @@ mod tests { // The UpdateResult contains only the updated rows. assert_eq!(result.data.rows.len(), 1); assert_eq!(result.data.rows[0][1], Some("Alicia".to_string())); - let data = db.query_data("Customers".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("Customers".to_string(), None, None).await.unwrap(); assert_eq!(data.rows[0][1], Some("Alicia".to_string())); assert_eq!(data.rows[1][1], Some("Bob".to_string())); } @@ -14053,7 +13884,7 @@ mod tests { // Carol (45/true) and Dave (35/true) match; Bob (35) is // inactive, Alice (25) is too young. assert_eq!(result.rows_affected, 2); - let data = db.query_data("People".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("People".to_string(), None, None).await.unwrap(); assert_eq!(names(&data), vec!["Alice", "Bob"]); } @@ -14070,7 +13901,7 @@ mod tests { .await .unwrap(); assert_eq!(result.rows_affected, 2); - let data = db.query_data("People".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("People".to_string(), None, None).await.unwrap(); assert_eq!(names(&data), vec!["Bob", "Dave"]); } @@ -14088,7 +13919,7 @@ mod tests { .await .unwrap(); assert_eq!(result.rows_affected, 2); - let data = db.query_data("People".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("People".to_string(), None, None).await.unwrap(); assert_eq!(names(&data), vec!["Bob", "Dave"]); } @@ -14106,7 +13937,7 @@ mod tests { .unwrap(); // Bob (35) and Dave (35) are in range. assert_eq!(result.rows_affected, 2); - let data = db.query_data("People".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("People".to_string(), None, None).await.unwrap(); assert_eq!(names(&data), vec!["Alice", "Carol"]); } @@ -14125,7 +13956,7 @@ mod tests { .await .unwrap(); assert_eq!(result.rows_affected, 2); - let data = db.query_data("People".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("People".to_string(), None, None).await.unwrap(); assert_eq!(names(&data), vec!["Bob", "Dave"]); } @@ -14142,7 +13973,7 @@ mod tests { .await .unwrap(); assert_eq!(result.rows_affected, 1, "only Alice matches `A%`"); - let data = db.query_data("People".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("People".to_string(), None, None).await.unwrap(); assert_eq!(names(&data), vec!["Bob", "Carol", "Dave"]); } @@ -14173,7 +14004,7 @@ mod tests { people_table(&db).await; let (filter, limit) = parse_show("show data People where Active = true"); let data = db - .query_data("People".to_string(), filter, limit, None) + .query_data("People".to_string(), filter, limit) .await .unwrap(); assert_eq!(names(&data), vec!["Alice", "Carol", "Dave"]); @@ -14185,7 +14016,7 @@ mod tests { people_table(&db).await; let (filter, limit) = parse_show("show data People limit 2"); let data = db - .query_data("People".to_string(), filter, limit, None) + .query_data("People".to_string(), filter, limit) .await .unwrap(); // `limit` implies an ORDER BY the primary key, so this @@ -14200,7 +14031,7 @@ mod tests { let (filter, limit) = parse_show("show data People where Age >= 35 limit 1"); let data = db - .query_data("People".to_string(), filter, limit, None) + .query_data("People".to_string(), filter, limit) .await .unwrap(); // Three rows match `Age >= 35` (Bob, Carol, Dave); the @@ -14277,7 +14108,7 @@ mod tests { assert!(!plan.rows.is_empty()); // ADR-0028 §1: EXPLAIN QUERY PLAN never executes. let data = db - .query_data("People".to_string(), None, None, None) + .query_data("People".to_string(), None, None) .await .unwrap(); assert_eq!(data.rows.len(), 4, "explain delete must not delete"); @@ -14294,7 +14125,7 @@ mod tests { .unwrap(); let (filter, _) = parse_show("show data People where Active = true"); let data = db - .query_data("People".to_string(), filter, None, None) + .query_data("People".to_string(), filter, None) .await .unwrap(); // Alice, Carol, Dave are still active — nothing ran. @@ -14423,7 +14254,7 @@ mod tests { .await .unwrap(); let data = db - .query_data("People".to_string(), None, None, None) + .query_data("People".to_string(), None, None) .await .unwrap(); assert_eq!(data.rows.len(), 4, "explain delete must not delete"); @@ -14439,7 +14270,7 @@ mod tests { .await .unwrap(); let data = db - .query_data("People".to_string(), None, None, None) + .query_data("People".to_string(), None, None) .await .unwrap(); assert!( @@ -14475,7 +14306,7 @@ mod tests { .await .unwrap(); let data = db - .query_data("People".to_string(), None, None, None) + .query_data("People".to_string(), None, None) .await .unwrap(); assert_eq!(data.rows.len(), 4, "explain insert must not insert"); @@ -14499,7 +14330,7 @@ mod tests { plan.rows, ); let data = db - .query_data("People".to_string(), None, None, None) + .query_data("People".to_string(), None, None) .await .unwrap(); assert_eq!(data.rows.len(), 4, "explain insert-select must not insert"); @@ -14635,7 +14466,7 @@ mod tests { .await .unwrap(); let data = db - .query_data("T".to_string(), None, None, None) + .query_data("T".to_string(), None, None) .await .unwrap(); let tier_idx = data.columns.iter().position(|c| c == "tier").unwrap(); @@ -14698,7 +14529,7 @@ mod tests { ) .await .unwrap(); - let desc = db.describe_table("T".to_string(), None).await.unwrap(); + let desc = db.describe_table("T".to_string()).await.unwrap(); let email = desc.columns.iter().find(|c| c.name == "email").unwrap(); assert!(email.notnull && email.unique, "email keeps NOT NULL + UNIQUE"); let tier = desc.columns.iter().find(|c| c.name == "tier").unwrap(); @@ -14723,7 +14554,7 @@ mod tests { .await .unwrap(); let data = db - .query_data("People".to_string(), None, None, None) + .query_data("People".to_string(), None, None) .await .unwrap(); let idx = data.columns.iter().position(|c| c == "tier").unwrap(); @@ -14768,7 +14599,7 @@ mod tests { ) .await .expect("NOT NULL with no default is fine on an empty table"); - let desc = db.describe_table("T".to_string(), None).await.unwrap(); + let desc = db.describe_table("T".to_string()).await.unwrap(); assert!(desc.columns.iter().find(|c| c.name == "x").unwrap().notnull); } @@ -14783,7 +14614,7 @@ mod tests { ) .await .unwrap(); - let desc = db.describe_table("People".to_string(), None).await.unwrap(); + let desc = db.describe_table("People".to_string()).await.unwrap(); let tier = desc.columns.iter().find(|c| c.name == "tier").unwrap(); assert!(tier.notnull); assert_eq!(tier.default.as_deref(), Some("0")); @@ -14800,7 +14631,7 @@ mod tests { ) .await .expect("a UNIQUE column with no default is fine — NULLs do not collide"); - let desc = db.describe_table("People".to_string(), None).await.unwrap(); + let desc = db.describe_table("People".to_string()).await.unwrap(); assert!(desc.columns.iter().find(|c| c.name == "badge").unwrap().unique); } @@ -14891,7 +14722,7 @@ mod tests { let (n, c, pk) = parse_create("create table T with pk age(int) check (age >= 0)"); db.create_table(n, c, pk, None).await.unwrap(); - let desc = db.describe_table("T".to_string(), None).await.unwrap(); + let desc = db.describe_table("T".to_string()).await.unwrap(); let age = desc.columns.iter().find(|c| c.name == "age").unwrap(); let check = age.check.as_deref().expect("age carries a CHECK"); assert!( @@ -14911,7 +14742,7 @@ mod tests { ) .await .expect("a CHECK column adds via the rebuild path"); - let desc = db.describe_table("People".to_string(), None).await.unwrap(); + let desc = db.describe_table("People".to_string()).await.unwrap(); assert!(desc.columns.iter().find(|c| c.name == "score").unwrap().check.is_some()); // An update that violates the check is refused. let bad = db @@ -14945,7 +14776,7 @@ mod tests { ) .await .unwrap(); - let desc = db.describe_table("T".to_string(), None).await.unwrap(); + let desc = db.describe_table("T".to_string()).await.unwrap(); assert!( desc.columns.iter().find(|c| c.name == "code").unwrap().check.is_some(), "code keeps its CHECK across the rebuild", @@ -15016,7 +14847,7 @@ mod tests { .unwrap(); assert_eq!(result.rows_affected, 1); assert!(result.cascade.is_empty(), "no children to cascade to"); - let data = db.query_data("Customers".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("Customers".to_string(), None, None).await.unwrap(); assert_eq!(data.rows.len(), 1); assert_eq!(data.rows[0][1], Some("Bob".to_string())); } @@ -15123,7 +14954,7 @@ mod tests { None) .await .unwrap(); - let orders = db.query_data("Orders".to_string(), None, None, None).await.unwrap(); + let orders = db.query_data("Orders".to_string(), None, None).await.unwrap(); assert!(orders.rows.is_empty(), "child rows should be cascaded"); } @@ -15223,7 +15054,7 @@ mod tests { None) .await .unwrap(); - let data = db.query_data("Flags".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("Flags".to_string(), None, None).await.unwrap(); assert_eq!(data.rows[0][1], Some("true".to_string())); assert_eq!(data.rows[1][1], Some("false".to_string())); } @@ -15245,7 +15076,7 @@ mod tests { None) .await .unwrap(); - let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("T".to_string(), None, None).await.unwrap(); assert_eq!(data.rows[0][1], None); } @@ -15504,7 +15335,7 @@ mod tests { .await .unwrap(); // Read the schema and confirm `seq` is still unique. - let desc = db.describe_table("T".to_string(), None).await.unwrap(); + let desc = db.describe_table("T".to_string()).await.unwrap(); let seq = desc .columns .iter() @@ -15556,7 +15387,7 @@ mod tests { .unwrap(); let tables = db.list_tables().await.unwrap(); assert_eq!(tables, vec!["Order Lines".to_string()]); - let desc = db.describe_table("Order Lines".to_string(), None).await.unwrap(); + let desc = db.describe_table("Order Lines".to_string()).await.unwrap(); assert_eq!(desc.name, "Order Lines"); } diff --git a/src/dsl/command.rs b/src/dsl/command.rs index 99304a3..66d1933 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -552,6 +552,11 @@ pub enum AppCommand { Help { topic: Option, }, + /// 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", diff --git a/src/dsl/grammar/app.rs b/src/dsl/grammar/app.rs index 8bc4361..1deaea7 100644 --- a/src/dsl/grammar/app.rs +++ b/src/dsl/grammar/app.rs @@ -177,6 +177,9 @@ const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result Result { Ok(Command::App(AppCommand::Undo)) } +const fn build_hint(_path: &MatchedPath, _source: &str) -> Result { + Ok(Command::App(AppCommand::Hint)) +} const fn build_redo(_path: &MatchedPath, _source: &str) -> Result { 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"],}; diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index b111075..752014d 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -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
▮` (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 ` — 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: &[], }; diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index 0167093..69e7774 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -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"], }; diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index f06cf3f..4ba3c1e 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -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.`) 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.` 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; diff --git a/src/dsl/walker/context.rs b/src/dsl/walker/context.rs index 807f41e..6306119 100644 --- a/src/dsl/walker/context.rs +++ b/src/dsl/walker/context.rs @@ -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, + /// 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
▮`, 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 (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()], diff --git a/src/dsl/walker/driver.rs b/src/dsl/walker/driver.rs index e14f04f..231bc36 100644 --- a/src/dsl/walker/driver.rs +++ b/src/dsl/walker/driver.rs @@ -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; diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index 3723f20..99a8e0b 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -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
` 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, + /// An `IntroProse` catalog key for an *optional* positional slot at + /// the cursor that produced no candidate (issue #26 — `seed
` + /// 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>, /// `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: &[], }; diff --git a/src/event.rs b/src/event.rs index 623f299..b71b850 100644 --- a/src/event.rs +++ b/src/event.rs @@ -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), diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index 57e2ed3..989876d 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -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", &[]), diff --git a/src/friendly/mod.rs b/src/friendly/mod.rs index 06a6f0f..4c571f6 100644 --- a/src/friendly/mod.rs +++ b/src/friendly/mod.rs @@ -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 diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 5999ff8..27ae330 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -256,6 +256,8 @@ help: help: |- help — show this command list help — 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.
`) and per-class error + # hints (`hint.err.`), each a `what` (1–2 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
▮` 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 []" + 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: diff --git a/src/friendly/translate.rs b/src/friendly/translate.rs index 74cdb00..aecf487 100644 --- a/src/friendly/translate.rs +++ b/src/friendly/translate.rs @@ -253,6 +253,73 @@ pub fn translate(error: &DbError, ctx: &TranslateContext) -> FriendlyError { fe } +/// The tier-3 hint class (`hint.err.`) 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(), diff --git a/src/input_render.rs b/src/input_render.rs index cf11299..01af66b 100644 --- a/src/input_render.rs +++ b/src/input_render.rs @@ -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 diff --git a/src/output_render.rs b/src/output_render.rs index 24554c4..ed00a6d 100644 --- a/src/output_render.rs +++ b/src/output_render.rs @@ -71,27 +71,22 @@ pub fn render_data_table(data: &DataResult) -> Vec { 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 (``), 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 (``), 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 ` 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 { 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 { 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 { - let mut out: Vec = 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] diff --git a/src/persistence/history.rs b/src/persistence/history.rs index cd0bf13..9fff901 100644 --- a/src/persistence/history.rs +++ b/src/persistence/history.rs @@ -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 { // 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 { 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()]); + } } diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 96ccec7..745d3c4 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -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); diff --git a/src/runtime.rs b/src/runtime.rs index df5b8fb..ca17a3f 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -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, ) { - 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- diff --git a/src/seed/generators.rs b/src/seed/generators.rs index a5d6a99..7e81f86 100644 --- a/src/seed/generators.rs +++ b/src/seed/generators.rs @@ -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 (1950–2025 at REF_YEAR=2025), wide +/// enough for `published` / `founded` / `release_year`; `YearBirth` +/// mirrors the adult birth window (1945–2007). +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() }; diff --git a/src/seed/heuristics.rs b/src/seed/heuristics.rs index d62f78a..3162dd2 100644 --- a/src/seed/heuristics.rs +++ b/src/seed/heuristics.rs @@ -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 Vec { 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. diff --git a/src/seed/mod.rs b/src/seed/mod.rs index 1a4d424..452097b 100644 --- a/src/seed/mod.rs +++ b/src/seed/mod.rs @@ -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, diff --git a/src/snapshots/rdbms_playground__output_render__tests__render_structure_with_relationships.snap b/src/snapshots/rdbms_playground__output_render__tests__render_structure_with_relationships.snap deleted file mode 100644 index f0d764c..0000000 --- a/src/snapshots/rdbms_playground__output_render__tests__render_structure_with_relationships.snap +++ /dev/null @@ -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) diff --git a/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap b/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap index 46a7503..0505791 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap @@ -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 diff --git a/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap b/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap index c49a798..e192380 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap @@ -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 diff --git a/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap b/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap index 4a41ef7..a86c7b3 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap @@ -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 diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap index 7f82289..f429374 100644 --- a/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap +++ b/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap @@ -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 diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap index 7120bbd..86f0ce5 100644 --- a/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap +++ b/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap @@ -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 diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap index d6358c1..e9b9e4a 100644 --- a/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap +++ b/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap @@ -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 diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap index b132bbd..1d2e68a 100644 --- a/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap +++ b/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap @@ -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 diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap index 9d2184d..b3e064d 100644 --- a/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap +++ b/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap @@ -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 diff --git a/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap index 34a6f6a..0c1353e 100644 --- a/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap @@ -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
[([, ...])] [values] ([, ...]) │ ╰──────────────────────────────────────────────────────────────────────────────╯ 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 diff --git a/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap b/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap index 57a76f8..feeda07 100644 --- a/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap @@ -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 diff --git a/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap b/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap index 5afcd79..99c972e 100644 --- a/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap @@ -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 diff --git a/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap b/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap index 012b295..bebe44f 100644 --- a/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap @@ -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 diff --git a/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap b/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap index 2b36e30..f396dff 100644 --- a/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap @@ -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 diff --git a/src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap b/src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap index 3840ae1..87afd3b 100644 --- a/src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap @@ -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 diff --git a/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap b/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap index 9168780..88166eb 100644 --- a/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap @@ -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 diff --git a/src/ui.rs b/src/ui.rs index 409cf9e..c50e95d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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> = Vec::new(); - - let push_shortcut = |spans: &mut Vec>, 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::>() + .join(" · ") + } + + fn hint_text(lines: &[Line<'_>]) -> String { + lines + .iter() + .map(|l| l.spans.iter().map(|s| s.content.clone()).collect::()) + .collect::>() + .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 diff --git a/tests/it/case_insensitive_names.rs b/tests/it/case_insensitive_names.rs index 6b644d6..fe28129 100644 --- a/tests/it/case_insensitive_names.rs +++ b/tests/it/case_insensitive_names.rs @@ -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"); } diff --git a/tests/it/compound_fk.rs b/tests/it/compound_fk.rs index 3ca22df..7ab6000 100644 --- a/tests/it/compound_fk.rs +++ b/tests/it/compound_fk.rs @@ -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 diff --git a/tests/it/iteration2_persistence.rs b/tests/it/iteration2_persistence.rs index 22b70f1..0260113 100644 --- a/tests/it/iteration2_persistence.rs +++ b/tests/it/iteration2_persistence.rs @@ -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 { - 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] diff --git a/tests/it/iteration3_rebuild.rs b/tests/it/iteration3_rebuild.rs index 4361dfb..922811d 100644 --- a/tests/it/iteration3_rebuild.rs +++ b/tests/it/iteration3_rebuild.rs @@ -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> = 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"); diff --git a/tests/it/iteration4a_rebuild_command.rs b/tests/it/iteration4a_rebuild_command.rs index 311ad07..ed64a62 100644 --- a/tests/it/iteration4a_rebuild_command.rs +++ b/tests/it/iteration4a_rebuild_command.rs @@ -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. } diff --git a/tests/it/iteration5_export_import.rs b/tests/it/iteration5_export_import.rs index 09a79fb..62ccee1 100644 --- a/tests/it/iteration5_export_import.rs +++ b/tests/it/iteration5_export_import.rs @@ -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. diff --git a/tests/it/iteration6_resume_history.rs b/tests/it/iteration6_resume_history.rs index 49b47b2..d4fe1d4 100644 --- a/tests/it/iteration6_resume_history.rs +++ b/tests/it/iteration6_resume_history.rs @@ -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(); diff --git a/tests/it/m2n.rs b/tests/it/m2n.rs index 972a8b3..189c37d 100644 --- a/tests/it/m2n.rs +++ b/tests/it/m2n.rs @@ -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"); }); diff --git a/tests/it/replay_command.rs b/tests/it/replay_command.rs index d003ebf..d5ccb33 100644 --- a/tests/it/replay_command.rs +++ b/tests/it/replay_command.rs @@ -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 = 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)"); } diff --git a/tests/it/seed.rs b/tests/it/seed.rs index a4a1bc2..ed389b1 100644 --- a/tests/it/seed.rs +++ b/tests/it/seed.rs @@ -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, Vec>) { + let mut lines = csv.lines().filter(|l| !l.trim().is_empty()); + let header: Vec = lines.next().unwrap().split(',').map(str::to_string).collect(); + let rows: Vec> = + lines.map(|l| l.split(',').map(str::to_string).collect()).collect(); + (header, rows) +} + +fn column_values(csv: &str, col: &str) -> Vec { + 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 1–5"); + } +} + +#[test] +fn seed_column_fill_uses_choice_set_heuristic() { + // The `seed
.` 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 diff --git a/tests/it/show_list.rs b/tests/it/show_list.rs index f3750e7..a5f9c69 100644 --- a/tests/it/show_list.rs +++ b/tests/it/show_list.rs @@ -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(); diff --git a/tests/it/sql_alter_table.rs b/tests/it/sql_alter_table.rs index 70c1a97..401b097 100644 --- a/tests/it/sql_alter_table.rs +++ b/tests/it/sql_alter_table.rs @@ -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 { - 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 Vec { - 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!( diff --git a/tests/it/sql_create_index.rs b/tests/it/sql_create_index.rs index 1e24518..442022d 100644 --- a/tests/it/sql_create_index.rs +++ b/tests/it/sql_create_index.rs @@ -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, 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] diff --git a/tests/it/sql_create_table.rs b/tests/it/sql_create_table.rs index 0d07f54..e2e6474 100644 --- a/tests/it/sql_create_table.rs +++ b/tests/it/sql_create_table.rs @@ -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)> = 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> { 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> = 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::>(), @@ -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( diff --git a/tests/it/sql_delete.rs b/tests/it/sql_delete.rs index 43f3a23..8f95463 100644 --- a/tests/it/sql_delete.rs +++ b/tests/it/sql_delete.rs @@ -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(); diff --git a/tests/it/sql_dml_e2e.rs b/tests/it/sql_dml_e2e.rs index 313d643..d316c08 100644 --- a/tests/it/sql_dml_e2e.rs +++ b/tests/it/sql_dml_e2e.rs @@ -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>> { - 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 } diff --git a/tests/it/sql_drop_index.rs b/tests/it/sql_drop_index.rs index fd4dd3a..6ae5127 100644 --- a/tests/it/sql_drop_index.rs +++ b/tests/it/sql_drop_index.rs @@ -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 { - 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] diff --git a/tests/it/sql_drop_table.rs b/tests/it/sql_drop_table.rs index a94195f..5ea6550 100644 --- a/tests/it/sql_drop_table.rs +++ b/tests/it/sql_drop_table.rs @@ -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"); } diff --git a/tests/it/sql_insert.rs b/tests/it/sql_insert.rs index 4afe14c..fdd71ec 100644 --- a/tests/it/sql_insert.rs +++ b/tests/it/sql_insert.rs @@ -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 diff --git a/tests/it/sql_select.rs b/tests/it/sql_select.rs index eb79c9f..c3c31d4 100644 --- a/tests/it/sql_select.rs +++ b/tests/it/sql_select.rs @@ -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:?}", - ); -} diff --git a/tests/it/sql_update.rs b/tests/it/sql_update.rs index b82ba3b..88ef502 100644 --- a/tests/it/sql_update.rs +++ b/tests/it/sql_update.rs @@ -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"); diff --git a/tests/it/undo_snapshots.rs b/tests/it/undo_snapshots.rs index d78ae8e..c0ce5f2 100644 --- a/tests/it/undo_snapshots.rs +++ b/tests/it/undo_snapshots.rs @@ -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 diff --git a/tests/it/walking_skeleton.rs b/tests/it/walking_skeleton.rs index 98b8400..40cea3c 100644 --- a/tests/it/walking_skeleton.rs +++ b/tests/it/walking_skeleton.rs @@ -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!( diff --git a/tests/typing_surface/mod.rs b/tests/typing_surface/mod.rs index 53bef3b..31e7229 100644 --- a/tests/typing_surface/mod.rs +++ b/tests/typing_surface/mod.rs @@ -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(),