diff --git a/docs/adr/0045-mn-convenience.md b/docs/adr/0045-mn-convenience.md new file mode 100644 index 0000000..a1d3f01 --- /dev/null +++ b/docs/adr/0045-mn-convenience.md @@ -0,0 +1,300 @@ +# ADR-0045: `create m:n relationship` convenience command (C4) + +## Status + +Accepted (2026-06-10); **implemented 2026-06-10**. Closes +`requirements.md` **C4**. + +**Implementation note — a corrected ADR premise.** The plan claimed +"the walker already dispatches multiple nodes per entry word" and used +that to justify a *separate* `CREATE_M2N` node. That is true only in +**advanced** mode. The build hit **two** places hard-coded to assume +**≤1 DSL form per entry word in simple mode**: (1) the dispatcher +(`decide`) committed `simple.first()` unconditionally, so `create +table` shadowed `create m:n`; (2) the completion continuation-merge was +gated `if mode == Advanced`, so simple mode never surfaced `m:n` as a +candidate. Both were generalized to support multiple DSL forms per +entry word — **behaviour-preserving for every existing single-form +command** (the dispatch reduces to the old single-candidate commit; the +completion merge is gated on `simple_count > 1`). Verified: zero ripple +beyond the new command's own surfaces. The teaching echo (advanced-mode +DSL→SQL, ADR-0038) was also wired: `render_create_m2n` emits the +generated `CREATE TABLE … FOREIGN KEY …` from the post-exec junction +description (round-trips as valid SQL). + +A second `/runda` DA pass (pre-commit) closed five coverage gaps +(highlighting, persistence round-trip, junction rename, name-collision, +missing-parent — the first two had been wrongly claimed verified) and +found two more issues: (a) `create m:n … as __rdbms_*` was accepted — +a hidden-orphan hole the new `as` slot **exposed**, but rooted in the +simple-mode `TABLE_NAME_NEW` slot having no internal-name guard (so +plain `create table __rdbms_*` had it too). Fixed at the **root** — +a `reject_internal_table_name` guard in the shared `do_create_table`, +closing every path (the advanced-SQL path already rejected at parse). +(b) the usage disambiguator (`usage_key_for_input`) handled the `1:n` +opener but not `m:n`, so `create m:n …` resolved to no usage form — +fixed with an explicit `m:n` branch. All four +design forks were escalated and user-confirmed at the recommended +option (compound-over-FKs junction PK; `CASCADE` actions; auto-name + +optional `as`; both modes). Two follow-up points were also confirmed +in a `/runda` DA pass: **self-referential m:n is refused outright** +(user: "refuse — full stop"; it is a beginner-facing convenience, not +the place for directional-naming complexity), and the FK column naming +is **`{parent_table}_{pk_column}`**. The DA pass additionally +established — against the user's initial assumption — that **PK-less +tables *are* reachable** (advanced-mode SQL `create table t (a int)` +declares no PK; `sql_create_table.rs` asserts `pk.is_empty()`), so the +parent-PK guard (D7) is retained as a correctness check. + +Builds on ADR-0013 (named 1:n relationships, the relationship +metadata table, the rebuild-table primitive), ADR-0043 (compound, +list-based FK references — the junction may reference compound parent +PKs), ADR-0011 (`Type::fk_target_type()` for FK column typing), and +the existing `do_create_table` executor (which already accepts +`foreign_keys: Vec` and writes relationship metadata +per FK). Honours ADR-0003 (mode model), ADR-0009 (DSL conventions), +ADR-0002 (no engine name in user-facing strings), and ADR-0024 +(unified grammar / `CommandNode` registration, completion, hints, +help-id, usage-id wiring). + +## Context + +A many-to-many relationship is modelled in a relational database by a +**junction table** (a.k.a. associative / bridge table) that holds one +foreign key to each of the two parents. Today a learner can build this +by hand: `create table` the junction, then `add 1:n relationship` +twice. That is three commands and requires the learner to already know +the junction-table pattern — exactly the concept C4 is meant to +*teach*. + +C4 (`requirements.md`): *"`create m:n relationship from to ` +produces an auto-named junction table the user can rename; pulls +primary keys and FK definitions automatically."* + +The relationship machinery this builds on is freshly solid: ADR-0043 +made the relationship model list-based (compound-aware) across six +layers, and ADR-0044 gave relationships a visual representation. C4 is +the natural convenience layer on top. + +## Decision + +Add a DSL command: + +``` +create m:n relationship from to [as ] +``` + +It generates a **junction table** with one FK column per primary-key +column of each parent, a **compound primary key** over all of those FK +columns, and **two 1:n relationships** (junction → T1, junction → T2), +all in a single transaction (= one undo step). The junction is a +normal table: `rename table`, `drop table`, `show table`, `insert`, +etc. all work on it afterward. + +### D1 — Junction primary key: compound over the FK columns (fork, user-chosen) + +The junction's PK is the **combination of all its FK columns**. For +`create m:n relationship from Students to Courses` (both PK `id`): + +``` +Students_Courses + Students_id int ┐ PRIMARY KEY (Students_id, Courses_id) + Courses_id int ┘ + FOREIGN KEY (Students_id) REFERENCES Students(id) + FOREIGN KEY (Courses_id) REFERENCES Courses(id) +``` + +This is the textbook junction: the `(Students_id, Courses_id)` pair is +unique, so a student cannot be linked to the same course twice. It is +the most pedagogically correct model and needs no surrogate key. + +*Rejected:* a surrogate `serial` PK + `UNIQUE` over the FK pair (adds a +key the learner did not ask for); two FK columns with no PK (allows +duplicate links — wrong lesson). + +### D2 — Referential actions: `CASCADE` on delete and update (fork, user-chosen) + +Both generated FKs default to `ON DELETE CASCADE ON UPDATE CASCADE`. A +junction row is meaningless without both ends, so deleting a parent +(a Student or a Course) removes its link rows automatically — the +natural junction semantics, and a clean teaching demonstration of +cascade. There is no syntax in this command to override the actions; +the learner who wants different actions builds the junction by hand +(or drops + re-adds a relationship). This keeps the convenience +command convenient. + +### D3 — Naming: auto-name `_`, optional `as ` (fork, user-chosen) + +The junction table is auto-named `{T1}_{T2}` (e.g. `Students_Courses`). +An optional `as ` clause overrides it — consistent with +`add 1:n relationship [as ]` and saving a follow-up +`rename table`. The two generated relationships are auto-named by the +existing relationship-name resolver (e.g. +`Students_id_to_Students_Courses_Students_id`), exactly as `create +table` with inline FKs already names them. + +### D4 — FK column naming: `{parent_table}_{pk_column}` + +Each FK column is named `{parent_table}_{pk_column}` — one per PK +column of each parent. This disambiguates the common case where both +parents share a PK column name (both `id` → `Students_id`, +`Courses_id`), and generalises to compound parent PKs: a parent +`Sections(course_id, term)` contributes `Sections_course_id` and +`Sections_term`. Column types come from each parent PK column's +`Type::fk_target_type()` (ADR-0011): `serial → int`, `shortid → text`, +others identity — so the junction columns are plain storable types, +never auto-generating. + +### D5 — Mode availability: both simple and advanced + +`create m:n relationship` is a `CommandCategory::Simple` DSL command, +reachable in **both** input modes — the same posture as the sibling +relationship commands (`drop relationship … Mode::Advanced` is a tested +path today). There is no SQL spelling for it; an advanced-mode user who +prefers raw SQL still has `CREATE TABLE` + `FOREIGN KEY`. The command +is purely additive teaching sugar, so making it available everywhere is +harmless and consistent. + +### D6 — Implementation: one `do_create_table` call, not a batch + +The junction table and both relationships are created by **building a +single `Command`/worker request that `do_create_table` already +handles**: `do_create_table` accepts `columns`, `primary_key`, and +`foreign_keys: Vec`, and already inserts relationship +metadata for each FK. So the executor: + +1. Canonicalises `T1`, `T2` (`require_canonical_table`) and reads each + parent's PK (`read_schema().primary_key`). **D7 — parent-PK guard:** + if either parent's `primary_key` is empty, error with a friendly + "`` has no primary key, so it cannot anchor an m:n + relationship" *before* building any FK. This is a real case — a + table created via advanced-mode SQL `create table t (a int)` has no + PK (`sql_create_table.rs` asserts `pk.is_empty()` for that form) — + not a theoretical one, so the guard is a correctness requirement, + not defensive padding. +2. Builds the junction `ColumnSpec`s (one per parent PK column, typed + via `fk_target_type`), the compound `primary_key` list, and two + `SqlForeignKey` values (`on_delete = on_update = Cascade`). +3. Calls the create-table path, which creates the table + both FKs + + all metadata in **one transaction** — naturally one undo step, no + `BeginBatch`/`EndBatch` bracketing needed. + +This reuses the most-tested machinery and inherits its persistence, +metadata, and FK-validation behaviour for free. + +A new typed command variant `Command::CreateM2nRelationship { t1, t2, +name }` carries the parsed form; the runtime/executor expands it to the +junction definition. (We do **not** lower it to `Command::CreateTable` +at parse time — keeping a distinct command preserves command identity +per the X5 "unique commands for every unique case" principle, and lets +the teaching echo speak in m:n terms.) + +## Grammar, AST, and cross-cutting wiring + +A new command is not done when it parses — it must light up every +surface a learner touches. Enumerated here so none is missed +(verification in **Testing**). + +- **Grammar node** `CREATE_M2N` (`ddl.rs`): a separate `CommandNode` + with `entry: create`, shape `m:n relationship [as ] from + to `, registered in `REGISTRY` as `CommandCategory::Simple`. A + separate node (not a branch inside `CREATE`) keeps the tested + create-table builder untouched; the walker already dispatches + multiple nodes per entry word. The `m:n` opener mirrors `1:n` + (`Word("m")`, `Punct(':')`, `Word("n")`). +- **AST builder** `build_create_m2n` → `Command::CreateM2nRelationship`. +- **Command + worker plumbing:** `Command::CreateM2nRelationship` + variant; `Request::CreateM2nRelationship`; runtime + `execute_command_typed` arm; `Database::create_m2n_relationship` + public API; executor `do_create_m2n_relationship` (per D6). +- **Completion:** add `("m", "m:n")` to `COMPOSITE_CANDIDATES` + (`completion.rs`) so Tab on `create m` offers `m:n` as one fluent + piece (exactly as `("1", "1:n")` does for `add`). Identifier slots + for ``/`` inherit table-name completion from the walker's + `IdentSource::Tables` automatically. +- **Hints:** set `HintMode`s on the `CREATE_M2N` nodes so the ambient + hint panel guides `from` / table / `to` / table / optional `as`, + matching the `add 1:n relationship` hinting. +- **Highlighting:** automatic — `walker/highlight.rs` is grammar-driven + with no per-command special-casing; verify the line highlights. +- **Help:** `help_id: Some("ddl.create_m2n")` → the command appears in + `help` automatically (REGISTRY iteration) and under `help create`; + add the `help.ddl.create_m2n` catalog string. +- **Usage / parse errors:** `usage_ids: &["parse.usage.create_m2n"]` + → a malformed `create m:n …` shows the form in the usage block; add + the `parse.usage.create_m2n` catalog string. Add the near-miss cases + to the `parse_error_pedagogy` matrix (ADR-0042) for the `create` + entry word. +- **Teaching echo** (`echo.rs` + `build_schema_echo`): a + `CreateM2nRelationship` arm that echoes the generated junction — + the `create table` it built plus the two `FOREIGN KEY` lines — so the + learner sees the pattern the convenience expanded to. +- **Structure render:** the executor returns the junction's + `TableDescription`; the ADR-0044 render path already draws its + relationships as diagrams on the create echo? No — incidental + `create table` echoes keep prose (ADR-0044 reach); the m:n echo shows + the junction structure with its outbound FKs in the standard prose + form. (A future enhancement could draw both relationships as + diagrams; out of scope here.) + +## Genuine forks (escalated, all resolved 2026-06-10) + +1. **Junction PK** — compound-over-FKs (chosen) vs surrogate serial + + UNIQUE vs no PK. → D1. +2. **Referential actions** — `CASCADE` (chosen) vs `NO ACTION` vs + `RESTRICT`. → D2. +3. **Naming** — auto-name + optional `as` (chosen) vs auto-name only. + → D3. +4. **Mode** — both (chosen by default, unobjected) vs simple-only. → D5. + +## Testing + +Integration (`tests/it/`), test-first: + +- **Functional:** `create m:n relationship from A to B` creates table + `A_B` with the two FK columns, compound PK over them, and two + enforced FKs; inserting a junction row with a non-existent parent is + refused; the `(fk1, fk2)` pair is unique (duplicate link refused). +- **`as `** overrides the junction name. +- **Compound parent PK:** a parent with a 2-column PK contributes two + FK columns; the junction PK spans all of them; FKs enforce per pair. +- **Cascade:** deleting a parent row removes its junction rows. +- **Undo:** one `create m:n` is exactly one undo step (table + both + relationships gone after `undo`). +- **Persistence round-trip:** the junction + both relationships survive + a save → rebuild-from-text. +- **Errors:** missing parent table; parent without a PK; junction-name + collision with an existing table; (self-m:n → OOS error, below). + +Cross-cutting (the surfaces a new command must light up): + +- **Completion:** Tab after `create ` surfaces `m:n relationship`; + table-name completion fires at the ``/`` slots. +- **Help:** `help` lists the command; `help create` includes the m:n + form; the catalog string renders. +- **Usage / parse pedagogy:** a bare/half `create m:n` shows the usage + block; near-miss matrix entries added (`parse_error_pedagogy`). +- **Hints + highlighting:** ambient hint progression through the form; + the line highlights (snapshot or assertion as the sibling commands + use). + +All tiers green, zero skips; clippy clean (nursery). + +## Out of scope + +- **Self-referential m:n** (`from T to T`) — **refused outright** + (user-confirmed, "full stop"): the two FK column sets would collide + on `{T}_{pkcol}`, and directional disambiguation (`from_*`/`to_*`) + is more complexity than this beginner-facing convenience warrants. + The executor detects `t1 == t2` (on the canonical names) and errors + with a friendly pointer ("an m:n relationship needs two different + tables — to link a table to itself, add the junction by hand"). + Not a deferred follow-up; a deliberate non-goal. +- **Per-relationship action overrides** in the command syntax (D2 fixes + `CASCADE`); use a hand-built junction for other actions. +- **Extra junction columns** (payload attributes on the link, e.g. an + enrolment date) — add them afterward with `add column`. +- **m:n visualization as diagrams** on the create echo (ADR-0044 reach + keeps incidental create echoes in prose). +- **Renaming the auto-generated *relationships*** (only the table is + `as`-nameable); drop + re-add covers it. diff --git a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md new file mode 100644 index 0000000..3305a11 --- /dev/null +++ b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md @@ -0,0 +1,556 @@ +# ADR-0046: Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20 / #21 / #23) + +## Status + +Accepted (2026-06-10); **implemented 2026-06-10**, phased **A → B → C** +(see *Decision — phasing*) across commits `9f5f76b` (DA1/DA2) · `e0b9470` +(DA3) · `41bae99` (DA4) · `386627a` (DB1) · `94825d0` (DB2/DB4) · +`c9da6ff` (DC1/DC2/DC4) · `22bec61` (DC3 + DC2 refinement). Closes Gitea +issues **#20** (hint-panel height jumpiness), **#21** (database-structure +/ left-column improvements), and **#23** (long command input). Issue +#23's own note ("handle after #21 is decided") is honoured: the input +work is split so the part that depends on the sidebar's width budget +lands with it. + +Two decisions landed differently from the original draft and are +recorded inline: the relationship data lives on **`App`, not +`SchemaCache`** (DB2), and the navigation overlay clears **only the +sidebar strip + a one-column gutter** (panels stay visible behind), +not the whole area (DC2). + +Builds on and honours: **ADR-0003** (the persistent Simple/Advanced +mode model — navigation mode is *not* a third input mode, see DC1), +**ADR-0027** (the input validity indicator's reserved 6 right columns — +horizontal scroll and 2-line display preserve that reserve), +**ADR-0044** (relationship visualization — the relationships panel +renders the same `RelationshipSchema` data the `show relationship` +diagram already consumes), **ADR-0013 / ADR-0043** (the +`RelationshipSchema` model: name, parent/child tables, list-based +compound columns, referential actions), **ADR-0015** (project file +format — sidebar visibility is **session-only**, so the format is +untouched), and **ADR-0002** (no engine name in user-facing strings). +Preserves the **pure-render-from-`App`-state** invariant (CLAUDE.md): +visual changes here are driven either by new `App` *state* fields +(mutated in `update()`) or by pure *render-time* functions of the frame +geometry (see State section); `update()` stays pure-sync. + +**Requirements & issues touched (verified against `requirements.md`).** +Evolves **S1** (the always-present three-region layout — the left items +region becomes width-optional, DB1). **Overrides S2**, which planned +additional element kinds as *nested* items in the tables list; +relationships get their own panel instead (DB2/DB4 — see Genuine forks +§11). **Corrects S4**: its "keyboard-toggleable hint area" was never +implemented (no toggle keybinding exists in the code) and is not wanted +— the hint panel became indispensable once completion moved into it +(ADR-0022) — so the toggle phrase is struck from `requirements.md` and +no toggle is added here. Extends **I1a** (single-line cursor editing → +horizontal scroll, DA3) and honours **S6 / ADR-0027** (the 6-column +validity-indicator reserve, DA3/DA4). The PageUp/PageDown context-rebind +(DC3) does **not** regress **V4**'s output scroll, which stays live in +input mode. Adjacent but separate: Gitea **#22** (an in-app +overlay/annotation layer for casts and guided lessons — its own ADR) +shares the overlay-render and screencast context with DC2's `Clear` +overlay; the two are meant to coexist, not merge. + +## Context + +Three UI issues were raised together because they are coupled through +the terminal's width and height budget; treating them as one decision +avoids three conflicting partial fixes. + +**Current layout (verified in `src/ui.rs`).** `render()` splits +vertically into `Min(8)` main / `Length(1)` project label / `Length(1)` +status. The main area splits horizontally into a **fixed +`Length(28)`** left column (`render_items_panel`, a "Tables" list with +indented index names) and a `Min(20)` right column. The left column's +block has `Borders::ALL`, so its **usable inner width is 26 columns** +(28 − 2 borders). The right column splits vertically into output +(`Min(5)`), input (`Length(3)`), and hint (`Length(hint_content)`). + +**#20 — hint jumpiness.** `hint_content` is recomputed **every frame** +as `clamp(wrapped_lines, 1, MAX_HINT_ROWS=3) + 2`, i.e. 3–5 rows. As the +user types and hint strings appear, grow, and vanish, the hint panel +resizes and **shoves the input and output panels**, producing the +flicker visible in screencasts. The root cause is that height tracks +*content* rather than terminal *geometry*. + +The hint catalog (`src/friendly/strings/en-US.yaml`) was measured: the +two longest strings are `value_literal_slot` (106 chars) and +`create_table_element` (102); four more are 50–57; the rest ≤ 50. The +wrapping consequence is sharp: at a right-column inner width ≥ ~54 +columns the worst string needs **at most 2 lines**; a **3rd line is +only ever required when the right column is narrower than ~54** (a +sub-~83-column terminal *with the sidebar shown*, or a sub-~55-column +terminal). On the project's screencasts (90 columns wide, sidebar +hidden — see DB1) two lines are provably sufficient. + +**#21 — the left column.** A persistent 26-column Tables list is rarely +filled even half-way by a teaching database, yet it permanently costs +horizontal space the output and input panels want — acutely so on the +90-column screencasts. The pedagogical value of an *always-visible* +schema overview is real (CLAUDE.md "pedagogy wins ties"), so the column +is **kept but made optional and more useful**, not deleted. + +**#23 — long input.** The command input is a **single logical `String`** +rendered by a `Paragraph` with no wrap and no horizontal scroll +(`render_input_panel`); text past the panel width **clips silently**. +The cursor is a byte offset on a char boundary; Up/Down drive history. +The fix needs the width the sidebar's removal frees, hence the coupling. + +**Keybinding space (verified).** Taken: Tab/Shift-Tab (completion), +Enter (submit), Up/Down (history), Left/Right/Home/End (cursor), +PageUp/PageDown (output scroll), Backspace/Delete, Esc +(completion-undo / modal cancel), Ctrl-C (quit). Reserved-but-deferred +(I1b readline): Ctrl-A/E/W/K/U. Printable keys all route to the input. +Terminal-hijacked and therefore unusable: Ctrl-S/Q (flow control), +Ctrl-Z (suspend), Ctrl-H (backspace), Ctrl-I/M (Tab/Enter), Ctrl-G +(BEL). This leaves a narrow band of safe combinations for new controls. + +## Decision — phasing + +The work ships in three phases so the screencasts benefit from the +least-controversial part first and the riskiest part (the focus/scroll +model) is isolated: + +- **Phase A — input & hint (DA1–DA4):** self-contained, no sidebar + dependency; fixes #20 and the baseline of #23. +- **Phase B — optional, richer sidebar (DB1–DB3):** visibility model + + relationships panel + schema-cache enrichment. +- **Phase C — navigation mode (DC1–DC4):** the Ctrl-O focus/scroll/ + expand model that makes the sidebar browsable. + +Each phase is independently shippable and independently green. + +## Decision — Phase A: responsive input & hint heights + +### DA1 — Hint height is a function of terminal geometry, fixed between resizes + +The hint panel's height is **decoupled from hint content**. It is +computed from the terminal's width and height **once per resize** and +held constant as the user types. Because the panel no longer resizes on +every keystroke, it never shoves the input/output panels — the #20 jump +is eliminated at the source, not damped. Content that exceeds the fixed +height is ellipsized (the existing `clamp_wrapped` truncation), which is +now a rare, width-driven event rather than a per-keystroke one. + +### DA2 — Responsive height buckets + +Heights are chosen by terminal **height** (rows), with the hint's +optional 3rd line gated on right-column **width** (per the Context +measurement): + +| Terminal height | Input content rows | Hint content rows | +| --- | --- | --- | +| **Compact** (`H < 40` — covers the 25-row screencasts) | 1 (+ horizontal scroll, DA3) | 2 | +| **Comfortable** (`H ≥ 40` — fullscreen terminals) | 2 (soft-wrap, DA4) | 2 (→ 3 only if right-column inner < ~54) | + +A safety degradation protects tiny terminals: the output panel's +`Min(5)` is honoured first; if rows are insufficient, the hint shrinks +to 1, then the input to 1. The `40`-row threshold is a tunable constant. + +### DA3 — Input horizontal scroll (single logical line) + +The input keeps its **single-`String`** model (no embedded newlines — +this is explicitly *not* multi-line input, see Out of scope). A new +`App` field `input_scroll_offset: usize` tracks the first visible +column; the renderer shows a window of the line and keeps the cursor in +view, mirroring the candidate-line horizontal-scroll markers already in +`render_candidate_line`. The ADR-0027 6-column indicator reserve is +preserved (the scroll window is the text area = `inner.width − 6`, not +the full inner width). Because `update()` does not know the panel width, +the renderer feeds it back via a `note_input_viewport(text_width)` call +(the analogue of the existing `note_output_viewport`), against which the +offset is clamped to keep the cursor visible. `input_scroll_offset` +**resets to 0** whenever the buffer is replaced wholesale — on `submit`, +on history navigation (Up/Down), and on any clear. This is the baseline +#23 fix and is sufficient on its own for the compact (1-row) layout. + +### DA4 — Two-line input display when tall (`H ≥ 40`) + +On comfortable terminals the input renders across **2 visual rows** by +soft-wrapping the single logical line, with the cursor mapped to a +(row, col) within the two rows. Content longer than two rows scrolls +the two-row window horizontally (DA3) so the cursor stays visible. The +**ADR-0027 `[ERR]`/`[WRN]` indicator stays anchored to the right edge +of the *first* row** (its 6-column reserve applies to row 1; the soft- +wrap on row 1 stops 6 columns short, row 2 uses the full text width) — +S6 is preserved. + +This is display-only over the same single-`String` model — distinct +from the deferred true multi-line-input feature (I1, which adds +*multiple logical lines* with Enter-inserts-newline). **Forward-compat +note:** I1, when built, should reuse DA4's row-rendering and cursor +(row, col) mapping rather than introduce a parallel one — DA4 is the +substrate, not a competitor. + +## Decision — Phase B: optional, richer sidebar + +### DB1 — Width-derived visibility plus transient peek (session-only) + +Sidebar visibility is **derived, not stored**: the sidebar is visible +iff the terminal **width > 90** *or* navigation mode is currently +focused on a sidebar panel (the Ctrl-O peek, DC1). It is recomputed +every frame from terminal width and `NavFocus`; nothing persists to +`project.yaml` (ADR-0015 untouched), so it is session-only by +construction — and there is no stored visibility field to keep in sync. + +At ≤ 90 columns the sidebar is hidden by default — so the 90-column +screencasts never show it and the output panel gets the full width it +needs there — but `Ctrl-O` temporarily reveals it for the duration of a +browse and re-hides it on exit (DC1). + +**No persistent show/hide toggle (resolved 2026-06-10, user).** Issue +#21's original wording asked for "a keystroke to show and hide it"; the +Ctrl-O peek covers that need, so no separate toggle and no +force-shown/force-hidden override is added. Visibility stays a pure +function of `(terminal width, NavFocus)` — the simplest model that +satisfies the requirement. Should pinning ever prove necessary, a +persistent override is an additive follow-up (see Out of scope). + +### DB2 — Add a relationships panel; enrich the schema cache + +The left column gains a **second panel** below Tables: a list of the +project's relationships. This is a deliberate **override of S2**, whose +note proposed additional element kinds (relations, views) as *nested* +items inside the existing tables list. Relationships are *cross-table*, +not per-table, so nesting them under a single table reads wrong; a +sibling panel is the honest shape (user-confirmed 2026-06-10). S2's +"without restructuring" intent is still met — the items column simply +holds two stacked panels (DB4) instead of one. + +The panel needs the full `RelationshipSchema` (name, parent/child +tables, list-based columns, on-delete/on-update actions) that the `show +relationship` path already fetches. + +**Data home — `App`, not `SchemaCache` (revised at implementation, +2026-06-10).** The design first proposed an additive +`SchemaCache.relationship_details: Vec` field. +Implementation revised this to a **parallel `App.relationships: +Vec`** field for two reasons: (1) `SchemaCache` is +*walker/completion-facing* — it needs only relationship **names** +(unchanged in `SchemaCache.relationships`, still borrowed as +`&Vec` by `IdentSource::Relationships`); the full records are +**UI-only**, so `App` is the architecturally correct home, mirroring +`app.tables` (which the items panel already reads alongside the cache). +(2) Adding a field to `SchemaCache` would force edits to ~23 full +struct literals across the test suite, whereas `App` gains one field. +The /runda guard it answered — *don't break completion by retyping +`relationships`* — is fully honoured either way. Delivery: a worker +`Request::ReadAllRelationships` (→ `Database::read_all_relationships`, +returning `Vec` via the existing +`read_all_relationships(conn)`); the runtime's `refresh_schema_cache` +posts a new `AppEvent::RelationshipsRefreshed` alongside +`SchemaCacheRefreshed`, and the `App` stores it. No behavioural +difference from the original design. + +The panel has **two display states** keyed off focus (DC2): + +- **Unfocused (26-col)** — an ambient glance. Per relationship: the + name (ellipsized past the inner width), and the endpoints broken at + the arrow to fit a narrow column: + + ``` + Customers_Orders + Customers.id -> + Orders.customer_id + ``` + +- **Focused + expanded (40–50 col, DC2)** — a browse view. At the wider + width the endpoints fit on one line + (`Customers.id -> Orders.customer_id`); the arrow-break is used only + when even the expanded width cannot hold a (possibly compound) + endpoint pair. The wider width minimises horizontal truncation so the + panel needs **mainly vertical scrolling** (DC3). + +### DB3 — Sidebar width unchanged when unfocused + +The unfocused sidebar keeps `Length(28)` / 26 inner columns. Widening +happens only on focus (DC2), as an overlay, so the unfocused layout and +the right-column reflow are unchanged from today. + +### DB4 — Vertical split of the two left-column panels + +The items column stacks **Tables (top)** and **Relationships (bottom)**. +The Relationships panel's height is content-driven within bounds, so it +stays small when there is little to show and never dominates the column +(user-chosen 2026-06-10): + +- **No relationships:** fixed at **5 rows** (3 content + 2 border), + rendering a single `None` line. This is the floor. +- **With relationships:** grows with content (`content_rows + 2`, where + the unfocused format is ~3 rows per relationship) up to a **cap of + 50 % of the column height**; beyond the cap the panel **scrolls** + (DC3). Formally `rel_h = clamp(content_rows + 2, 5, ⌊col_h / 2⌋)`. +- **Tables** takes the remainder (`col_h − rel_h`) and scrolls if it + overflows (it, too, is a focusable, scrollable panel — DC3). +- **Degradation:** on a column too short to honour the 5-row floor plus + a usable Tables panel (`col_h < ~10`), the floor yields first so + Tables keeps at least its border + one row; both panels stay + renderable. The `50 %` cap and `5`-row floor are tunable constants. + +Heights are a pure render-time function of the column height and the +cached relationship count, so they are unit-testable without a terminal +(see Testing). + +## Decision — Phase C: navigation mode + +### DC1 — `Ctrl-O` navigation mode: a focus cycle, not an input mode + +`Ctrl-O` enters a **navigation mode** that is orthogonal to the +Simple/Advanced input mode (ADR-0003) — it changes *where keystrokes +go*, not *how commands parse*. It drives a focus cycle: + +1. **Press 1 →** focus the **Tables** panel (revealing the sidebar if + it is currently hidden — a temporary peek). +2. **Press 2 →** focus the **Relationships** panel. +3. **Press 3 →** leave navigation mode: restore the sidebar width, + re-hide it if the peek revealed it, and return focus to the command + input. + +`Esc` exits navigation mode directly from any focused panel (a +short-cut for step 3); `Esc` is otherwise only completion-undo, which +does not apply while browsing. + +**Why `Ctrl-O` and not `Ctrl-B`.** `Ctrl-B` is the *default tmux prefix* +and `Ctrl-A` is *screen's* — a multiplexer eats them before the app +sees them, so either would make navigation mode unreachable for the many +students who run inside tmux/screen. `Ctrl-O` is not a multiplexer +prefix; in the raw mode the TUI sets, its legacy line-discipline meaning +(discard-output) is disabled, so it reaches the app. It is free in the +app today (the main key handler's catch-all, `app.rs:1001`). The +mnemonic is weak ("**O**utline"); reachability won over mnemonic. + +**Routing.** Navigation mode is handled inside the **main** key handler, +which runs only when no modal is open (`app.rs:919` gates on +`self.modal.is_some()`). So `Ctrl-O` and the nav keys are **inert while +a modal dialog is active** — modals keep full keyboard ownership. Within +the main handler, a `NavFocus != Input` branch precedes the normal +input-editing arms and routes keys per DC3/DC4. + +### DC2 — Expand-on-focus as an overlay + +A focused sidebar panel widens to a **45-column** overlay +(`NAV_EXPANDED_WIDTH`): the renderer `Clear`s the strip the expanded +panel occupies **plus a one-column gutter** (`NAV_OVERLAY_GUTTER`) and +paints the wide panel on top. The output/input/hint panels underneath +keep their exact layout — **unused and unchanging** while browsing, +**still visible to the right** of the overlay (just partially occluded +on the left) — and are restored fully by the next frame on exit. The +gutter keeps them from butting against the expanded panel's border so +the overlay edge reads cleanly. This is cheap because the renderer is a +pure function of `App` state: focus state selects the width and the +overlay path. (The input underneath is inactive in navigation mode.) + +*Implementation note (2026-06-10):* a full-area clear (hiding the base +panels entirely during browse) was tried first and rejected — leaving +the base visible is truer to "underneath keep their layout," and the +one-column gutter resolves the only wrinkle (the panels' left edges +being cut by the overlay reading harshly without separation). + +### DC3 — Scroll the focused panel; focus highlight + +While a sidebar panel is focused it scrolls, reusing the output panel's +proven mechanism (a `usize` offset clamped against a renderer-reported +viewport via a `note_*_viewport` call): + +- **Up / Down — line-by-line** scroll (the lazygit `j`/`k` feel; + user-chosen 2026-06-10). +- **PageUp / PageDown — page** scroll. + +This is a context-sensitive rebind: Up/Down drive *history* and +PageUp/PageDown scroll the *output* in input mode, whereas in navigation +mode they scroll the *focused sidebar panel*. The two contexts never +apply simultaneously (`NavFocus` selects which). The focused panel shows +an **accent border** so it is obvious where keys are going (lazygit +convention). + +### DC4 — Other keys are inert in navigation mode + +The command input is visibly occluded by the overlay while browsing, so +keys that have no navigation meaning are **inert** rather than acting on +the hidden input. Specifically, **only** `Ctrl-O` (advance focus), +Up/Down + PageUp/PageDown (scroll, DC3), and `Esc` (exit) are live; +printable characters, Enter, Tab, Backspace/Delete, Left/Right, and +Home/End all do nothing until navigation mode is exited. The occlusion +signals "not typing," so swallowing these is clearer than letting them +silently edit an invisible buffer. + +## State, keybindings, and cross-cutting wiring + +**Stored `App` state** (mutated in `update()`, read by the renderer): + +- `input_scroll_offset: usize` (DA3) — reset on submit / history-nav / + clear. +- `NavFocus { Input, SidebarTables, SidebarRelationships }` (DC1) — the + navigation-mode focus cursor; `Input` ≙ not in navigation mode. +- Per-panel scroll offsets for the Tables and Relationships panels, each + clamped against a renderer-reported viewport (DC3), mirroring + `output_scroll` / `note_output_viewport`. +- **`App.relationships: Vec`** (DB2) — the full + relationship records for the sidebar panel, delivered by + `AppEvent::RelationshipsRefreshed` from the runtime's schema refresh. + `SchemaCache.relationships: Vec` (names, for completion) is + unchanged. (See DB2 for why this lives on `App`, not `SchemaCache`.) + +**Render-time derived** (pure functions of `frame.area()` + cached +counts — *not* stored fields; this keeps the pure-render invariant and +makes the geometry logic unit-testable without a terminal): + +- Sidebar visibility — `(width > 90) || NavFocus is a sidebar panel` + (DB1). +- Input/hint row counts — a pure helper `panel_heights(area) -> + (input_rows, hint_rows)` (DA1/DA2), the same helper the renderer and + the Tier-1 tests call. +- Left-column split `rel_h = clamp(content_rows + 2, 5, ⌊col_h/2⌋)` + (DB4). +- Input width fed back to `update()` via `note_input_viewport` + (DA3), since `update()` cannot read `frame.area()`. + +Keybindings introduced/affected: + +| Key | Input mode | Navigation mode | +| --- | --- | --- | +| `Ctrl-O` | enter nav mode, focus Tables (peek-reveal) | advance focus (Tables → Relationships → exit) | +| `Up` / `Down` | history (unchanged) | line-scroll focused panel | +| `PageUp` / `PageDown` | scroll output (unchanged) | page-scroll focused panel | +| `Esc` | completion-undo (unchanged) | exit nav mode directly | +| printable / Enter / Tab / Backspace / Left / Right / Home / End | edit/submit input (unchanged) | inert | + +All nav keys are inert while a modal is open (the main handler is gated +on `!modal.is_some()`, `app.rs:919`). + +Renderer changes (`src/ui.rs`): geometry-driven hint/input height +(DA1/DA2), input window + cursor windowing (DA3) and 2-row soft-wrap +with row-1 indicator (DA4), the relationships panel + two-panel split +(DB2/DB4), the focus accent border and expand-on-focus `Clear` overlay +(DC2/DC3); `note_input_viewport` feedback added alongside the existing +`note_output_viewport`. + +## Genuine forks (escalated, resolved 2026-06-10) + +1. **Left column fate** — remove entirely vs narrow vs **keep + make + optional and richer** (chosen, user). → DB1/DB2. +2. **Focus/scroll model** — a navigation mode (chosen, user) vs + modeless modifier-key scroll vs deferring scroll. → DC1. +3. **Navigation shortcut** — **`Ctrl-O`** (chosen, user); `Ctrl-B` + *rejected on review* (it is the default tmux prefix → unreachable + inside tmux); Ctrl-T also viable; terminal-hijacked combos excluded. + → DC1. +4. **Expand-on-focus rendering** — **overlay with `Clear`** (chosen, + keeps the right panels unchanging) vs re-splitting the layout (would + reflow output). → DC2. +5. **Navigation-mode printables** — **ignore** (chosen, user) vs + drop-to-input-and-type. → DC4. +6. **Hint anti-jump** — **fix height to terminal geometry** (chosen) + vs damping/hysteresis vs always-reserve-max. → DA1. +7. **Height thresholds** — `H < 40` compact / `H ≥ 40` comfortable, with + 1/2 and 2/2 splits (chosen, user). → DA2. +8. **Visibility persistence** — **session-only** (chosen, user) vs + per-project in `project.yaml`. → DB1. +9. **Persistent show/hide toggle** — **deferred** (chosen, user): the + Ctrl-O peek covers #21's "keystroke to show and hide", so visibility + stays width-derived with no override. → DB1. +10. **Nav-mode Up/Down** — **line-scroll the focused panel** (chosen, + user) vs leaving scroll to PageUp/PageDown only. → DC3. +11. **Relationships placement** — **a separate sibling panel** (chosen, + user — *overrides S2*) vs nesting relations inside the tables list + per S2's documented extension model. → DB2/DB4. +12. **Hint-area toggle (S4)** — **no toggle** (chosen, user): the hint + panel is indispensable since completion moved into it; S4's stale + "keyboard-toggleable" claim (never implemented) is struck from + `requirements.md`. → Status (Requirements & issues touched). + +## Testing + +Tier-1 (`app.rs` pure `update()` unit tests), **Tier-2 (`insta` +snapshots, `src/snapshots/`) for the visual surfaces** — this change is +heavily render-side, so the geometry/format/overlay assertions belong in +snapshots, not only behavioural tests — and Tier-3 integration. +Test-first per CLAUDE.md. The geometry helpers (`panel_heights`, the +DB4 split, visibility) are **pure functions** exercised directly in +Tier-1 without a terminal. + +Phase A: +- **Hint anti-jump:** `panel_heights(area)` is invariant under changing + hint content at a fixed terminal size (assert it does not change as + `app.hint` varies); it *does* change across the `H < 40` / `H ≥ 40` + boundary and the width-< 54 boundary. +- **Height buckets:** compact → input 1 row / hint 2; comfortable → + input 2 / hint 2 (3 only when right-column inner < ~54); tiny-terminal + degradation honours output `Min(5)`. +- **Input horizontal scroll:** a line longer than the panel keeps the + cursor visible while moving Left/Right/Home/End; ADR-0027's 6-column + reserve is intact; no characters are lost (buffer = full string); + `input_scroll_offset` resets on submit / history-nav / clear. +- **Two-line input:** at `H ≥ 40` a line wrapping to two rows renders + both rows with correct cursor (row, col), the `[ERR]`/`[WRN]` + indicator on row 1's right edge (Tier-2 snapshot); a longer line + scrolls. + +Phase B: +- **Relationship data path:** `Database::read_all_relationships` + returns full records through the worker thread (integration test, real + DB via an m:n junction); `AppEvent::RelationshipsRefreshed` populates + `App.relationships`; `SchemaCache.relationships` names are undisturbed + (completion still resolves them). +- **Relationships panel render (Tier-2):** empty → a single `None` line + at the 5-row floor; the unfocused narrow format (name + arrow-break, + ellipsis past inner width); a compound endpoint pair arrow-breaks + correctly. +- **Two-panel split (DB4):** `rel_h = clamp(content_rows + 2, 5, + ⌊col_h/2⌋)` — 5 when empty; grows with content; capped at 50 %; + Tables takes the remainder; degrades sanely at `col_h < 10`. +- **Width-derived visibility:** width ≤ 90 hides, > 90 shows, recomputed + on resize (the peek interaction is covered under Phase C). + +Phase C: +- **Focus cycle:** `Ctrl-O` cycles Input → Tables → Relationships → + Input; `Esc` exits directly; a peek-revealed sidebar re-hides on exit; + a width-shown (> 90) sidebar stays shown on exit; `Ctrl-O` is inert + while a modal is open. +- **Expand overlay (Tier-2):** focusing widens to the expanded width; + the underlying output/input/hint state is unchanged across enter/exit + (no reflow); the focus accent border marks the focused panel. +- **Scroll rebind:** in nav mode Up/Down line-scroll and PageUp/PageDown + page-scroll the focused panel (clamped to its viewport); in input mode + Up/Down still drive history and PageUp/PageDown still scroll output + (no V4 regression); inert keys (printable/Enter/Tab/Backspace) do + nothing in nav mode. + +All tiers green, zero skips; clippy clean (nursery). + +## Out of scope + +- **True multi-line input (I1)** — Enter-inserts-newline / Ctrl-Enter- + 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. +- **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 + mode cycles the two sidebar panels only; output keeps its input-mode + PageUp/PageDown scroll. +- **Relationship search / filtering within the panel** — the panel is a + scrollable list; no query box. +- **Relationship rename / edit from the panel** — it is read-only; + mutation stays with the DSL/SQL commands. +- **A persistent show/hide toggle / force-shown override** (DB1, + resolved deferred) — visibility is width-derived + Ctrl-O peek; a + pin/force override is an additive follow-up if ever needed. +- **A hint-area toggle (old S4 wording)** — not implemented today and + not wanted (the hint panel is indispensable since completion moved in; + fork §12). The stale "keyboard-toggleable" phrase is removed from S4. +- **In-app overlay / keystroke-annotation layer (Gitea #22)** — a + separate feature with its own ADR; DC2's `Clear` overlay is built to + coexist with it, not to provide it. + +## Accepted consequences + +- **Width-threshold discontinuity.** Because `Auto` visibility flips at + width 90 and the sidebar costs 28 columns, widening a terminal across + the boundary (89 → 91) makes the *output* narrower (≈ 89 → 63 inner) + as the sidebar appears. This is inherent to any width-gated auto-hide + 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. diff --git a/docs/adr/0047-demonstration-overlay-layer.md b/docs/adr/0047-demonstration-overlay-layer.md new file mode 100644 index 0000000..4258e95 --- /dev/null +++ b/docs/adr/0047-demonstration-overlay-layer.md @@ -0,0 +1,418 @@ +# ADR-0047: Demonstration overlay layer — keystroke badges and step captions + +## Status + +Accepted (2026-06-10); **implemented 2026-06-11**, phased A→B→C (closes +Gitea **#22**). Addresses Gitea **#22**. Builds the in-app +overlay/annotation primitive that screencast recording (ADR-website-001 +§2, the `autocast` pipeline) and a future guided-lesson system both +need. Adjacent to ADR-0046 (the nav-mode sidebar overlay it must +coexist with) and unblocks the polished version of the assistive-editor +and projects (`#24`) casts. + +**Implementation (commits `f879d54` → `2d0f4b2`).** Phase A +(`f879d54`): `--demo` flag + `RDBMS_PLAYGROUND_DEMO` env → +`App.demo_mode`, mirroring the `--no-undo` plumbing; help text mentions +only the visible badges (the `Ctrl+]` caption trigger stays +low-profile, D6). Phase B (`2584e76`): automatic keystroke badges — pure +`demo_badge_label`, set in `App::update` before the modal gate, expired +by a ~1.5 s runtime timer via the new `nearest_deadline` helper that +extends the time-boxed-`recv` arm condition **without** regressing the +ADR-0027 indicator debounce (the rewrite tracks `Instant` deadlines; +verified equivalent). Phase C (`241f60c`): the stealth `Ctrl+]` +caption buffer in `App::update`, intercepted before the modal gate so +captions work over the load picker. Post-build (`2d0f4b2`, user +decision): the overlays render as **flat filled yellow rectangles** (no +border glyphs, one-cell text margin) to read as a distinct callout. A +whole-implementation `/runda` pass returned **PASS** with no blockers; +the only untested wiring is the `run_loop` badge timer (not unit-testable +in isolation — same posture as the existing `IndicatorDebounce`; the +pure pieces are all tested). One intentional, user-acknowledged +behaviour: `Ctrl-C` is inert while capturing (every non-`Ctrl+]` key is, +by spec; exit capture with `Ctrl+]`). Tests: 2290 passing / 0 failing / +0 skipped (Tier-1 label fn + caption FSM + `nearest_deadline`, Tier-2 +dark/light/stacked/wrapped/clamp snapshots + black-on-yellow style, +CLI parse/env); clippy clean. + +All primary forks and the visual placement were **user-confirmed** — +including the two follow-ups settled after the first draft: the trigger +key (**`Ctrl+]`**, the maximally-obscure valid single-byte code, over +`Ctrl+!` which autocast cannot send) and caption sizing (**wrap to 3 +lines**). A `/runda` pass over this ADR ran before implementation and +tightened it — its findings are folded in below (caption/badge +interception placement, in-capture key disposition, badge suppression +during capture, the timer arm-condition, box clamping, the new +output-rect field, and the control-code decode note). + +**Requirements traceability.** There is **no `requirements.md` item** +for this work — verified by sweep. It is tracked as Gitea issue **#22** +plus this ADR, consistent with the project's convention ("issues are +the lightweight tracker; ADRs are the decisions"). The website-side cast +scope lives in **ADR-website-001** (website branch), not main's +`requirements.md`. + +## Context + +The website records its demos as asciinema `.cast` files driven by +**`autocast`** (ADR-website-001 §2; STYLE.md): source step-lists in +`casts-src/casts.mjs` (`type` / `wait` / `key`) expand to **one key per +character, Enter = `^M`**, recorded against the real `target/debug` +binary. The hard constraint — the same one that drove `#24` — is that +autocast can only emit **typeable characters, ASCII control codes +(`^X`), and waits**. It cannot send arrow keys, function keys, or any +multi-byte escape sequence. + +Two classes of on-screen event are therefore invisible or +unexplained in a cast: + +1. **Keystrokes that cause a visible change but render no glyph of + their own** — most acutely **Tab** completion: the command line + jumps from `show data bo` to `show data books` with no sign a key + was pressed. Enter, the arrows, Ctrl-O, Esc are the same. +2. **Step structure / "what just happened" narration** — a cast is a + silent moving picture; there is no channel to separate or explain + steps for a visual learner. + +asciinema-player has no inline keystroke overlay, and a website-side +HTML overlay layered on the player would be fragile (its timings would +have to track every recording and break on each re-record). The robust +place to solve this is **in the app**: if the app renders the overlay, +the cast captures it natively and it re-records for free. The same +primitive is exactly what a future **guided-lesson** system needs to +point at things and narrate steps — so it is built as a general +capability, not a cast-only hack (the issue's "pays off twice"). It is +also directly useful for a **teacher demonstrating the playground +live** — pressing Tab in front of a class has the same +invisible-keystroke problem as a cast. + +The app's renderer is a pure function of `App` state and already draws +two kinds of last-pass overlay over the base render with **no layout +reflow**: modals and the ADR-0046 nav-mode sidebar overlay. The event +loop already **time-boxes `event_rx.recv()`** with a `tokio` timeout +(the ADR-0027 `IndicatorDebounce`) and redraws when the timer elapses — +the exact mechanism a self-expiring badge needs. These two existing +seams make the feature cheap. + +## Decisions + +### D1 — Activation: a `--demo` flag (+ env var), off by default + +Demonstration mode is entered with a **`--demo`** CLI flag, or +equivalently the **`RDBMS_PLAYGROUND_DEMO`** environment variable (set +truthy) — mirroring the existing `--log-file` / `RDBMS_PLAYGROUND_LOG_FILE` +pair. It combines freely with every other flag (`--resume`, `--mode`, a +positional path); there are no exclusions. + +When the flag is **off** (the default), none of the key handling or +rendering below is active and the app behaves exactly as today — **zero +footprint for real users** (R8). `autocast` sets the flag when it +launches the binary; a teacher sets it on their own command line. + +It is framed as a general **demonstration mode**, not "cast mode" — the +honest name for what it does, and it reads sensibly in `--help`. The +flag is documented in the CLI banner (one line); obscurity is not a +security property here and a harmless opt-in flag is better surfaced +than hidden. What stays "low-profile" (per #22) is that there is **no +normal in-app command** for it and **no persistent on-screen indicator** +(see D7) — so a cast frame is never polluted by a `[DEMO]` marker. + +### D2 — Keystroke badges: automatic, app-detected + +In demo mode the app shows a transient badge **automatically** whenever +it handles one of a curated set of *otherwise-invisible* keys. The cast +does nothing special — it presses the key it was going to press anyway, +and the badge re-records for free. The set: + +| Key | Badge | | Key | Badge | +|-----|-------|-|-----|-------| +| Tab | `[TAB]` | | Home | `[HOME]` | +| Shift-Tab | `[SHIFT-TAB]` | | End | `[END]` | +| Enter | `[ENTER]` | | PageUp | `[PGUP]` | +| Esc | `[ESC]` | | PageDown | `[PGDN]` | +| ↑ | `[UP]` | | Backspace | `[BKSP]` | +| ↓ | `[DOWN]` | | Delete | `[DEL]` | +| ← | `[LEFT]` | | Ctrl-O | `[CTRL-O]` | +| → | `[RIGHT]` | | | | + +Plain character keys render a glyph on the input line already, so they +produce **no** badge (that is the definition of the set — "invisible" +keys). The badge fires on **key press**, regardless of whether the key +had an effect in the current state (e.g. `↑` with no history still shows +`[UP]`): simpler, and the demo author controls the script. Badge text is +bracketed ASCII (`[TAB]`) per the user's preference — renders on every +terminal and is cast-safe, unlike the `⇥` glyph mocked earlier. + +The label mapping is a **pure function** `demo_badge_label(&KeyEvent) -> +Option<&'static str>` (Tier-1 testable). The badge **auto-expires on a +timer** (D5). + +### D3 — Step captions: a stealth, control-code-delimited input buffer + +Caption text must arrive through typeable input only (R4). A **single +toggle control code — `Ctrl+]`** (byte `0x1D`) drives a **stealth +capture buffer**. `Ctrl+]` was chosen (over the bound `Ctrl-O`/`Ctrl-C`, +the readline-reserve letters Ctrl-A/E/W/K/U, the tmux-prefix Ctrl-B, the +signal/flow-control codes Ctrl-\\=SIGQUIT and Ctrl-S/Q=XON/XOFF, and a +plain letter chord like Ctrl-G) because it is **maximally non-obvious** +— the classic telnet escape, almost never pressed by accident — while +still being a single ASCII control byte autocast can emit. It has **no +signal or flow-control baggage** and is **multiplexer-safe**. Note +collision risk is already near-zero in casts (a fresh `--demo` binary +sees only scripted keys); the obscurity mainly protects a live teacher +from a stray trigger. + +- First `Ctrl+]` **opens** capture. The command input line and the + output are untouched. If a caption is already visible, opening clears + it (you are starting a new annotation). +- Subsequent typed characters **accumulate into the caption buffer + invisibly** — they do **not** appear on the prompt, do not execute, + and do not enter history. **`Backspace`** deletes the last buffered + character. **Every other key while capturing — Enter, the arrows, + Tab, … — is inert** (swallowed, no effect): only typing and `Ctrl+]` + do anything. +- A second `Ctrl+]` **commits** the buffer to the caption box (D4). + An **empty** commit (toggle-toggle with nothing typed) clears any + visible caption — the author's explicit dismiss. + +Because nothing about the capture shows on the prompt, the caption +"pops" into its box with no ugly typing artifact, while the caption text +still lives **inline in `casts.mjs`** at the right spot (one source of +truth, no separate notes file to keep ordered). + +This is all keyboard-stream interpretation, so it lives in the +pure-sync `App::update()` (Tier-1 testable) and is **only active in demo +mode** — when off, `Ctrl+]` is inert and characters reach the input +line normally. + +**Placement in `handle_key` — before the modal gate (runda finding).** +The capture interception (`Ctrl+]` and the accumulating characters) +**and** the "clear a visible caption on the next keystroke" check sit at +the **very top of `handle_key`, before the `self.modal.is_some()` +gate** — *not* alongside the `Ctrl-O` handler, which is gated behind it. +This is required so captions can be authored **while a modal is open** — +specifically the load-picker, which is exactly the **projects / `#24` +cast** (annotating "press j/k to move", with an `[ENTER]` badge as the +selection is made). While capturing, the modal is frozen (capture +swallows keys), which is the intended behaviour. `App` exposes +`demo_capturing` so the runtime can read it (see D5). + +The control-code path is sound end to end, verified against our +crossterm (0.29, `event/sys/unix/parse.rs:110-113`): `autocast` emits +`^]` = byte `0x1D`; crossterm decodes `0x1C..=0x1F` → +`KeyCode::Char('4'..='7') + CONTROL`, so **`Ctrl+]` (0x1D) arrives in +the app as `KeyCode::Char('5') + KeyModifiers::CONTROL`** — that is the +pattern `handle_key` matches. (The same routine decodes `0x09`/`0x0D`/ +`0x1B`/`0x7F` to the named `Tab`/`Enter`/`Esc`/`Backspace` keys and +`0x01..=0x1A` to `Ctrl+a..z`, so `0x1D` is unambiguously distinct.) The +canonical way to produce it is **Ctrl+]**; on some layouts `Ctrl+5` +yields the same byte. *(This is the Unix/Linux decode path — the +cast-recording platform; crossterm's separate Windows backend would be +confirmed by test if live `--demo` on Windows is exercised.)* + +### D4 — Both overlays are floating boxes at the output panel's inner bottom-right + +The badge and the caption both render as **floating, flat filled +rectangles anchored to the inside of the output panel's bottom-right +corner** (inset one cell from the panel's inner edge), drawn **last over +the base render** — after modals, so they remain visible while the +load-picker (the `#24` cast) or any modal is up, and with **no layout +reflow** (consistent with the modal / nav-overlay precedent; honours +R8). + +**Flat rectangle, not a bordered box (user decision, post-build).** The +overlays draw as a **solid yellow rectangle with no border glyphs** and +a one-cell margin around the text — deliberately *unlike* the app's +rounded-border panels, so they read as a distinct callout that "stands +out nicely" rather than as another panel. Implemented with a borderless +`Block` fill (the `paint_background` mechanism) plus a `Paragraph` inset +into a one-cell `Margin`. + +The top-level `render()` does not currently know the output-panel rect +(it is computed inside `render_right_column`), so a **new field +`App.last_output_area: Rect`** is set in `render_output_panel` and read +at the top-level draw pass to anchor the overlay — the established +"renderer reports metrics back to `App`" pattern (sibling to +`note_output_viewport`, which stores row counts, not a rect). + +When **both** are present, the **keystroke badge stacks directly above +the caption box** (both right-aligned in the corner) so they never +overlap. + +**Styling — deliberately high-contrast:** **bold black text on a yellow +fill** — hard to overlook, identical in light and dark themes (a fixed +high-contrast pair centralised in `theme.rs`, not theme-derived). + +**Caption sizing (user-confirmed).** The caption is **word-wrapped to at +most 3 lines** within a content width of `min(40, output_inner_width − +4)` columns, ellipsised beyond the third line. So the caption rectangle +is **3–5 rows** tall (1–3 text rows + a one-cell margin top and bottom), +its height varying with the text — a full sentence fits without forcing +the author to split it, while the 3-line cap keeps it corner-sized. The +**badge** rectangle is always a single short token (`[TAB]` … +`[SHIFT-TAB]`), so it is a fixed **3 rows** (1 text row + the margin), +narrow. + +**Clamping (runda finding).** Stacked, the two boxes are up to 8 rows +(5 caption + 3 badge); the output panel's inner height is only `Min(5)`, +so on a short terminal they could exceed it. Both boxes are **clamped to +the output inner area**: width to `output_inner_width`, the caption's +wrap-line count reduced so the stack fits the available height (badge +first — it is the time-critical one), and if a box cannot fit at all +(pathologically small terminal) it is **not drawn** rather than +overflowing. Cast geometry (90×26) leaves ~18 output rows — ample; the +guard only protects a real user who runs `--demo` in a tiny window. + +### D5 — Timing: badges expire on a ~1.5 s timer; captions persist until the next keystroke + +- **Keystroke badge:** auto-expires on a **time-based TTL**, default + **1.5 s** (a single tunable constant; the user asked for 1–2 s). This + matters for both media: in a cast the badge fades on its own so a + trailing `wait` ends on a clean frame, and in live teaching the badge + clears without the presenter needing another key. A new badge replaces + the current one and resets the timer. +- **Caption:** persists **until the next keystroke**, which clears it + and is then processed normally (or until an explicit empty-`Ctrl+]` + dismiss, or replacement by a new caption). + +The timer reuses the runtime's existing time-boxed-`recv` pattern: the +loop already arms a `tokio::time::timeout` for the indicator debounce. + +**Arm-condition extension (runda finding).** Today the loop time-boxes +`recv` **only while `debounce.is_armed()`** — and the debounce settles +at `INDICATOR_DEBOUNCE` (1000 ms), shorter than the 1500 ms badge TTL. +So the arm condition becomes **`debounce.is_armed() || badge_pending`**, +and the loop waits on the **nearest deadline** of the two. On a wake it +checks each independently: at the 1000 ms debounce deadline it settles +the indicator **without clearing the badge**; at the 1500 ms badge +deadline it clears the badge; then redraws. The pure "nearest deadline" +computation is unit-testable on its own. + +The badge's expiry `Instant` lives in the **runtime** (so `App` stays +clock-free and Tier-1-pure, exactly as `IndicatorDebounce` keeps timing +out of `App`); `App.demo_badge: Option<&'static str>` is the render +mirror, **set by the runtime** on a significant key and cleared on timer +elapse. + +**Badge suppression during capture (runda finding).** Because the +runtime sets badges from the raw key independently of `App` state, it +must **not** badge a key that capture swallowed (e.g. an inert `Tab` +while a caption is being typed would otherwise flash `[TAB]` for a +no-op). The runtime sets a badge only when **`!app.demo_capturing`**. + +**Ownership note.** `demo_caption` is mutated inside `update()` +(input-driven) while `demo_badge` is mutated by the runtime +(timing-driven). This split is deliberate and mirrors the existing +`input` (set in `update()`) vs `input_indicator` (set by the runtime +from `IndicatorDebounce`) pair — not an inconsistency. + +### D6 — Help text and strings + +The CLI banner (`help.cli_banner` in `en-US.yaml`) gains a `--demo` +line. User-facing wording obeys the house rules (no engine name, no +"DSL"): *"Demonstration mode — show on-screen badges for otherwise- +invisible keys (Tab, Enter, …), for screencasts and live teaching."* + +The help text **deliberately mentions only the visible badges, not the +`Ctrl+]` step-caption mechanism** (user decision): the caption trigger +stays low-profile, true to #22's "secret trigger" framing — a cast +author or lesson script knows it; a casual `--help` reader is not +pointed at it. Badge labels and the `[…]` chrome are fixed ASCII, not +localised; caption content is author-supplied free text and likewise +not a catalog string. + +## Alternatives considered + +- **Scripted badges** (cast pushes each badge explicitly) — rejected: + the app already sees every key, so automatic detection (D2) is more + robust and re-records for free. *(User-confirmed.)* +- **Typed hidden command for captions** (a secret-prefixed line) — + rejected: the command is briefly visible being typed on the prompt. + **Preloaded notes file + advance key** — rejected: a separate file + that must stay ordered/in-sync with the cast. The **stealth buffer** + (D3) is self-contained in the cast script *and* leaves the prompt + clean. *(User-confirmed.)* +- **Fixed-corner HUD badge / badge by the input line** — rejected in + favour of a floating box at the output panel's bottom-right; **top + banner / subtitle band** for captions — rejected in favour of the + matching floating box. *(User-confirmed via mockups.)* +- **A persistent `[DEMO]` status-bar marker** — rejected: it would show + in every cast frame. Demo mode is silent except for the transient + overlays (D7). +- **Caption persists for a fixed time** (instead of until next + keystroke) — noted as a one-constant change if the next-keystroke rule + proves too eager in practice; the user chose next-keystroke. +- **Trigger via `Ctrl+!` / a Kitty-protocol chord** — rejected: not + representable as a single ASCII control byte, so autocast cannot send + it (fails R4, the same wall as arrow keys). **`Ctrl+G` / a letter + chord** — workable but less non-obvious; the user chose the + maximally-obscure `Ctrl+]` from the valid single-byte set. +- **Single-line ellipsised caption** — rejected in favour of wrap-to-3- + lines so a full sentence fits. *(User-confirmed via mockups.)* + +## Consequences + +- A general overlay primitive exists that the cast pipeline uses now and + the guided-lesson system can reuse later (`App.demo_caption` and the + badge channel are the seam). +- `autocast` casts gain a real Tab-completion moment, key indicators for + the projects/`#24` round-trip, and step captions — all by adding + `key: ^G` / `type:` / `key: ^G` and ordinary keys to `casts.mjs`, then + re-running `pnpm casts`. No website-side overlay machinery. +- Teachers get the same affordance live via `--demo`. +- One new control-code binding (`Ctrl+]`) is consumed, but only inside + demo mode — normal sessions are unaffected, so it does not encroach on + the reserved readline chords (I1b). +- The renderer must expose the output-panel rect to `App`; a small, + pattern-consistent addition. + +## Scope / non-goals (OOS) + +- **Manual/scripted badge push** and **badges for plain character + keys** — out; badges are automatic over the fixed invisible-key set. +- **Configurable overlay styling or placement** — out; fixed + black-on-yellow boxes at the output panel's bottom-right. +- **The guided-lesson / tutorial system itself** — out (its own ADR); + this ADR only builds the primitive it will reuse. +- **Persisting demo mode across project switches / sessions** — out; + it is a per-run flag. +- **Localising caption content** — out; captions are author-supplied + free text. +- **Output-pane scroll-in-casts** and other arrow-only interactions — + out (separate enhancement; same autocast limitation as noted in #24). + +## Testing + +Per ADR-0008 and the project's test discipline (test-first; green, no +skips): + +- **Tier 1 (`app.rs` units):** `demo_badge_label` mapping over the full + key set **and** the no-badge cases (plain chars, `Ctrl+]`, `Ctrl-C`); + the stealth-caption state machine — open on `Ctrl+]`; characters + accumulate with the **input line unchanged**; `Backspace` edits the + buffer; **non-typing keys inert while capturing**; commit sets the + caption; empty commit clears; opening over a visible caption clears + it; next keystroke clears a visible caption **then processes + normally**; capture works **with a modal open** (caption set while the + load-picker modal is up, picker state untouched); the **demo-off + gate** (`Ctrl+]` inert, characters reach the input, no caption/badge + state ever set); the pure "nearest deadline" helper. +- **Tier 2 (insta snapshots, `ui.rs`):** badge box, caption box, both + stacked, at 90×26 in light and dark — verifying the bottom-right + anchor, the stack order, and the black-on-yellow styling; plus a + short-terminal case exercising the clamp/skip guard. +- **Tier 3 (integration):** `--demo` plumbs `app.demo_mode`; a + significant-key event sets `app.demo_badge` and a swallowed key during + capture does **not**; a `Ctrl+]` / type / `Ctrl+]` sequence sets + `app.demo_caption` without touching `app.input`. +- **CLI (`cli.rs` units):** `--demo` parses (mirrors `--no-undo`); the + `RDBMS_PLAYGROUND_DEMO` env fallback; default-off. + +**Honest coverage limit.** The badge **timer-expiry wiring** runs inside +`run_loop` (terminal + db worker), which is not unit-testable in +isolation; it is a thin reuse of the already-proven `IndicatorDebounce` +time-boxed-`recv` path. We therefore test the **pure pieces** +exhaustively (label fn, capture state machine, nearest-deadline helper) +and assert plumbing via Tier-3, rather than over-claiming an integration +test of the `tokio` timeout itself. + + diff --git a/docs/adr/README.md b/docs/adr/README.md index 46abe13..884d6ef 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -57,3 +57,6 @@ This directory contains the project's ADRs, recorded per - [ADR-0042 — H1a parse-error pedagogy in the grammar-tree era](0042-h1a-parse-error-pedagogy-grammar-tree.md) — **Accepted 2026-06-03.** Continues **H1a** from ADR-0021 against the ADR-0024 grammar tree (ADR-0021's chumsky mechanism is dead). Records the **baseline already shipped** — per-command `usage:` block (38 `parse.usage.*` templates), available-commands fallback, structural "after `…`, expected …" wording, source-derived ident slot labels ("table name"/"column name"), curated `parse.custom.*` near-miss messages, and the ADR-0027/0033/0036 schema-aware `[ERR]` diagnostics — so H1a is *substantially* delivered at the intent level. Defines the remaining work as **(1)** a verified per-command **near-miss matrix** (`tests/typing_surface/` + `tests/it/parse_error_pedagogy.rs`) as the definition of done, test-first; **(2)** **friendlier literal expectation labels** — optional prose glosses on `Word`/`Punct`/`Flag` positions that *add* role context while always keeping the exact literal visible (e.g. "a filter clause: `where …` or `--all-rows`"); **(3)** **advanced-mode SQL** near-miss parity (RETURNING scope, CTE-arity positioning, `CROSS JOIN … ON`, INSERT…SELECT count) — **in scope**, kept distinct from ADR-0019 §OOS-2 which covers advanced-SQL *engine*-error sanitisation, a different layer. Catalog/anchor-phrase discipline (ADR-0019) preserved; no public API change. OOS: I3/I4, spell-correction, multi-error reporting, verbosity-gating the usage block - [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-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 diff --git a/docs/handoff/20260610-handoff-61.md b/docs/handoff/20260610-handoff-61.md new file mode 100644 index 0000000..22be510 --- /dev/null +++ b/docs/handoff/20260610-handoff-61.md @@ -0,0 +1,171 @@ +# Session handoff — 2026-06-10 (61) + +Sixty-first handover. Continues from handoff-60 (Gitea migration +cleanup + V1 relationship visualization, ADR-0044). This session was +a **list-trimming pass on "easy wins"**: it closed **X1** +(comprehensive logging, full sweep) and both **T3 residuals** (the two +ADR-0043 messaging-polish items). Four commits, all green, all +user-confirmed. + +## §1. State at handoff + +**Branch:** `main`. **HEAD `5a33f2a`.** 4 commits this session +(`a8ad0c6` → `5a33f2a`) on top of session-60's 5; push is the user's +step. + +**Tests: 2211 passing / 0 failing / 1 ignored** (lib 1588, it 431, +typing_surface_matrix 192; the 1 ignored is the long-standing +doc-test). **Clippy clean** (nursery, all targets). +4 over the +handoff-60 baseline of 2207 (one test per residual at each of the +enrichment + render layers, plus the two grammar/worker tests). + +This session's commits: +``` +5a33f2a fix(fk): compound-FK violation message names every column pair +6985a43 fix(fk): inline FK referencing a compound PK points at the table-level form +0a7612e feat: comprehensive logging across parser, app, persistence, runtime (X1) +a8ad0c6 feat(db): comprehensive logging across worker + executors (X1) +``` + +## §2. X1 — comprehensive logging (closed, `[x]`) + +The full-sweep instrumentation pass the "log liberally" standard +called for. **~75 → 135 `tracing` sites** under a documented level +discipline now living in the **`src/logging.rs` module doc** (read it +before adding logs — it is the durable convention). + +**Levels:** `error` = unrecoverable; `warn` = recoverable / fallback +taken; `info` = low-volume lifecycle (worker start/exit, project +open); `debug` = the bulk, one line per *executed* command + its +decision points (off by default, opt-in `RDBMS_PLAYGROUND_LOG=debug`); +`trace` = hot paths only (per-keystroke parse, per-key input). + +**Where logs go (was a point of confusion):** always a **file** +(stdout/stderr would corrupt the TUI). Path precedence: `--log-file` +> `RDBMS_PLAYGROUND_LOG_FILE` > default `~/.rdbms-playground/ +playground.log` (append mode). Level filter is the *separate* +`RDBMS_PLAYGROUND_LOG` env var, default `info`. + +**Coverage by commit:** +- `a8ad0c6` **db.rs** (26→67): entry-`debug!` on all 34 `do_*` + executors (DDL/DML/relationship/index/read), matching the existing + `do_sql_delete`/`do_run_select` style — so the route through + *delegating* executors (e.g. `add_column` → + `add_constrained_column_via_rebuild`) is visible in the log + *sequence*. Decision-point logs: `rebuild_table_with_copy` + begin/commit (+ FK-check-failure and `foreign_keys` re-enable + failure as `warn`), `do_insert` autofill summary, `do_delete` + cascade summary, `do_create_table` FK resolution. Worker + start/exit `debug!`→`info!`. +- `0a7612e` **rest**: `persistence/mod.rs` logs every yaml/CSV/history + write (the silent-failure disk paths); `runtime.rs` + `execute_command_typed` dispatch; `app.rs` submit / + `dispatch_app_command` / ADR-0044 diagram-vs-prose render choice; + `dsl/parser.rs` parse begin/outcome at **`trace`** (the + `parse_command_inner` choke point — `completion.rs` re-parses + per-keystroke, probing candidates in a loop, so `debug` would + flood). + +**Verification:** emission proven end-to-end through the *real* worker +thread + real `logging::init` via two throwaway smoke tests (db path +and persistence path), both since deleted. The DA-honest gap: a few +internal read-only helpers (`do_find_rows_matching`, +`do_read_relationships`, `do_list_names_for`) and the thin `*_request` +wrappers are not *individually* instrumented — the wrappers delegate +to logged executors (skipped to avoid double-logging), the helpers are +low-value. Effective coverage is complete via logged entry points; it +is not literally 44/44. + +## §3. T3 residuals — both closed (ADR-0043) + +Two messaging-only items carried since handoff-59 §4; FK +correctness/enforcement was never affected. + +**#1 — inline-FK arity wording (`6985a43`).** `col REFERENCES P(a,b)` +referencing a compound PK gave the generic arity error. An inline +column-level FK is single-column by construction, so it now points at +the table-level form: *"an inline column reference can only name one +column … Use the table-level form instead: `FOREIGN KEY () +REFERENCES P (a, b)`."* Mechanism: new **`inline: bool` on +`SqlForeignKey`**, set by the single shared grammar builder +`consume_fk_reference` (true for the inline path at `ddl.rs:1560`, +false for table-level `1590` and `build_alter_fk`); threaded into +`resolve_fk_parent_columns`, which tailors the arity-mismatch message +when `inline && parent_key.len() > 1`. 6 construction sites total (2 +grammar + 1 ALTER delegate + 3 test literals) — hand-edited, **not** +the scripted sweep handoff-59 §4 warned about. The bare inline form +(`col REFERENCES P`, no parens) hits the same arity branch, so it is +covered by the same code (tested via the explicit-parens form). + +**#2 — compound-FK violation names every pair (`5a33f2a`).** +`enrich_fk_violation` (`runtime.rs`) picked only `local_columns +.first()` / `other_columns.next()`. It now gathers all pairs of the +matched relationship and carries them **comma-joined in the existing +single-column facts slots** (`column`, `parent_column`, `value`), so +the headline reads *"no parent row in `Region` has `country, code` = +`7, 8`."* No facts-model or catalog change — joined strings flow +through the existing `{parent_column}`/`{value}` placeholders. +Single-column behaviour is byte-identical (a one-element join is the +element). **Known minor awkwardness:** the *verbose hint* interpolates +`{parent_table}.{parent_column}` → `Region.country, code`, which reads +a touch oddly; the headline is clean. A perfectly-formatted compound +hint would need catalog work, out of scope for a messaging-polish +residual — flagged, not fixed. + +## §4. Remaining open landscape (unchanged except X1) + +**Closed this session:** X1 → `[x]`; both T3 residuals (ADR-0043 fully +wrapped — no residuals left). + +**Still `[/]` / `[~]` / larger (design-first, own ADR):** +- **V2 / S3** multi-result tabs — output-model redesign. +- **V3** whole-DB ER export; **V4** scrollable journal + Markdown + (also the home for diagram live-reflow, ADR-0044 OOS-1). +- **A1** app-commands — blocked on `seed` (SD1) + `hint` (H2). +- **H1a** parse-error syntax help (partial; ADR-0021). +- **DOC1** reference docs. + +**`[ ]` not started:** H2 `hint`, SD1 `seed`, C4 m:n convenience, B3 +query-timeout, I1 multi-line input, I1b readline shortcuts, I5 +cancellation, **TT5 CI** (now Gitea Actions / Woodpecker — a fresh +decision tied to the migration + ADR-0001's reopened distribution +question), TT4 PTY (spec-only), D1–D3 distribution, NFR-1…7. + +**ADR-0044 OOS for later:** OOS-7 user-configurable relationship- +display setting (always-prose / always-diagram / auto-by-width). + +## §5. Next job — candidates (by readiness) + +No forced next step. Recommended order: +1. **TT5 CI** — test infra is solid (2211 green) and now there is real + logging to surface failures; no pipeline yet. A fresh **Gitea + Actions / Woodpecker** decision (earns a short ADR; ties into + ADR-0001's reopened distribution question). Highest leverage: + protects everything else. +2. **SD1 `seed`** then **H2 `hint`** — the two unblockers for **A1** + app-commands; both are net-new, self-contained features (each its + own ADR). +3. **C4 m:n convenience** — auto-generate a junction table; depends on + relationships, which are now solid (ADR-0043/0044 done). +4. **V2/S3 tabs** or **V4 journal** — larger output-model redesign; + design-first, own ADR. V4 also unlocks diagram live-reflow. + +## §6. How to take over + +1. Read handoffs 59 → 60 → 61, then `CLAUDE.md` (Gitea/`tea` section), + `docs/requirements.md` (X1 now `[x]`), `docs/adr/README.md`. +2. **Before adding any logging:** read the level-discipline block in + the `src/logging.rs` module doc (the X1 convention). +3. **For FK/relationship work:** ADR-0043 (compound FKs) + ADR-0044 + (visualization) are both fully landed; `SqlForeignKey` now carries + `inline`. +4. Codebase on `main` at `5a33f2a`, clean, 9 commits unpushed (5 from + session 60 + 4 this session). +5. Process pins that paid off: **verify log emission end-to-end, not + just that it compiles** (throwaway smoke tests through the real + worker thread caught nothing broken but proved the stack); + **hot-path logging belongs at `trace`, not `debug`** (the parser); + **test-first on both residuals** (red → green at every layer); + **hand-edit struct-field ripples, never script them** (handoff-59 + §4's scare avoided). Commits user-confirmed, append-only, no AI + attribution. diff --git a/docs/handoff/20260610-handoff-62.md b/docs/handoff/20260610-handoff-62.md new file mode 100644 index 0000000..a73f864 --- /dev/null +++ b/docs/handoff/20260610-handoff-62.md @@ -0,0 +1,185 @@ +# Session handoff — 2026-06-10 (62) + +Sixty-second handover. Continues from handoff-61 (X1 logging full sweep ++ T3 residuals). This session was a **list-trimming + one-feature run**: +it closed **C4** (the `create m:n relationship` convenience command, +**ADR-0045**) and, in passing, resolved **Gitea issue #19** (drop-PK +guard). Handoff-61 itself was written mid-session, so the X1 / T3 work +it describes is also part of this session's commit range. + +## §1. State at handoff + +**Branch:** `main`. **HEAD `8bd43cc`.** Push is the user's step. + +**Tests: 2237 passing / 0 failing / 1 ignored** (the 1 ignored is the +long-standing doc-test). **Clippy clean** (nursery, all targets). +30 +over the handoff-60 baseline of 2207. + +**This session's commits** (8, on top of session-60's 5): +``` +8bd43cc feat: create m:n relationship convenience command (C4, ADR-0045) +e598008 docs: ADR-0045 m:n convenience command (C4); accepted +e44d298 test+docs: lock drop-PK-refused on advanced surface; document no-PK advanced mode (#19) +b803468 docs: session handoff 61 — X1 logging full sweep + T3 residuals closed +5a33f2a fix(fk): compound-FK violation message names every column pair +6985a43 fix(fk): inline FK referencing a compound PK points at the table-level form +0a7612e feat: comprehensive logging across parser, app, persistence, runtime (X1) +a8ad0c6 feat(db): comprehensive logging across worker + executors (X1) +``` + +**Requirements closed this session:** **X1** `[x]` (logging), **T3** +residuals (both ADR-0043 messaging items), **C4** `[x]` (m:n). Gitea +**#19 closed**. + +## §2. X1 — comprehensive logging (closed) — see handoff-61 §2 + +Full detail in handoff-61. In brief: ~75 → **137** `tracing` sites under +a documented level discipline (read the **`src/logging.rs` module doc** +before adding logs). Logs go to a **file** (`--log-file` > +`RDBMS_PLAYGROUND_LOG_FILE` > `~/.rdbms-playground/playground.log`); +level via the separate `RDBMS_PLAYGROUND_LOG` env (default `info`). +`debug` = per-command detail (off by default), `trace` = hot paths +(per-keystroke parse). + +## §3. T3 residuals (both closed) — see handoff-61 §3 + +`6985a43` inline-FK arity wording (points at the table-level form; +added `inline: bool` to `SqlForeignKey`). `5a33f2a` compound-FK +violation names every column pair (comma-joined in the single-column +facts slots; `enrich_fk_violation`). ADR-0043 now has no residuals. + +## §4. Issue #19 — drop-PK guard (closed, `e44d298`) + +A parallel check the user requested. **Finding: dropping a PK column is +already refused in both modes** via the shared `do_drop_column` guard +(*"cannot drop primary-key column …"*) — simple `drop column` and +advanced `ALTER … DROP COLUMN` both route through it. Added end-to-end +coverage (`tests/it/sql_alter_table.rs`: single + compound PK, refusal +for the right reason). **Corrected a long-standing misconception:** the +issue's premise ("we don't support creating a table with no PK") is true +only in **simple** mode — advanced SQL `create table t (a int)` makes a +real **PK-less** table (SQLite's implicit `rowid` keys it; only +`WITHOUT ROWID` lacks one, which this app never creates). The simple-mode +`with pk` requirement is **pedagogical** (ADR-0029), not an engine +constraint. Documented in `docs/simple-mode-limitations.md`. + +## §5. C4 — `create m:n relationship` (the feature, ADR-0045) + +`create m:n relationship from to [as ]` generates a +**junction table**: one FK column per parent PK column +(`{table}_{pkcol}`, typed via `fk_target_type` — ADR-0011), a **compound +PK** over all of them, and **two `CASCADE` 1:n relationships** — all in +**one `do_create_table` call = one undo step** (no batch needed; +`do_create_table` already takes `foreign_keys` + writes per-FK +relationship metadata). Auto-named `{T1}_{T2}` (optional `as`), available +in **both modes**, compound-parent PKs supported (ADR-0043). + +**Forks (all user-confirmed):** compound-over-FKs PK (vs surrogate / +none); `CASCADE` actions; auto-name + optional `as`; both modes; FK +columns `{table}_{pkcol}`. **Refused:** self-referential m:n (`from T to +T` — full stop, OOS); PK-less parent; internal `__rdbms_*` junction +name; name collision. + +**Where the code lives:** +- Grammar: a **separate `CREATE_M2N` `CommandNode`** in + `dsl/grammar/ddl.rs` (entry `create`, opener `Node::Literal("m")` — + not a keyword, so it never shadows an identifier), registered Simple + in `grammar/mod.rs` `REGISTRY`. `build_create_m2n` → + `Command::CreateM2nRelationship { t1, t2, name }`. +- Worker: `Request::CreateM2nRelationship`, + `Database::create_m2n_relationship`, executor + `do_create_m2n_relationship` (reads each PK, guards self-ref / + PK-less, builds columns + compound PK + 2 `SqlForeignKey`s, calls + `do_create_table`). +- Runtime: `execute_command_typed` arm. Echo: + `echo::render_create_m2n` (advanced-mode DSL→SQL teaching echo, ADR- + 0038 — the generated `CREATE TABLE … FOREIGN KEY …`, round-trips as + valid SQL), wired in `build_schema_echo`. +- Surfaces: completion `("m","m:n")` composite; `help.ddl.create_m2n` + + `parse.usage.create_m2n` catalog (+ `keys.rs` declarations); + highlighting is grammar-driven (automatic). + +**Tests:** 14 integration (`tests/it/m2n.rs`), 7 typing-surface matrix +(`tests/typing_surface/create_m2n.rs` — completion/hint/highlight/parse), +plus echo / highlight / usage-disambiguator / internal-name units. + +## §6. Framework fixes the C4 build + two `/runda` passes surfaced + +C4's "separate node" design rested on an ADR premise that proved **only +half true**: *"the walker already dispatches multiple nodes per entry +word"* held in **advanced** mode but not **simple**. Three latent +simple-mode assumptions ("≤1 DSL form per entry word") were generalized, +**all behaviour-preserving for existing single-form commands**: + +1. **Dispatch** (`walker/mod.rs` `decide`) committed `simple.first()` + unconditionally → now tries simple candidates (so `create table` no + longer shadows `create m:n`). Reduces to the old single-candidate + commit when there is one. +2. **Completion continuation-merge** (`walker/mod.rs`) was gated + `if mode == Advanced` → now runs in simple mode too, **gated on + `simple_count > 1`** so single-form entry words are untouched. +3. **Usage disambiguator** (`grammar/mod.rs` `usage_key_for_input`) + knew the `1:n` opener but not `m:n` → added an explicit branch. + +Plus a **root-cause bug fix** (user-chosen scope): `do_create_table` +now rejects internal `__rdbms_*` names. This closed both the C4 `as +__rdbms_*` hole **and a pre-existing hole** — simple-mode DSL `create +table __rdbms_*` was accepted at parse (the `TABLE_NAME_NEW` slot had no +guard; only the advanced-SQL path rejected internal names). The shared +executor is the single choke point; the SQL path still rejects earlier +at parse. + +**Process note:** the two `/runda` passes were worth it. The first +(pre-build) corrected the inverted "no PK-less tables" assumption and +confirmed the `do_create_table` reuse against code. The second +(pre-commit) closed **five** test-coverage gaps — two of which +(highlighting, persistence round-trip) had been **wrongly claimed +verified** (the typing-surface `Assessment` has no highlight field; +"transitively covered" was a hand-wave) — and found the two bugs above. +Lesson re-confirmed: verify a claimed-tested surface actually has an +assertion; "transitively covered" is a DA red flag. + +## §7. Remaining open landscape + +**Closed since handoff-60:** X1, both T3 residuals, C4, #19. ADR-0043 and +ADR-0045 fully landed. + +**Still open (by readiness, unchanged otherwise):** +1. **TT5 CI** — test infra solid (2237 green); no pipeline. **Gitea + Actions / Woodpecker** (a fresh decision tied to the migration + + ADR-0001's reopened distribution question). **Friction:** the + requirement is Linux/macOS/Windows on stable — self-hosted Gitea can + do Linux easily, but mac/Windows runners need machines that may not + exist; likely needs a Linux-first scope decision. +2. **SD1 `seed`** then **H2 `hint`** — the two unblockers for **A1** + app-commands; both net-new, own ADR (SD2 is the seed-generator design + ADR). SD1 should now seed **m:n junctions** too (valid FK refs from + parent rows) — C4 makes that concrete. +3. **V2/S3 multi-result tabs** or **V4 journal** — larger output-model + redesign, design-first, own ADR. V4 also unlocks diagram live-reflow. +4. **C3a modify relationship** — small follow-up (drop+add covers it + today; ADR pending). + +**ADR-0045 OOS for later:** self-referential m:n (deliberate non-goal); +per-relationship action overrides; extra junction payload columns; +m:n-as-diagram echo. **Pre-existing, now-fixed:** the internal-name hole +(§6) — no separate issue needed, it's closed. + +## §8. How to take over + +1. Read handoffs 60 → 61 → 62, then `CLAUDE.md`, `docs/requirements.md` + (X1/C4 now `[x]`), `docs/adr/README.md`. +2. **Before adding logging:** the level discipline in the + `src/logging.rs` module doc. +3. **For grammar/command work:** an entry word can now carry **multiple + DSL forms** in simple mode (C4 generalized the dispatch + completion + + usage paths). `create` is the first such entry word (table + m:n). +4. **For relationship/FK work:** ADR-0013/0043/0044/0045 are all landed; + `SqlForeignKey` carries `inline`; `do_create_table` now guards + internal names. +5. Codebase on `main` at `8bd43cc`, clean. Commits user-confirmed, + append-only, no AI attribution. Process pins that paid off: **two + `/runda` passes per feature** (design + pre-commit) — both found real + bugs and gaps every time; **verify a claimed-tested surface has an + actual assertion**; **escalate genuine forks** (every C4 design choice + + the internal-name fix scope was the user's). diff --git a/docs/handoff/20260610-handoff-63.md b/docs/handoff/20260610-handoff-63.md new file mode 100644 index 0000000..33cb862 --- /dev/null +++ b/docs/handoff/20260610-handoff-63.md @@ -0,0 +1,159 @@ +# Session handoff — 2026-06-10 (63) + +Sixty-third handover. Continues from handoff-62 (C4 m:n + #19). This +was a **single-ADR, full-build session**: it designed and implemented +**ADR-0046** end to end — the UI work for the three sidebar/input +issues **#20 / #21 / #23**, all now **closed** on Gitea. + +## §1. State at handoff + +**Branch:** `main`. **HEAD `22bec61`** (plus an uncommitted docs +finalization — ADR status flip + this handoff — see §7). Push is the +user's step. + +**Tests: 2263 passing / 0 failing / 1 ignored** (the 1 ignored is the +long-standing doc-test). **Clippy clean** (nursery, all targets). +26 +over the handoff-62 baseline of 2237. + +**This session's commits** (8 + the docs finalization): +``` +22bec61 feat(ui): scroll the focused sidebar panel + refine the nav overlay (DC3 + DC2) +c9da6ff feat(ui): Ctrl-O navigation mode — peek + expand the schema sidebar (DC1/DC2/DC4) +94825d0 feat(ui): relationships sidebar panel + schema data (DB2/DB4) +386627a feat(ui): width-derived sidebar visibility — hide at <=90 cols (DB1) +41bae99 feat(ui): two-row input display on tall terminals (DA4) +e0b9470 feat(ui): horizontal-scroll long input so the cursor stays visible (DA3) +9f5f76b fix(ui): geometry-fixed hint-panel height kills the typing jump (DA1/DA2) +93266b9 docs: ADR-0046 UI sidebar nav-mode + responsive input/hint +``` + +**Issues closed:** **#20**, **#21**, **#23** (all via ADR-0046). +**#22** (in-app overlay/keystroke-annotation layer for casts/lessons) +remains **open** — its own future ADR; adjacent but out of scope here. + +## §2. What shipped — ADR-0046 (read it; it's the source of truth) + +Three coupled UI issues, treated as one decision because they share the +terminal width/height budget. Phased A → B → C. + +**Phase A — input & hint (#20, #23).** +- **DA1/DA2 (#20):** the Hint panel height is now a pure function of + terminal geometry (`hint_rows` → later `panel_heights`), **fixed + between resizes** — it no longer resizes as you type, killing the + jump. Compact (`<40` rows) = hint 2; comfortable = hint 2, or 3 only + when the column is narrow (`inner < 54`). This **reverses issue #12's** + shrink-to-content sizing (its two tests were replaced by an anti-jump + invariant). Long hints ellipsize at the fixed budget. +- **DA3 (#23):** long input **horizontally scrolls** to keep the cursor + visible (`input_scroll_offset`, pure `input_scroll_offset()` helper), + with muted `<` / `>` edge markers; resets on submit / history. + Preserves ADR-0027's 6-col indicator reserve. +- **DA4 (#23):** on a tall terminal (`>=40` rows) the input renders + across **two visual rows** (soft-wrap of the single logical line; + indicator stays on row 1). Distinct from deferred multi-line **I1**; + `expand_runs_to_cells` is the substrate I1 should reuse. + +**Phase B — the sidebar (#21).** +- **DB1:** the left column is **width-optional** — `sidebar_visible() = + width > 90`, so it's hidden at <=90 (the 90-col screencasts) and the + right column takes the full width. (Resize a terminal below ~90 to see + it; in a normal wide terminal it shows, by design.) +- **DB2/DB4:** a **Relationships panel** stacks below Tables — each + relationship is name + endpoints broken at the arrow + (`Customers.id ->` / indented `Orders.customer_id`), ellipsized. The + panel floors at 5 rows ("(none)") and grows to a 50%-of-column cap + (`relationships_panel_height`). **Overrides S2** (relations were to be + *nested* in the tables list; a sibling panel is the honest shape). + +**Phase C — navigation mode (#21).** +- **DC1/DC4:** **`Ctrl-O`** enters a navigation mode orthogonal to the + input mode, cycling focus **Input → Tables → Relationships → Input** + (`Esc` exits). It's routed in the main key handler *after* the modal + gate, so it's inert behind a modal; in nav mode every non-nav key is + inert (the input is occluded). `NavFocus` enum on `App`. +- **DC2:** the focused panel is revealed (peek, even when width-hidden) + and drawn as a **45-col expanded overlay**, clearing the sidebar strip + **+ a one-column gutter** and leaving the base output/input/hint + visible (unchanged) to the right. *(Two variants were eyeballed; this + partial-clear-with-gutter was chosen over a full-area clear.)* +- **DC3:** the focused panel **scrolls** — Up/Down by a line, + PageUp/PageDown by its visible rows; per-panel offsets clamped to + content at render time, mirroring the output-panel scroll. + +**`Ctrl-B` was rejected** for nav mode (it's the tmux prefix → +unreachable inside tmux); `Ctrl-O` is multiplexer-safe. + +## §3. Two decisions that landed differently from the draft + +Both recorded inline in the ADR (and called out in its Status): +1. **Relationship data on `App`, not `SchemaCache`** (DB2). `SchemaCache` + is walker/completion-facing and needs only relationship *names* + (untouched); the full records are UI-only, so `App.relationships` + mirrors `app.tables`, and it avoided editing ~23 `SchemaCache` + literals. Delivered via `Database::read_all_relationships` (new worker + request) + `AppEvent::RelationshipsRefreshed` from the runtime's + schema refresh. +2. **Nav overlay = partial clear + 1-col gutter** (DC2), not a full-area + clear — truer to "underneath keeps its layout." + +## §4. Process notes + +- **The pre-build `/runda` pass earned its keep again.** It caught the + `Ctrl-B`/tmux collision, a `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 — `requirements.md` updated). +- **Snapshot discipline:** DB1's 90-col threshold collided with the + test-suite's 80-col convention — many snapshots/tests were retuned + (sidebar-dependent ones now render at 110; input tests at narrower + widths so the now-wider input still overflows). One masked-intent + integration check (matched "Customers" in output, not the panel) was + corrected. +- Each phase was committed green + clippy-clean, user-confirmed message, + no AI attribution, append-only. + +## §5. Requirements / S-items touched + +`requirements.md` annotated: **S1** (three-region layout → left region +width-optional), **S2** (*overridden* — relationships get a sibling +panel, not nested), **S4** (*corrected* — the "keyboard-toggleable" hint +claim was never implemented and is struck; the panel is always-on). + +## §6. Remaining open landscape (unchanged from handoff-62, minus the closed items) + +1. **TT5 CI** — test infra solid (2263 green); no pipeline. Gitea Actions + / Woodpecker decision + likely a Linux-first scope call. +2. **SD1 `seed`** then **H2 `hint`** — the two unblockers for **A1** + app-commands; both net-new, own ADR. SD1 should seed m:n junctions. +3. **V2/S3 multi-result tabs** or **V4 journal** — larger output-model + redesign, design-first, own ADR. +4. **C3a modify relationship** — small follow-up (drop+add covers it). +5. **#22 overlay/annotation layer** — own ADR; shares the cast + overlay + space with DC2 (designed to coexist). +6. **Tutorial/lesson system** — acknowledged in scope; needs its own ADR. + +**ADR-0046 OOS (deferred):** true multi-line input (I1); readline +shortcuts (I1b); cross-session sidebar persistence; a persistent +show/hide toggle (Ctrl-O peek covers it); output as a third nav focus; +relationship search/edit from the panel; a hint-area toggle. + +## §7. How to take over + +1. Read handoffs 61 → 62 → 63, then `CLAUDE.md`, `docs/requirements.md`, + `docs/adr/README.md`, and **ADR-0046** (fully landed). +2. **Pending:** an uncommitted docs finalization (ADR-0046 status → + *implemented*; README index status; this handoff). Commit it as + `docs: session handoff 63` (the user confirms commit messages). +3. **For UI/layout work:** `src/ui.rs` now has `panel_heights`, + `sidebar_visible`, `relationships_panel_height`, the nav overlay, and + `&mut App` sidebar panels (they report scroll viewports). `App` gained + `input_scroll_offset`, `nav_focus`, `relationships`, and the + `tables_scroll` / `relationships_scroll` (+ `last_*_visible`) fields. +4. **For relationship/schema-cache work:** relationship *names* are in + `SchemaCache.relationships` (completion); full records are on + `App.relationships` via `Database::read_all_relationships` + + `RelationshipsRefreshed`. +5. **Eyeball reminder honoured:** the user reviewed the nav overlay + appearance and chose the partial-clear + 1-col-gutter variant. +6. Run a `cargo sweep` at some point — `target/` has grown across this + build-heavy session. diff --git a/docs/handoff/20260611-handoff-64.md b/docs/handoff/20260611-handoff-64.md new file mode 100644 index 0000000..e5dbbe2 --- /dev/null +++ b/docs/handoff/20260611-handoff-64.md @@ -0,0 +1,140 @@ +# Session handoff — 2026-06-11 (64) + +Sixty-fourth handover. Continues from handoff-63 (ADR-0046 sidebar/nav). +This session closed **two unrelated, website-screencast-enabling Gitea +issues**: **#24** (vi-style load-picker navigation) and **#22** +(in-app demonstration overlay layer — its own **ADR-0047**, built end +to end across three phases + a restyle). + +## §1. State at handoff + +**Branch:** `main`. **HEAD `2d0f4b2`** plus an **uncommitted docs +finalization** (ADR-0047 status → implemented, README index, this +handoff — see §6). Push is the user's step. + +**Tests: 2290 passing / 0 failing / 0 skipped / 1 ignored** (the 1 +ignored is the long-standing `friendly` doctest). **Clippy clean** +(nursery, all targets). +27 over the handoff-63 baseline of 2263. + +**This session's commits:** +``` +2d0f4b2 feat(ui): flat filled rectangles for demo overlays (#22, ADR-0047 D4) +241f60c feat(ui): demo-mode step-caption stealth buffer (#22, ADR-0047 D3/D4) +2584e76 feat(ui): demo-mode keystroke badges (#22, ADR-0047 D2/D4/D5) +f879d54 feat(cli): --demo demonstration mode flag + app plumbing (#22, ADR-0047 D1) +e9eb1b1 docs: ADR-0047 — demonstration overlay layer for casts/teaching (#22) +638b4c9 feat(app): vi-style j/k/g/G navigation in the load picker (#24) +``` + +**Issues closed:** **#24** (vi nav) and **#22** (demo overlays) — close +#22 once the docs finalization commit lands. + +## §2. #24 — vi-style load-picker navigation (commit `638b4c9`) + +Purely additive to the ADR-0015 load picker (`handle_load_picker_key`, +`LoadPickerSubMode::List`): **`j`/`k`** mirror Down/Up (bounds- +respecting, no wrap), **`g`/`G`** jump to first/last. Existing keys +(`↑↓`/`Enter`/`Esc`/`b`) unchanged; the footer hint is **left as-is** at +the user's request (the new keys are not advertised). No ADR (additive). +Motivation: `autocast` (the website cast driver) can only send typeable +characters — not arrow keys — so the projects demo needs `j`/`k` to +drive the picker. Tests: `load_picker_jk_navigates_like_arrows`, +`load_picker_g_jumps_to_first_and_last` (test-first). + +## §3. #22 — ADR-0047 demonstration overlay layer (read the ADR) + +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. + +- **Phase A (`f879d54`):** `--demo` + env → `App.demo_mode`, threaded + through `run_loop` like `--no-undo`. `--help` line mentions **only the + visible badges**; the `Ctrl+]` caption trigger is kept low-profile + (user decision, D6). +- **Phase B (`2584e76`):** **automatic keystroke badges** + (`[TAB]`/`[ENTER]`/`[UP]`/…) over a fixed set of glyph-less keys — + pure `demo_badge_label(&KeyEvent)`, set in `App::update` **before the + modal gate** (so they fire over the load picker), expired by a **~1.5 s + runtime timer**. The timer extends the event loop's time-boxed-`recv` + via a new pure `nearest_deadline` helper; the rewrite tracks `Instant` + deadlines and was **verified not to regress the ADR-0027 indicator + debounce**. New `App.last_output_area: Rect` (set in + `render_output_panel`) anchors the overlays. +- **Phase C (`241f60c`):** the **stealth `Ctrl+]` caption buffer** — + `Ctrl+]` (byte `0x1D` → `Char('5')+CONTROL`, verified vs crossterm + 0.29) toggles an invisible buffer; typed chars accumulate without + touching input/output, `Backspace` edits, other keys inert, a second + `Ctrl+]` commits (empty commit dismisses). In pure-sync `App::update`, + intercepted **before the modal gate**; an ordinary keystroke clears a + visible caption. +- **Restyle (`2d0f4b2`):** the overlays render as **flat filled yellow + rectangles** (no border glyphs, one-cell text margin) — user decision, + deliberately unlike the bordered panels so they pop. Shared + `fill_overlay_rect` (borderless `Block` fill + inset `Paragraph`). + +**Placement:** both float at the output panel's inner bottom-right, +drawn **last over modals**, badge **stacked directly above** the caption +when both show; caption **wraps to ≤ 3 lines** then ellipsises; clamp/ +skip guard for tiny terminals. + +**Process:** ADR-first (user chose), pre-build `/runda` (10 findings, +folded in) + whole-implementation `/runda` (**PASS, no blockers**). Every +fork user-confirmed via mockups/questions, incl. the two post-draft +follow-ups: `Ctrl+]` trigger (over `Ctrl+!`, which `autocast` cannot +send — not a single ASCII byte) and wrap-to-3-line captions. + +## §4. Two things to know about the implementation + +1. **Ownership split (intentional, mirrors `input`/`input_indicator`):** + `demo_caption`/`demo_caption_capturing`/`demo_caption_buffer` are + driven by `App::update` (input); `demo_badge` is **set** by + `App::update` but its expiry is **timed by the runtime** + (`demo_badge_seq` bumps so a repeated key restarts the timer). +2. **`Ctrl-C` is inert while capturing** — by spec ("every other key is + inert"); exit capture with `Ctrl+]`. User-acknowledged; flagged in + the ADR. The only behaviour worth a second look if it ever annoys. + +## §5. Honest coverage note + +Everything *testable* is tested (label fn, full caption FSM incl. +over-modal + demo-off, `nearest_deadline`, all rendering, CLI parse/env). +The **only** untested wiring is inside `run_loop` (the badge-timer +arm/clear and `app.demo_mode = demo_mode`) — `run_loop` is not +unit-testable (terminal + DB + channels), exactly the posture the +existing `IndicatorDebounce` already takes. A future Tier-4 PTY harness +(ADR-0008 TT4, still unwired) would close it. + +## §6. How to take over + +1. Read handoffs 62 → 63 → 64, `CLAUDE.md`, `docs/requirements.md`, + `docs/adr/README.md`, and **ADR-0047** (fully landed). +2. **Pending:** the docs finalization commit (ADR-0047 status → + implemented; README index; this handoff). Commit as + `docs: session handoff 64 + ADR-0047 implemented (#22/#24)` (the user + confirms commit messages). Then close **#22** on Gitea. +3. **For demo-overlay work:** `App` has `demo_mode`, `demo_badge`, + `demo_badge_seq`, `demo_caption`, `demo_caption_capturing`, + `demo_caption_buffer`, `last_output_area`. Rendering: + `render_demo_overlays` / `render_badge_box` / `render_caption_box` / + `fill_overlay_rect` in `ui.rs`; colours `DEMO_OVERLAY_FG/BG` in + `theme.rs`; key handling `handle_demo_caption_key` + the top-of- + `handle_key` gate; timer in `runtime.rs` (`nearest_deadline`, + `DEMO_BADGE_TTL`). + +## §7. Remaining open landscape (from handoff-63, minus the closed items) + +1. **Wire the overlays into the website casts** — `casts.mjs` on the + `website` branch can now emit `^]`/text/`^]` for captions and rely on + automatic badges. Website-branch follow-up (OOS for #22's app scope). +2. **TT5 CI** — 2290 green, no pipeline yet. +3. **SD1 `seed`** then **H2 `hint`** — the unblockers for **A1** + app-commands; own ADRs. +4. **V2/S3 multi-result tabs** / **V4 journal** — larger output-model + redesign, own ADR. +5. **C3a modify relationship** — small (drop+add covers it). +6. **Tutorial/lesson system** — acknowledged in scope; needs its own + ADR; ADR-0047's overlay primitive is what it will reuse. + +Run a `cargo sweep` at some point — `target/` grew across this +build-heavy session. diff --git a/docs/requirements.md b/docs/requirements.md index 144b459..68fa1fe 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -73,24 +73,38 @@ since ADR-0027.) panel (right), input field (bottom). *(Verified 2026-06-07: `ui.rs:26-58` lays out a horizontal split — items panel left, right column subdivided into output - panel / input field / hint panel; rendered every frame.)* + panel / input field / hint panel; rendered every frame. + **ADR-0046 evolves this:** the left items region becomes + width-optional — hidden by default at ≤ 90 columns, peek-revealed + via `Ctrl-O` navigation mode — so the three-region layout is the + wide-terminal default, not an invariant.)* - [x] **S2** Items list shows tables and per-table indexes; designed to extend to additional element kinds (relations, views, etc.) without restructuring. *(ADR-0025: the items panel renders a nested list — each table with its index names indented beneath it. The nested - model is the extension point for future element kinds.)* + model is the extension point for future element kinds. + **ADR-0046 overrides the nesting approach for relationships:** + because relationships are cross-table rather than per-table, they + get their own sibling panel stacked below the tables list, not + nested items within it — user-confirmed 2026-06-10.)* - [/] **S3** Output panel renders a visualization of the currently selected item and supports multiple tabs. *(Partial, verified 2026-06-07: single-element structure visualisation renders (`output_render.rs:82-180`); **multiple tabs are not implemented** — the output is one line buffer, no tab abstraction. Same multi-tab gap as V2.)* -- [x] **S4** Hint area below the input field; keyboard-toggleable - for inspecting hints about the current input or last error. +- [x] **S4** Hint area below the input field, showing hints about + the current input or last error. *(Verified 2026-06-07: `ui.rs:1088-1110` `render_hint_panel` / `resolve_hint_lines` — a dynamic 1–`MAX_HINT_ROWS` panel below - the input showing ambient hints, candidates, or the last error.)* + the input showing ambient hints, candidates, or the last error. + **Correction (2026-06-10, ADR-0046):** the original wording said + the area was "keyboard-toggleable"; that was never implemented and + is deliberately dropped — the panel became indispensable once + completion moved into it (ADR-0022), so it is always on. ADR-0046 + replaces its content-driven height with a geometry-driven one to + stop the resize jump (#20); no toggle is added.)* - [x] **S5** Mode label and distinct border style on the input field communicate the current input mode at all times. *(Verified 2026-06-07: `ui.rs:896-934` `render_input_panel` — @@ -276,9 +290,23 @@ since ADR-0027.) the same via drop + add today; one-step modify is a small follow-up using the existing rebuild-table machinery. ADR pending. -- [ ] **C4** Convenience: `create m:n relationship from to +- [x] **C4** Convenience: `create m:n relationship from to ` produces an auto-named junction table the user can rename; pulls primary keys and FK definitions automatically. + *(Done 2026-06-10 via **ADR-0045**. `create m:n relationship from + to [as ]` builds a junction table with one FK column + per parent PK column (`{table}_{pkcol}`, typed via `fk_target_type`), + a **compound PK** over them, and two **`CASCADE`** 1:n relationships + — all in one `do_create_table` call = one undo step. Auto-named + `{T1}_{T2}` (optional `as`), available in both modes, compound-parent + PKs supported (ADR-0043). Self-referential m:n refused; PK-less parent + refused. Wired across every surface — completion (`m:n` composite), + hints, highlighting, `help`/usage, and the advanced-mode DSL→SQL + teaching echo (the generated `CREATE TABLE … FOREIGN KEY …`). 9 + integration + 7 typing-surface + echo/parse unit tests. The build + surfaced — and fixed — two latent simple-mode dispatch/completion + assumptions ("≤1 DSL form per entry word"), now generalized + behaviour-preservingly.)* - [x] **C5** Data operations: insert / update / delete via DSL. *(ADR-0014. INSERT short and long forms, UPDATE/DELETE with required WHERE plus `--all-rows` opt-in, `show data `, @@ -804,17 +832,27 @@ since ADR-0027.) ## Cross-cutting -- [/] **X1** Comprehensive logging via the project's logging +- [x] **X1** Comprehensive logging via the project's logging infrastructure per `CLAUDE.md` (decision points, parameter values, fallback paths). - *(Partial, verified 2026-06-07: the logging **harness** is - wired — `src/logging.rs` sets up file-backed `tracing` with an - env filter — but instrumentation is **sparse**: ~25 `tracing::` - call sites across the tree, concentrated in `runtime.rs` and - `undo.rs` and mostly error/warning on failure paths. The - decision-point / parameter-value / fallback-path coverage the - `CLAUDE.md` "log liberally" standard calls for — especially in - `db.rs`, the parser, and the executors — is largely absent.)* + *(Done 2026-06-10 via a full-sweep instrumentation pass. The + prior state (verified 2026-06-07) was a wired harness + (`src/logging.rs`) but sparse instrumentation — failure-path + heavy, nothing in `db.rs`/parser/executors. The sweep brought + every layer to the "log liberally" bar under a documented level + discipline (see the `logging.rs` module doc): **`db.rs`** gained + entry-level `debug!` on all 34 `do_*` executors plus decision-point + logs (rebuild-table primitive, insert auto-fill, delete cascade, + FK resolution) — so the route through delegating executors is + visible in the log sequence; **persistence** logs every + yaml/CSV/history write (the silent-failure paths); **runtime** + logs `execute_command_typed` dispatch; **`app.rs`** logs + submit / app-command dispatch / render-mode choice; the **parser** + logs parse begin/outcome at `trace` (it is a per-keystroke hot + path). Levels: `debug` for per-command detail (off by default, + `RDBMS_PLAYGROUND_LOG=debug`), `info` for lifecycle, `warn` for + fallbacks, `trace` for hot paths. Emission verified end-to-end + through the real worker thread + `logging::init`. ~75 → ~135 sites.)* - [~] **X2** Language: English-only for v1; multi-language is an open question to revisit later. - [~] **X3** Accessibility: TUI screen-reader support is diff --git a/docs/simple-mode-limitations.md b/docs/simple-mode-limitations.md index 331b848..d89a1b1 100644 --- a/docs/simple-mode-limitations.md +++ b/docs/simple-mode-limitations.md @@ -41,6 +41,22 @@ entry names the ADR that drew the boundary. ## Table creation (ADR-0029) +- **A simple-mode table always has a primary key; an advanced-mode + table need not.** `create table … with pk …` is mandatory in simple + mode (ADR-0029) — the bare `with pk` even defaults to `id serial`. + Advanced-mode SQL follows standard SQL and permits a *PK-less* table: + `create table t (a int)` declares no primary key. This is **not** a + storage problem — every ordinary table (STRICT included) carries + SQLite's implicit `rowid`, which keys it internally; only a + `WITHOUT ROWID` table (which this app never creates) would lack one. + So the simple-mode requirement is a *pedagogical* boundary (teach that + tables should have a key), not an engine constraint. Consequences in a + PK-less table, all handled: `show data … limit` falls back to rowid + order (no stable user-facing key to order by); `update` / `delete` + still target the affected rows by rowid; and there is no "PK column" + to drop — dropping a *declared* PK column is refused in **both** modes + (the shared `do_drop_column` guard: *"cannot drop primary-key column + …"*). - **`create table` declares only primary-key columns.** `create table T with pk …` makes every listed column part of the primary key; there is no simple-mode syntax for a diff --git a/src/app.rs b/src/app.rs index 75ec9d9..56fc7fc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,6 +9,7 @@ use std::collections::VecDeque; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::layout::Rect; use tracing::{debug, trace, warn}; use crate::action::Action; @@ -226,6 +227,28 @@ impl EffectiveMode { } } +/// Navigation-mode focus cursor (ADR-0046 DC1). +/// +/// `Input` means not in navigation mode — keystrokes edit the command +/// input as usual. `Ctrl-O` cycles Input → SidebarTables → +/// SidebarRelationships → Input; while a sidebar panel is focused the +/// sidebar is revealed (peek) and expanded as an overlay, and scroll +/// keys drive it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum NavFocus { + #[default] + Input, + SidebarTables, + SidebarRelationships, +} + +impl NavFocus { + /// True while a sidebar panel is focused (navigation mode is active). + pub const fn in_sidebar(self) -> bool { + matches!(self, Self::SidebarTables | Self::SidebarRelationships) + } +} + #[derive(Debug)] pub struct App { pub mode: Mode, @@ -237,6 +260,15 @@ pub struct App { /// Byte offset into `input` where the next character will be /// inserted. Always lies on a UTF-8 character boundary. pub input_cursor: usize, + /// First visible display column of the input line when it is too + /// long to fit the input panel (ADR-0046 DA3). The renderer keeps + /// the cursor in view by adjusting this; it resets to 0 whenever the + /// buffer is replaced wholesale (submit / history navigation). + pub input_scroll_offset: usize, + /// Navigation-mode focus cursor (ADR-0046 DC1). `Input` when not in + /// navigation mode. Driven by `Ctrl-O` / `Esc`; the renderer reveals + /// + expands the focused sidebar panel as an overlay. + pub nav_focus: NavFocus, pub output: VecDeque, pub hint: Option, /// The validity indicator's currently-visible verdict @@ -247,6 +279,12 @@ pub struct App { /// [`App::input_validity_verdict`] once typing pauses. pub input_indicator: Option, pub tables: Vec, + /// All relationships as full schema records, for the sidebar + /// relationships panel (ADR-0046 DB2). Refreshed by the runtime + /// alongside `tables`. Kept on the App (not `SchemaCache`) because + /// only the UI needs the details — the walker/completion need just + /// the names, which stay in `SchemaCache::relationships`. + pub relationships: Vec, /// Last successfully described table, shown in the output /// pane until the next DDL operation. pub current_table: Option, @@ -286,6 +324,20 @@ pub struct App { /// diagram's side-by-side vs vertical layout choice. Defaults to /// `80` until the first render measures the real width. pub last_output_width: u16, + /// The most recent **inner area** (inside the border) of the output + /// panel, recorded by the renderer (ADR-0047 D4). The demo overlays + /// anchor to its bottom-right corner; read at the top-level draw + /// pass, which otherwise does not know where the output panel sits. + /// Zero-sized until the first render measures it. + pub last_output_area: Rect, + /// Top visible row of the Tables / Relationships sidebar panels + /// while scrolled in navigation mode (ADR-0046 DC3), with the most + /// recent visible-row count the renderer reported for each (used to + /// page-scroll and to clamp the offset). `0` = showing from the top. + pub tables_scroll: usize, + pub relationships_scroll: usize, + pub last_tables_visible: usize, + pub last_relationships_visible: usize, /// Prettified display name of the currently-open project, /// rendered in the status bar (P-NAME-3, ADR-0015 §2). `None` /// during very-early startup before the runtime has opened a @@ -323,6 +375,38 @@ pub struct App { /// flag; the `undo` / `redo` commands then report undo is off /// rather than emitting a prepare action. pub undo_enabled: bool, + /// Whether **demonstration mode** is active this session (ADR-0047, + /// issue #22). `true` under `--demo` / `RDBMS_PLAYGROUND_DEMO`. When + /// off (the default) none of the demo key handling or overlay + /// rendering runs — zero footprint. When on, otherwise-invisible + /// keys raise a transient badge (`demo_badge`) and `Ctrl+]` drives + /// the stealth step-caption buffer (`demo_caption` / `demo_capturing`, + /// Phase C). + pub demo_mode: bool, + /// The keystroke badge currently displayed in demo mode (ADR-0047 + /// D2), e.g. `"[TAB]"`. Set in `update()` when an otherwise-invisible + /// key is handled; cleared by the runtime when its ~1.5 s timer + /// elapses (the timing lives in the runtime, mirroring how + /// `input_indicator` is driven from `IndicatorDebounce`). `None` when + /// no badge is showing. + pub demo_badge: Option<&'static str>, + /// Monotonic counter bumped every time `demo_badge` is (re)set + /// (ADR-0047 D5). The runtime watches it so a *new* badge — even the + /// same label twice in a row (Tab, Tab) — restarts the expiry timer. + pub demo_badge_seq: u64, + /// The step-caption currently displayed in demo mode (ADR-0047 D3), + /// or `None`. Committed from the stealth buffer on the closing + /// `Ctrl+]`; cleared by the next ordinary keystroke (or an empty + /// commit). Rendered as a wrapped box stacked above the badge. + pub demo_caption: Option, + /// Whether the stealth caption buffer is open (ADR-0047 D3): between + /// the opening and closing `Ctrl+]`, typed characters accumulate into + /// `demo_caption_buffer` invisibly and every other key is inert. + pub demo_caption_capturing: bool, + /// The invisible accumulator for the caption being typed while + /// `demo_caption_capturing` (ADR-0047 D3). Never rendered directly; + /// its trimmed contents become `demo_caption` on commit. + pub demo_caption_buffer: String, /// The DSL → SQL teaching echo (ADR-0038) for the command currently /// being rendered: set from the success event just before its handler /// runs, consumed by `note_ok_summary` (which pushes it beneath @@ -425,6 +509,36 @@ const PAGE_SCROLL_LINES: usize = 5; const HISTORY_CAPACITY: usize = 1000; +/// The demo-mode keystroke badge for `key`, or `None` if the key +/// produces a glyph of its own (and so needs no badge) — ADR-0047 D2. +/// +/// The set is exactly the *otherwise-invisible* keys: motion, editing, +/// submission, and the `Ctrl-O` navigation toggle. Plain character keys +/// already appear on the input line, and `Ctrl-C` (quit) / `Ctrl+]` +/// (the caption toggle) are deliberately excluded. Pure and total, so +/// it is exhaustively unit-testable without a running app. +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::Enter, _) => Some("[ENTER]"), + (KeyCode::Esc, _) => Some("[ESC]"), + (KeyCode::Up, _) => Some("[UP]"), + (KeyCode::Down, _) => Some("[DOWN]"), + (KeyCode::Left, _) => Some("[LEFT]"), + (KeyCode::Right, _) => Some("[RIGHT]"), + (KeyCode::Home, _) => Some("[HOME]"), + (KeyCode::End, _) => Some("[END]"), + (KeyCode::PageUp, _) => Some("[PGUP]"), + (KeyCode::PageDown, _) => Some("[PGDN]"), + (KeyCode::Backspace, _) => Some("[BKSP]"), + (KeyCode::Delete, _) => Some("[DEL]"), + // The only badged control chord: the ADR-0046 navigation toggle. + (KeyCode::Char('o'), m) if m.contains(KeyModifiers::CONTROL) => Some("[CTRL-O]"), + _ => None, + } +} + impl Default for App { fn default() -> Self { Self::new() @@ -439,10 +553,13 @@ impl App { messages_verbosity: crate::friendly::Verbosity::default(), input: String::new(), input_cursor: 0, + input_scroll_offset: 0, + nav_focus: NavFocus::Input, output: VecDeque::with_capacity(OUTPUT_CAPACITY), hint: None, input_indicator: None, tables: Vec::new(), + relationships: Vec::new(), current_table: None, history: Vec::new(), history_cursor: None, @@ -451,6 +568,11 @@ impl App { last_output_visible: 0, last_output_total_wrapped: 0, last_output_width: 80, + last_output_area: Rect::new(0, 0, 0, 0), + tables_scroll: 0, + relationships_scroll: 0, + last_tables_visible: 0, + last_relationships_visible: 0, project_name: None, project_is_temp: false, fatal_message: None, @@ -460,6 +582,14 @@ impl App { // Undo is on by default; the runtime flips this off for // a `--no-undo` session (ADR-0006 Amendment 1). undo_enabled: true, + // Demo mode is off by default; the runtime flips it on for + // a `--demo` session (ADR-0047). + demo_mode: false, + demo_badge: None, + demo_badge_seq: 0, + demo_caption: None, + demo_caption_capturing: false, + demo_caption_buffer: String::new(), pending_echo: None, } } @@ -715,6 +845,11 @@ impl App { self.schema_cache = cache; Vec::new() } + AppEvent::RelationshipsRefreshed(relationships) => { + trace!(count = relationships.len(), "relationships refreshed"); + self.relationships = relationships; + Vec::new() + } AppEvent::PersistenceFatal { operation, path, @@ -904,6 +1039,64 @@ impl App { } } + /// ADR-0046 DC1: advance the navigation focus cycle. From `Input` + /// it enters navigation mode on the Tables panel (revealing + + /// expanding the sidebar via the renderer); the third press returns + /// to the command input. + fn nav_advance(&mut self) { + self.nav_focus = match self.nav_focus { + NavFocus::Input => NavFocus::SidebarTables, + NavFocus::SidebarTables => NavFocus::SidebarRelationships, + NavFocus::SidebarRelationships => NavFocus::Input, + }; + trace!(nav_focus = ?self.nav_focus, "navigation focus advanced"); + } + + /// Leave navigation mode, returning focus to the command input + /// (ADR-0046 DC1 — the `Esc` shortcut for the cycle's last step). + const fn nav_exit(&mut self) { + self.nav_focus = NavFocus::Input; + } + + /// ADR-0046 DC3/DC4: key handling while a sidebar panel is focused. + /// `Esc` exits navigation mode; scroll keys drive the focused panel + /// (wired in DC3); every other key is inert because the command + /// input is occluded by the expanded sidebar overlay. + fn handle_nav_key(&mut self, key: KeyEvent) -> Vec { + match key.code { + KeyCode::Esc => self.nav_exit(), + KeyCode::Up => self.nav_scroll(-1), + KeyCode::Down => self.nav_scroll(1), + KeyCode::PageUp => self.nav_scroll_page(-1), + KeyCode::PageDown => self.nav_scroll_page(1), + _ => {} + } + Vec::new() + } + + /// Scroll the focused sidebar panel by `lines` (ADR-0046 DC3); the + /// renderer clamps the offset to the panel's content on the next + /// frame, so over-scrolling is harmless. + const fn nav_scroll(&mut self, lines: i32) { + let slot = match self.nav_focus { + NavFocus::SidebarTables => &mut self.tables_scroll, + NavFocus::SidebarRelationships => &mut self.relationships_scroll, + NavFocus::Input => return, + }; + *slot = slot.saturating_add_signed(lines as isize); + } + + /// Page-scroll the focused panel by its last reported visible-row + /// count (ADR-0046 DC3). + fn nav_scroll_page(&mut self, dir: i32) { + let visible = match self.nav_focus { + NavFocus::SidebarTables => self.last_tables_visible, + NavFocus::SidebarRelationships => self.last_relationships_visible, + NavFocus::Input => return, + }; + self.nav_scroll(dir * (visible.max(1) as i32)); + } + fn handle_key(&mut self, key: KeyEvent) -> Vec { // On Windows, key events fire for both Press and Release; // honour only Press to avoid double-handling. Other @@ -913,6 +1106,32 @@ impl App { } trace!(?key, "handle_key"); + // ADR-0047 D3: the demo step-caption stealth buffer runs before + // every other gate — even ahead of the badge and the modal gate — + // so it can be authored over the load picker (the `#24` cast) and + // so captured keystrokes never leak into the input, a badge, or a + // command. `Ctrl+]` toggles capture; while capturing, the key is + // consumed here. + if self.demo_mode { + if let Some(actions) = self.handle_demo_caption_key(key) { + return actions; + } + // Not a caption key: any ordinary keystroke dismisses a + // visible caption (it then falls through to normal handling). + self.demo_caption = None; + } + + // ADR-0047 D2: in demo mode raise a transient badge for an + // otherwise-invisible key. Done before the modal / nav gates so + // it fires even while a modal is open (the `#24` projects cast) + // or in navigation mode. The runtime times its expiry (D5). + if self.demo_mode + && let Some(label) = demo_badge_label(&key) + { + self.demo_badge = Some(label); + self.demo_badge_seq = self.demo_badge_seq.wrapping_add(1); + } + // While a modal is open it owns the keyboard. Normal // input editing, history navigation, and command // submission are all gated behind closing the modal. @@ -920,6 +1139,20 @@ impl App { return self.handle_modal_key(key); } + // ADR-0046 DC1: `Ctrl-O` cycles navigation focus from any state + // (Input → Tables → Relationships → Input), inert only behind a + // modal (handled above). + if (key.code, key.modifiers) == (KeyCode::Char('o'), KeyModifiers::CONTROL) { + self.nav_advance(); + return Vec::new(); + } + + // DC3/DC4: in navigation mode, keys drive the focused sidebar + // panel (scroll) or are inert; the command input is occluded. + if self.nav_focus.in_sidebar() { + return self.handle_nav_key(key); + } + // 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 @@ -1002,6 +1235,59 @@ impl App { } } + /// Drive the demo step-caption stealth buffer (ADR-0047 D3). + /// + /// Returns `Some(_)` when the key belongs to the caption mechanism + /// (the `Ctrl+]` toggle, or any key while capturing) — the caller + /// then returns it and processes nothing else. Returns `None` when + /// the key is not consumed, so normal handling continues. + /// + /// `Ctrl+]` decodes to `Char('5') + CONTROL` (ADR-0047 D3, verified + /// against crossterm 0.29). Only active in demo mode (the caller + /// gates on `self.demo_mode`). + fn handle_demo_caption_key(&mut self, key: KeyEvent) -> Option> { + let is_toggle = key.code == KeyCode::Char('5') + && key.modifiers.contains(KeyModifiers::CONTROL); + + if self.demo_caption_capturing { + if is_toggle { + // Commit: a trimmed, non-empty buffer becomes the caption; + // an empty commit dismisses any caption (explicit clear). + self.demo_caption_capturing = false; + let text = std::mem::take(&mut self.demo_caption_buffer); + let trimmed = text.trim(); + self.demo_caption = + (!trimmed.is_empty()).then(|| trimmed.to_string()); + } else { + match key.code { + // Plain characters accumulate invisibly; the prompt + // and output are untouched. + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + self.demo_caption_buffer.push(c); + } + KeyCode::Backspace => { + self.demo_caption_buffer.pop(); + } + // Every other key (Enter, arrows, Tab, …) is inert + // while capturing. + _ => {} + } + } + return Some(Vec::new()); + } + + if is_toggle { + // Open capture. Starting a new annotation clears any caption + // currently on screen. + self.demo_caption_capturing = true; + self.demo_caption_buffer.clear(); + self.demo_caption = None; + return Some(Vec::new()); + } + + None + } + fn cursor_left(&mut self) { let mut idx = self.input_cursor; while idx > 0 { @@ -1232,6 +1518,7 @@ impl App { self.history_cursor = Some(next_index); self.input = self.history[next_index].clone(); self.input_cursor = self.input.len(); + self.input_scroll_offset = 0; } /// Move forwards in history (towards newer entries; eventually @@ -1250,6 +1537,7 @@ impl App { self.input = self.history_draft.take().unwrap_or_default(); } self.input_cursor = self.input.len(); + self.input_scroll_offset = 0; } fn cancel_history_navigation(&mut self) { @@ -1284,6 +1572,7 @@ impl App { fn submit(&mut self) -> Vec { let raw = std::mem::take(&mut self.input); self.input_cursor = 0; + self.input_scroll_offset = 0; let trimmed = raw.trim(); if trimmed.is_empty() { return Vec::new(); @@ -1311,6 +1600,13 @@ impl App { return Vec::new(); } + debug!( + persistent_mode = ?self.mode, + submission_mode = ?submission_mode, + len = effective_input.len(), + "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 @@ -1342,6 +1638,7 @@ impl App { source: &str, ) -> Vec { use crate::dsl::{AppCommand, MessagesValue, ModeValue}; + debug!(command = ?cmd, "dispatch app command"); match cmd { AppCommand::Quit => vec![Action::Quit], AppCommand::Help { topic } => { @@ -1700,6 +1997,7 @@ impl App { | Command::AddRelationship { .. } | Command::DropRelationship { .. } ) { + debug!(verb = command.verb(), width = self.last_output_width, "render: relationship diagrams (ADR-0044)"); for line in crate::output_render::render_structure_with_diagrams( desc, self.last_output_width, @@ -2049,6 +2347,10 @@ impl App { // column for a compound FK (ADR-0043). parent_columns.first().map(String::as_str), ), + // m:n builds a junction table; its errors (missing parent, + // no PK, self-reference, name collision) name the relevant + // table in the message, so no fallback table/column here. + C::CreateM2nRelationship { .. } => (Operation::CreateTable, None, None), C::DropRelationship { selector } => match selector { RelationshipSelector::Endpoints { parent_table, @@ -2343,20 +2645,34 @@ impl App { self.note_system(crate::t!("modal.load_cancelled")); Vec::new() } - KeyCode::Up => { + // `k` mirrors Up; vi-style keys keep the picker drivable by + // autocast, which can only emit typeable characters (#24). + KeyCode::Up | KeyCode::Char('k') => { if state.selected > 0 { state.selected -= 1; } self.modal = Some(Modal::LoadPicker(state)); Vec::new() } - KeyCode::Down => { + // `j` mirrors Down (see the Up arm above). + KeyCode::Down | KeyCode::Char('j') => { if state.selected + 1 < state.entries.len() { state.selected += 1; } self.modal = Some(Modal::LoadPicker(state)); Vec::new() } + // `g` jumps to the first entry, `G` to the last (vi convention). + KeyCode::Char('g') => { + state.selected = 0; + self.modal = Some(Modal::LoadPicker(state)); + Vec::new() + } + KeyCode::Char('G') => { + state.selected = state.entries.len().saturating_sub(1); + self.modal = Some(Modal::LoadPicker(state)); + Vec::new() + } KeyCode::Enter => { if let Some(entry) = state.entries.get(state.selected).cloned() { self.modal = None; @@ -2764,6 +3080,209 @@ mod tests { AppEvent::Key(KeyEvent::new(code, mods)) } + // ---- ADR-0047 (issue #22): demo-mode keystroke badges ---- + + fn ke(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent::new(code, mods) + } + + #[test] + fn demo_badge_label_maps_the_invisible_keys() { + let none = KeyModifiers::NONE; + assert_eq!(demo_badge_label(&ke(KeyCode::Tab, none)), Some("[TAB]")); + assert_eq!(demo_badge_label(&ke(KeyCode::BackTab, KeyModifiers::SHIFT)), Some("[SHIFT-TAB]")); + assert_eq!(demo_badge_label(&ke(KeyCode::Enter, none)), Some("[ENTER]")); + assert_eq!(demo_badge_label(&ke(KeyCode::Esc, none)), Some("[ESC]")); + assert_eq!(demo_badge_label(&ke(KeyCode::Up, none)), Some("[UP]")); + assert_eq!(demo_badge_label(&ke(KeyCode::Down, none)), Some("[DOWN]")); + assert_eq!(demo_badge_label(&ke(KeyCode::Left, none)), Some("[LEFT]")); + assert_eq!(demo_badge_label(&ke(KeyCode::Right, none)), Some("[RIGHT]")); + assert_eq!(demo_badge_label(&ke(KeyCode::Home, none)), Some("[HOME]")); + assert_eq!(demo_badge_label(&ke(KeyCode::End, none)), Some("[END]")); + assert_eq!(demo_badge_label(&ke(KeyCode::PageUp, none)), Some("[PGUP]")); + assert_eq!(demo_badge_label(&ke(KeyCode::PageDown, none)), Some("[PGDN]")); + assert_eq!(demo_badge_label(&ke(KeyCode::Backspace, none)), Some("[BKSP]")); + assert_eq!(demo_badge_label(&ke(KeyCode::Delete, none)), Some("[DEL]")); + assert_eq!( + demo_badge_label(&ke(KeyCode::Char('o'), KeyModifiers::CONTROL)), + Some("[CTRL-O]") + ); + } + + #[test] + fn demo_badge_label_none_for_glyphs_and_excluded_chords() { + // Plain characters render their own glyph — no badge. + assert_eq!(demo_badge_label(&ke(KeyCode::Char('a'), KeyModifiers::NONE)), None); + assert_eq!(demo_badge_label(&ke(KeyCode::Char(' '), KeyModifiers::NONE)), None); + // Quit and the (Phase C) caption toggle are deliberately excluded. + assert_eq!(demo_badge_label(&ke(KeyCode::Char('c'), KeyModifiers::CONTROL)), None); + // Ctrl+] decodes to Char('5')+CONTROL — must not badge. + assert_eq!(demo_badge_label(&ke(KeyCode::Char('5'), KeyModifiers::CONTROL)), None); + } + + #[test] + fn demo_mode_off_never_sets_a_badge() { + let mut app = App::new(); + assert!(!app.demo_mode); + app.update(key(KeyCode::Tab)); + assert_eq!(app.demo_badge, None); + assert_eq!(app.demo_badge_seq, 0); + } + + #[test] + fn demo_mode_on_sets_badge_and_bumps_seq() { + let mut app = App::new(); + app.demo_mode = true; + + app.update(key(KeyCode::Tab)); + assert_eq!(app.demo_badge, Some("[TAB]")); + assert_eq!(app.demo_badge_seq, 1); + + app.update(key(KeyCode::Enter)); + assert_eq!(app.demo_badge, Some("[ENTER]")); + assert_eq!(app.demo_badge_seq, 2); + + // The same label twice still bumps the seq so the runtime + // restarts the expiry timer. + app.update(key(KeyCode::Enter)); + assert_eq!(app.demo_badge, Some("[ENTER]")); + assert_eq!(app.demo_badge_seq, 3); + + // A glyph key leaves the badge (and seq) untouched — the + // runtime's timer is what clears it, not the next key. + app.update(key(KeyCode::Char('x'))); + assert_eq!(app.demo_badge, Some("[ENTER]")); + assert_eq!(app.demo_badge_seq, 3); + } + + #[test] + fn demo_badge_fires_over_an_open_modal() { + // Badges are set before the modal gate, so the `#24` projects + // cast can show [ENTER]/[DOWN] while the load picker is up. + let mut app = App::new(); + app.demo_mode = true; + app.modal = Some(Modal::LoadPicker(LoadPickerModal { + entries: Vec::new(), + selected: 0, + sub_mode: LoadPickerSubMode::List, + })); + app.update(key(KeyCode::Down)); + assert_eq!(app.demo_badge, Some("[DOWN]")); + assert_eq!(app.demo_badge_seq, 1); + } + + // ---- ADR-0047 (issue #22): demo-mode step-caption stealth buffer ---- + + /// `Ctrl+]` — the caption toggle (decodes to Char('5')+CONTROL). + fn caption_toggle() -> AppEvent { + key_mod(KeyCode::Char('5'), KeyModifiers::CONTROL) + } + + #[test] + fn demo_caption_toggle_captures_then_commits() { + let mut app = App::new(); + app.demo_mode = true; + + app.update(caption_toggle()); + assert!(app.demo_caption_capturing, "first Ctrl+] opens capture"); + assert_eq!(app.demo_caption, None); + + type_str(&mut app, "Press Tab"); + // The text accumulates invisibly — nothing on the input line. + assert_eq!(app.input, ""); + assert_eq!(app.demo_caption_buffer, "Press Tab"); + assert_eq!(app.demo_caption, None, "not shown until committed"); + + app.update(caption_toggle()); + assert!(!app.demo_caption_capturing, "second Ctrl+] commits"); + assert_eq!(app.demo_caption.as_deref(), Some("Press Tab")); + assert_eq!(app.demo_caption_buffer, "", "buffer drained on commit"); + assert_eq!(app.input, "", "input never touched"); + } + + #[test] + fn demo_caption_backspace_edits_the_buffer() { + let mut app = App::new(); + app.demo_mode = true; + app.update(caption_toggle()); + type_str(&mut app, "Helloo"); + app.update(key(KeyCode::Backspace)); + assert_eq!(app.demo_caption_buffer, "Hello"); + assert_eq!(app.input, ""); + } + + #[test] + fn demo_caption_other_keys_are_inert_while_capturing() { + let mut app = App::new(); + app.demo_mode = true; + app.update(caption_toggle()); + type_str(&mut app, "note"); + // Enter must not submit, Tab must not complete, arrows do nothing. + let a1 = app.update(key(KeyCode::Enter)); + let a2 = app.update(key(KeyCode::Tab)); + let a3 = app.update(key(KeyCode::Up)); + assert!(a1.is_empty() && a2.is_empty() && a3.is_empty()); + assert!(app.demo_caption_capturing, "still capturing"); + assert_eq!(app.demo_caption_buffer, "note"); + assert_eq!(app.input, ""); + assert_eq!(app.demo_badge, None, "inert keys raise no badge while capturing"); + } + + #[test] + fn demo_caption_empty_commit_dismisses() { + let mut app = App::new(); + app.demo_mode = true; + app.demo_caption = Some("old".to_string()); + // Open (clears the visible caption) then commit empty. + app.update(caption_toggle()); + assert_eq!(app.demo_caption, None, "opening clears the visible caption"); + app.update(caption_toggle()); + assert_eq!(app.demo_caption, None, "empty commit leaves nothing"); + assert!(!app.demo_caption_capturing); + } + + #[test] + fn demo_caption_cleared_by_next_ordinary_keystroke() { + let mut app = App::new(); + app.demo_mode = true; + app.demo_caption = Some("step 1".to_string()); + // An ordinary key clears the caption, then is processed normally. + app.update(key(KeyCode::Char('a'))); + assert_eq!(app.demo_caption, None); + assert_eq!(app.input, "a", "the key still reaches the input"); + } + + #[test] + fn demo_caption_captures_over_an_open_modal() { + // The stealth buffer sits before the modal gate, so captions can + // be authored while the load picker is up (the `#24` cast). + let mut app = App::new(); + app.demo_mode = true; + app.modal = Some(Modal::LoadPicker(LoadPickerModal { + entries: Vec::new(), + selected: 0, + sub_mode: LoadPickerSubMode::List, + })); + app.update(caption_toggle()); + type_str(&mut app, "pick one"); + app.update(caption_toggle()); + assert_eq!(app.demo_caption.as_deref(), Some("pick one")); + // The modal is untouched by the capture. + assert!(matches!(app.modal, Some(Modal::LoadPicker(_)))); + } + + #[test] + fn demo_mode_off_makes_ctrl_rbracket_inert() { + let mut app = App::new(); + assert!(!app.demo_mode); + app.update(caption_toggle()); + type_str(&mut app, "x"); + assert!(!app.demo_caption_capturing); + assert_eq!(app.demo_caption, None); + // Ctrl+] did nothing; the later 'x' is an ordinary character. + assert_eq!(app.input, "x"); + } + fn type_str(app: &mut App, s: &str) { for c in s.chars() { app.update(key(KeyCode::Char(c))); @@ -2918,13 +3437,15 @@ mod tests { #[test] fn tab_at_word_boundary_inserts_next_expected_keyword() { - // `create ` → expects only `table`. Single candidate; - // insert "table " with space, no memo. + // `change ` → expects only `column`. Single candidate; + // insert "column " with space, no memo. (Uses `change`, not + // `create`: ADR-0045 made `create ` ambiguous — `table` vs + // `m:n` — so it is no longer a single-candidate boundary.) let mut app = App::new(); - type_str(&mut app, "create "); + type_str(&mut app, "change "); let actions = app.update(key(KeyCode::Tab)); assert!(actions.is_empty()); - assert_eq!(app.input, "create table "); + assert_eq!(app.input, "change column "); assert!(app.last_completion.is_none()); } @@ -3071,17 +3592,19 @@ mod tests { // Stage-8 follow-up #2 (testing-round-2): the // single-candidate-no-memo design lets the user chain // Tabs through unique completions without getting - // stuck. From "cr", Tab → "create ", Tab → "create - // table ". (Round 5 added the app-lifecycle commands — + // stuck. From "ch", Tab → "change ", Tab → "change + // column ". (Round 5 added the app-lifecycle commands — // single-letter prefixes like `i` are now ambiguous // (`insert` vs. `import`), so the test starts from a - // disambiguated two-letter prefix.) + // disambiguated two-letter prefix. `change` is used rather + // than `create`: ADR-0045 made `create ` ambiguous (`table` + // vs `m:n`), so it no longer chains as a unique completion.) let mut app = App::new(); - type_str(&mut app, "cr"); + type_str(&mut app, "ch"); app.update(key(KeyCode::Tab)); - assert_eq!(app.input, "create "); + assert_eq!(app.input, "change "); app.update(key(KeyCode::Tab)); - assert_eq!(app.input, "create table "); + assert_eq!(app.input, "change column "); assert!(app.last_completion.is_none()); } @@ -5072,6 +5595,121 @@ mod tests { assert_eq!(app.input_cursor, 0); } + #[test] + fn relationships_refreshed_event_updates_the_field() { + // ADR-0046 DB2: the runtime posts RelationshipsRefreshed; the + // App stores it for the sidebar relationships panel to render. + use crate::dsl::action::ReferentialAction; + let mut app = App::new(); + assert!(app.relationships.is_empty()); + app.update(AppEvent::RelationshipsRefreshed(vec![ + crate::persistence::RelationshipSchema { + name: "Customers_Orders".to_string(), + parent_table: "Customers".to_string(), + parent_columns: vec!["id".to_string()], + child_table: "Orders".to_string(), + child_columns: vec!["customer_id".to_string()], + on_delete: ReferentialAction::Cascade, + on_update: ReferentialAction::NoAction, + }, + ])); + assert_eq!(app.relationships.len(), 1); + assert_eq!(app.relationships[0].name, "Customers_Orders"); + } + + #[test] + fn ctrl_o_cycles_navigation_focus() { + // ADR-0046 DC1: Input → Tables → Relationships → Input. + let mut app = App::new(); + assert_eq!(app.nav_focus, NavFocus::Input); + let ctrl_o = || key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL); + app.update(ctrl_o()); + assert_eq!(app.nav_focus, NavFocus::SidebarTables); + app.update(ctrl_o()); + assert_eq!(app.nav_focus, NavFocus::SidebarRelationships); + app.update(ctrl_o()); + assert_eq!(app.nav_focus, NavFocus::Input); + } + + #[test] + fn esc_exits_navigation_mode() { + let mut app = App::new(); + app.update(key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL)); + assert!(app.nav_focus.in_sidebar()); + app.update(key(KeyCode::Esc)); + assert_eq!(app.nav_focus, NavFocus::Input); + } + + #[test] + fn navigation_mode_ignores_input_keys() { + // ADR-0046 DC4: the input is occluded; printable/Enter/Backspace + // are inert while a sidebar panel is focused. + let mut app = App::new(); + type_str(&mut app, "select"); + app.update(key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL)); + app.update(key(KeyCode::Char('x'))); + app.update(key(KeyCode::Backspace)); + let actions = app.update(key(KeyCode::Enter)); + assert_eq!(app.input, "select", "input untouched in navigation mode"); + assert!(actions.is_empty(), "Enter does not submit in navigation mode"); + } + + #[test] + fn nav_scroll_keys_move_only_the_focused_panel() { + // ADR-0046 DC3: Up/Down line-scroll the focused sidebar panel. + let mut app = App::new(); + app.nav_focus = NavFocus::SidebarTables; + app.update(key(KeyCode::Down)); + app.update(key(KeyCode::Down)); + assert_eq!(app.tables_scroll, 2); + assert_eq!(app.relationships_scroll, 0, "only the focused panel scrolls"); + app.update(key(KeyCode::Up)); + assert_eq!(app.tables_scroll, 1); + // Up saturates at the top. + app.update(key(KeyCode::Up)); + app.update(key(KeyCode::Up)); + assert_eq!(app.tables_scroll, 0); + // Switching focus moves the other panel instead. + app.nav_focus = NavFocus::SidebarRelationships; + app.update(key(KeyCode::Down)); + assert_eq!(app.relationships_scroll, 1); + assert_eq!(app.tables_scroll, 0); + } + + #[test] + fn nav_page_scroll_uses_the_panels_visible_rows() { + // ADR-0046 DC3: PageUp/PageDown move by the last reported + // visible-row count. + let mut app = App::new(); + app.nav_focus = NavFocus::SidebarTables; + app.last_tables_visible = 10; + app.update(key(KeyCode::PageDown)); + assert_eq!(app.tables_scroll, 10); + app.update(key(KeyCode::PageUp)); + assert_eq!(app.tables_scroll, 0); + } + + #[test] + fn input_scroll_offset_resets_when_the_buffer_is_replaced() { + // ADR-0046 DA3: the horizontal scroll offset must not leak from + // one command to the next. Submitting and recalling from history + // both replace the buffer wholesale, so both reset it. + let mut app = App::new(); + type_str(&mut app, "a long command line that would have scrolled"); + app.input_scroll_offset = 25; + submit(&mut app); + assert_eq!(app.input_scroll_offset, 0, "submit resets the input scroll"); + + // Recall the submitted line from history — also a reset. + type_str(&mut app, "another draft line entirely"); + app.input_scroll_offset = 25; + app.update(key(KeyCode::Up)); + assert_eq!( + app.input_scroll_offset, 0, + "history recall resets the input scroll" + ); + } + #[test] fn page_up_scrolls_output_back() { let mut app = App::new(); diff --git a/src/cli.rs b/src/cli.rs index 29c4311..57f9a23 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -42,6 +42,13 @@ pub struct Args { /// mode > the default (`simple`). Combines with `--resume` and /// a positional path; on collision the flag wins. pub mode: Option, + /// `--demo` (or `RDBMS_PLAYGROUND_DEMO` set truthy): enter + /// **demonstration mode** (ADR-0047, issue #22). Off by default, + /// zero footprint when off. When on, the app shows transient + /// on-screen badges for otherwise-invisible keys (Tab, Enter, …) + /// and enables the `Ctrl+]` stealth step-caption buffer — for + /// screencasts and live teaching. The flag wins over the env var. + pub demo: bool, } /// Usage banner printed by `--help`. @@ -124,6 +131,12 @@ impl Args { let mut help = false; let mut no_undo = false; let mut mode: Option = None; + // Demonstration mode (ADR-0047): the env var is the default, + // the `--demo` flag overrides it to on. Mirrors the + // env-then-flag layering used for the log file above. + let mut demo = env::var("RDBMS_PLAYGROUND_DEMO") + .ok() + .is_some_and(|v| demo_value_is_truthy(&v)); let mut iter = iter.into_iter().map(Into::into); while let Some(arg) = iter.next() { match arg.as_str() { @@ -136,6 +149,9 @@ impl Args { "--no-undo" => { no_undo = true; } + "--demo" => { + demo = true; + } "--theme" => { let value = iter.next().ok_or(ArgsError::MissingValue("theme"))?; theme = match value.as_str() { @@ -194,10 +210,25 @@ impl Args { help, no_undo, mode, + demo, }) } } +/// Whether a `RDBMS_PLAYGROUND_DEMO` value enables demo mode. +/// +/// Truthy for any value except the conventional "off" set +/// (`0`/`false`/`no`/`off`, case-insensitively, and the empty +/// string). So `RDBMS_PLAYGROUND_DEMO=1` and `=true` enable, while +/// `=0` / `=false` explicitly disable — letting a value of `0` turn +/// it off even if something upstream exported the variable. +fn demo_value_is_truthy(value: &str) -> bool { + !matches!( + value.trim().to_ascii_lowercase().as_str(), + "" | "0" | "false" | "no" | "off" + ) +} + fn default_theme() -> Theme { // NFR-7: support both backgrounds. For the walking skeleton we // honour an explicit `--theme` flag and the COLORFGBG env var @@ -391,6 +422,52 @@ mod tests { ); } + // ---- ADR-0047 (issue #22): --demo demonstration mode ---- + + #[test] + fn demo_flag_parses() { + let args = Args::parse(["--demo"]).unwrap(); + assert!(args.demo); + } + + #[test] + fn demo_defaults_off() { + // Absent `--demo` (and absent env var in the test runner), + // demo mode is off — zero footprint for real users. + let args = Args::parse(std::iter::empty::<&str>()).unwrap(); + assert!(!args.demo, "demo is off unless --demo or the env var is given"); + } + + #[test] + fn demo_flag_coexists_with_positional_path() { + let args = Args::parse(["--demo", "/home/me/MyProject"]).unwrap(); + assert!(args.demo); + assert_eq!( + args.project_path.as_deref(), + Some(std::path::Path::new("/home/me/MyProject")) + ); + } + + #[test] + fn demo_flag_combines_with_resume_and_mode() { + let args = Args::parse(["--resume", "--demo", "--mode", "advanced"]).unwrap(); + assert!(args.demo); + assert!(args.resume); + assert_eq!(args.mode, Some(Mode::Advanced)); + } + + #[test] + fn demo_env_value_truthiness() { + // Enabling values. + for v in ["1", "true", "TRUE", "yes", "on", "anything", " 1 "] { + assert!(demo_value_is_truthy(v), "{v:?} should enable demo mode"); + } + // Disabling values. + for v in ["", " ", "0", "false", "False", "no", "off", "OFF"] { + assert!(!demo_value_is_truthy(v), "{v:?} should not enable demo mode"); + } + } + #[test] fn unknown_double_dash_flag_errors_even_with_positional() { // Make sure the path-vs-flag distinction is robust: diff --git a/src/completion.rs b/src/completion.rs index ef74daa..5ca535a 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -31,6 +31,7 @@ use crate::mode::Mode; /// fragments the user thinks of as a single phrase: /// /// - `1:n` — the opener for `add 1:n relationship`. +/// - `m:n` — the opener for `create m:n relationship` (ADR-0045). /// - `double precision` — the lone two-word SQL type alias /// (ADR-0035 §6.3; the grammar has a dedicated branch so the per-word /// `Ident` validator never has to make sense of `double` alone). @@ -40,7 +41,7 @@ use crate::mode::Mode; /// composite replaces the bare opener rather than appearing /// alongside it. const COMPOSITE_CANDIDATES: &[(&str, &str)] = - &[("1", "1:n"), ("double", "double precision")]; + &[("1", "1:n"), ("m", "m:n"), ("double", "double precision")]; /// Per-project schema lookup cache (ADR-0022 §9, ADR-0024 §Phase D). /// @@ -1346,12 +1347,19 @@ mod tests { fn at_token_boundary_offers_next_expected_keyword() { // After `create ` advanced mode offers `table` (valid in both // modes) plus the SQL-only `unique` (`create unique index`) and - // `index` — the shared-entry-word merge (ADR-0035 §4i d). + // `index` — the shared-entry-word merge (ADR-0035 §4i d) — and + // `m:n` (`create m:n relationship`, ADR-0045), surfaced as the + // composite (the bare `m` opener is filtered). // `table` (Both) blocks before the Advanced-only `unique`/`index`. let cs = cands("create ", 7); assert_eq!( cs, - vec!["table".to_string(), "unique".to_string(), "index".to_string()] + vec![ + "table".to_string(), + "unique".to_string(), + "index".to_string(), + "m:n".to_string() + ] ); } diff --git a/src/db.rs b/src/db.rs index cc77bfd..562b8d8 100644 --- a/src/db.rs +++ b/src/db.rs @@ -605,6 +605,13 @@ enum Request { source: Option, reply: oneshot::Sender>, }, + CreateM2nRelationship { + t1: String, + t2: String, + name: Option, + source: Option, + reply: oneshot::Sender>, + }, DropRelationship { selector: RelationshipSelector, source: Option, @@ -830,6 +837,13 @@ enum Request { source: crate::dsl::grammar::IdentSource, reply: oneshot::Sender, DbError>>, }, + /// All relationships as full schema records (name, parent/child + /// tables + columns, referential actions). Feeds the sidebar + /// relationships panel (ADR-0046 DB2); the walker only needs the + /// names, which `ListNamesFor` already provides. + ReadAllRelationships { + reply: oneshot::Sender, DbError>>, + }, /// Restore the most recent undo snapshot (ADR-0006 Amendment 1). /// Replies with the metadata of the command that was undone, or /// `None` if there is nothing to undo (or undo is disabled). @@ -1420,6 +1434,29 @@ impl Database { recv.await.map_err(|_| DbError::WorkerGone)? } + /// Generate a junction table for an m:n relationship between + /// `t1` and `t2` (ADR-0045 / C4). One worker request = one undo + /// step (the junction + both relationships are built in a single + /// `do_create_table`). + pub async fn create_m2n_relationship( + &self, + t1: String, + t2: String, + name: Option, + source: Option, + ) -> Result { + let (reply, recv) = oneshot::channel(); + self.send(Request::CreateM2nRelationship { + t1, + t2, + name, + source, + reply, + }) + .await?; + recv.await.map_err(|_| DbError::WorkerGone)? + } + pub async fn drop_relationship( &self, selector: RelationshipSelector, @@ -1757,6 +1794,14 @@ impl Database { recv.await.map_err(|_| DbError::WorkerGone)? } + /// All relationships as full schema records, for the sidebar + /// relationships panel (ADR-0046 DB2). + pub async fn read_all_relationships(&self) -> Result, DbError> { + let (reply, recv) = oneshot::channel(); + self.send(Request::ReadAllRelationships { reply }).await?; + recv.await.map_err(|_| DbError::WorkerGone)? + } + /// Restore the most recent undo snapshot (ADR-0006 Amendment 1). /// `Ok(Some(meta))` reports the command that was undone; /// `Ok(None)` means nothing to undo (or undo is disabled). @@ -1921,7 +1966,7 @@ fn worker_loop( snapshots: Option, mut rx: mpsc::Receiver, ) { - debug!("db worker started"); + info!("db worker started"); // `conn` must be mutable: restoring a snapshot (undo/redo) writes // into the live connection via the backup API (`&mut`). let mut conn = conn; @@ -1968,7 +2013,7 @@ fn worker_loop( other => handle_request(&conn, persistence.as_ref(), snap, &mut batch, other), } } - debug!("db worker exiting"); + info!("db worker exiting"); } /// Worker-side undo bracketing state for the request stream. @@ -2347,6 +2392,24 @@ fn handle_request( create_fk, )); } + Request::CreateM2nRelationship { + t1, + t2, + name, + source, + reply, + } => { + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_create_m2n_relationship( + conn, + persistence, + source.as_deref(), + &t1, + &t2, + name.as_deref(), + ) + }); + } Request::DropRelationship { selector, source, @@ -2726,6 +2789,9 @@ fn handle_request( let result = do_list_names_for(conn, source); let _ = reply.send(result); } + Request::ReadAllRelationships { reply } => { + let _ = reply.send(read_all_relationships(conn)); + } // Undo/redo/peek/batch are intercepted in `worker_loop` (they // need `&mut conn` or persistent batch state) and never reach // here. Listed explicitly so a new variant still forces a @@ -3393,6 +3459,15 @@ fn do_create_table( check_constraints: &[String], foreign_keys: &[SqlForeignKey], ) -> Result { + debug!(table = %name, cols = columns.len(), pk = ?primary_key, "create_table"); + // A new table may not take an internal `__rdbms_*` name (it would be + // filtered out of `list_tables` — a hidden orphan). The advanced-SQL + // create path rejects this at parse, but the simple-mode DSL + // `TABLE_NAME_NEW` slot has no validator, and `create m:n … as + // ` (ADR-0045) reaches here too — so the shared executor is the + // single place that closes every path (issue raised by the ADR-0045 + // /runda pass). + reject_internal_table_name(name)?; if columns.is_empty() { // SQLite requires at least one column. The DSL grammar // already prevents this, but defending here too keeps @@ -3407,6 +3482,9 @@ fn do_create_table( // §5, sub-phase 4b). Self-references validate against the columns // being defined; other parents must already exist. let resolved_fks = resolve_create_table_fks(conn, name, columns, primary_key, foreign_keys)?; + if !resolved_fks.is_empty() { + debug!(table = %name, fks = resolved_fks.len(), "create_table: foreign keys resolved + validated"); + } // Inline `PRIMARY KEY` on the column when the table has a single // primary-key column and it is the **first** column — the exact @@ -3568,6 +3646,7 @@ fn do_drop_table( source: Option<&str>, name: &str, ) -> Result<(), DbError> { + debug!(table = %name, "drop_table"); // Canonicalize the user-typed name to its stored case (and refuse a // non-existent / internal table), so the metadata DELETEs and the CSV // removal target the right name regardless of capitalization. @@ -3647,6 +3726,7 @@ fn do_add_column( table: &str, column: &ColumnSpec, ) -> Result { + debug!(table = %table, column = %column.name, "add_column"); let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); if matches!(column.ty, Type::Serial | Type::ShortId) { @@ -3700,6 +3780,7 @@ fn do_add_plain_column( table: &str, spec: &ColumnSpec, ) -> Result { + debug!(table = %table, column = %spec.name, "add_plain_column"); // The plain `ALTER TABLE ADD COLUMN` path. `do_add_column` // only routes here when the constraints are ALTER-expressible // (no UNIQUE; NOT NULL only alongside a default), so the @@ -3752,6 +3833,7 @@ fn do_add_auto_generated_column( table: &str, spec: &ColumnSpec, ) -> Result { + debug!(table = %table, column = %spec.name, "add_auto_generated_column"); use rusqlite::types::Value as RV; let ty = spec.ty; @@ -3883,6 +3965,7 @@ fn do_add_constrained_column_via_rebuild( table: &str, spec: &ColumnSpec, ) -> Result { + debug!(table = %table, column = %spec.name, "add_constrained_column_via_rebuild"); let old_schema = read_schema(conn, table)?; if old_schema.columns.iter().any(|c| c.name == spec.name) { return Err(DbError::Unsupported(format!( @@ -3984,6 +4067,7 @@ fn do_add_constraint( column: &str, constraint: &Constraint, ) -> Result { + debug!(table = %table, column = %column, "add_constraint"); // Canonicalize to the stored case (and refuse a non-existent / // internal `__rdbms_*` table as "no such table"), like the sibling // schema-mutation executors. Closes the simple `add constraint` @@ -4126,6 +4210,7 @@ fn do_drop_constraint( column: &str, kind: ConstraintKind, ) -> Result { + debug!(table = %table, column = %column, "drop_constraint"); let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let old_schema = read_schema(conn, table)?; @@ -4228,6 +4313,7 @@ fn do_set_column_default( column: &str, default_sql: &str, ) -> Result { + debug!(table = %table, column = %column, "set_column_default"); let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let old_schema = read_schema(conn, table)?; @@ -4617,6 +4703,7 @@ fn do_drop_column( column: &str, cascade: bool, ) -> Result { + debug!(table = %table, column = %column, cascade, "drop_column"); let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let schema = read_schema(conn, table)?; @@ -4776,6 +4863,7 @@ fn do_rename_column( old: &str, new: &str, ) -> Result { + debug!(table = %table, old = %old, new = %new, "rename_column"); let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let schema = read_schema(conn, table)?; @@ -4898,6 +4986,7 @@ fn do_rename_table( old: &str, new: &str, ) -> Result { + debug!(old = %old, new = %new, "rename_table"); reject_internal_table_name(new)?; // Canonicalize the source to its stored case (and refuse a // non-existent / internal source as "no such table") — so a @@ -5086,6 +5175,7 @@ fn do_change_column_type( ty: Type, mode: ChangeColumnMode, ) -> Result { + debug!(table = %table, column = %column, ty = %ty, mode = ?mode, "change_column_type"); // Canonicalize to the stored case (and refuse a non-existent / // internal `__rdbms_*` table as "no such table"), like the sibling // column executors. Closes the simple `change column` exposure and @@ -5888,6 +5978,7 @@ fn more_row(width: usize, more: usize) -> Vec { } fn do_list_tables(conn: &Connection) -> Result, DbError> { + debug!("list_tables"); let mut stmt = conn .prepare( "SELECT name FROM sqlite_schema \ @@ -5915,6 +6006,7 @@ fn do_show_relationship( conn: &Connection, name: &str, ) -> Result, DbError> { + debug!(name = %name, "show_relationship"); let Some(rel) = read_all_relationships(conn)? .into_iter() .find(|r| r.name == name) @@ -5937,6 +6029,7 @@ fn do_show_list( kind: crate::dsl::command::ShowListKind, name: Option<&str>, ) -> Result, DbError> { + debug!(kind = ?kind, name = ?name, "show_list"); use crate::dsl::command::ShowListKind; // V5a: a named item shows one relationship/index's detail. if let Some(name) = name { @@ -6024,6 +6117,7 @@ fn do_show_one( kind: crate::dsl::command::ShowListKind, name: &str, ) -> Result, DbError> { + debug!(kind = ?kind, name = %name, "show_one"); use crate::dsl::command::ShowListKind; let mut lines = Vec::new(); match kind { @@ -6802,6 +6896,7 @@ where C: FnOnce(&rusqlite::Transaction<'_>, &str, &str) -> Result<(), DbError>, M: FnOnce(&rusqlite::Transaction<'_>) -> Result<(), DbError>, { + debug!(table = %table, cols = new_schema.columns.len(), "rebuild_table: begin (foreign_keys OFF, temp-copy primitive)"); // foreign_keys=OFF must be set *outside* a transaction. conn.execute_batch("PRAGMA foreign_keys = OFF;") .map_err(DbError::from_rusqlite)?; @@ -6870,6 +6965,7 @@ where .map_err(DbError::from_rusqlite)?; let mut rows = check.query([]).map_err(DbError::from_rusqlite)?; if let Some(_row) = rows.next().map_err(DbError::from_rusqlite)? { + warn!(table = %table, "rebuild_table: foreign_key_check failed; existing data violates new constraint, rolling back"); return Err(DbError::Sqlite { message: format!( "foreign-key check failed after rebuild of `{table}`; \ @@ -6882,6 +6978,7 @@ where drop(check); tx.commit().map_err(DbError::from_rusqlite)?; + debug!(table = %table, indexes = captured_indexes.len(), "rebuild_table: committed (indexes recreated)"); Ok(()) })(); @@ -6889,6 +6986,9 @@ where let pragma_result = conn .execute_batch("PRAGMA foreign_keys = ON;") .map_err(DbError::from_rusqlite); + if let Err(e) = &pragma_result { + warn!(table = %table, error = %e, "rebuild_table: failed to re-enable foreign_keys after rebuild"); + } result.and(pragma_result) } @@ -7084,6 +7184,7 @@ fn resolve_fk_parent_columns( parent_pk: &[String], explicit: Option<&[String]>, child_arity: usize, + inline: bool, ) -> Result, DbError> { if child_arity == 0 { return Err(DbError::Unsupported( @@ -7116,6 +7217,20 @@ fn resolve_fk_parent_columns( } }; if parent_columns.len() != child_arity { + // An inline column-level FK (`

REFERENCES …`) can only carry + // the one column it sits on, so it can never satisfy a compound + // key — point the user at the table-level form rather than the + // generic arity message (ADR-0043 D4). + if inline && parent_columns.len() > 1 { + return Err(DbError::Unsupported(format!( + "an inline column reference can only name one column, but \ + `{parent_table}`'s key has {n}. Use the table-level form \ + instead: `FOREIGN KEY () REFERENCES \ + {parent_table} ({pk})`.", + n = parent_columns.len(), + pk = parent_columns.join(", "), + ))); + } return Err(DbError::Unsupported(format!( "{child_arity} foreign-key column(s) on the child side, but \ `{parent_table}`'s key has {n}. A foreign key references every \ @@ -7184,6 +7299,7 @@ fn resolve_create_table_fks( &parent_pk, fk.parent_columns.as_deref(), fk.child_columns.len(), + fk.inline, )?; // Each child column must be one of the columns being defined, @@ -7235,6 +7351,101 @@ fn resolve_create_table_fks( Ok(out) } +/// Generate a junction table for an m:n relationship between `t1` and +/// `t2` (ADR-0045 / C4). Builds one FK column per parent PK column +/// (`{table}_{pkcol}`, typed via `fk_target_type` — ADR-0011), a +/// compound PK over all of them, and two `CASCADE` foreign keys, then +/// hands the whole thing to [`do_create_table`] — so the junction table +/// and both relationships are created in one transaction = one undo +/// step. Self-referential m:n is refused (column-name collision); a +/// PK-less parent is refused (nothing to reference). +fn do_create_m2n_relationship( + conn: &Connection, + persistence: Option<&Persistence>, + source: Option<&str>, + t1: &str, + t2: &str, + name: Option<&str>, +) -> Result { + debug!(t1 = %t1, t2 = %t2, name = ?name, "create_m2n_relationship"); + // Canonicalize both parents (refuse non-existent / internal tables). + let canon_t1 = require_canonical_table(conn, t1)?; + let t1 = canon_t1.as_str(); + let canon_t2 = require_canonical_table(conn, t2)?; + let t2 = canon_t2.as_str(); + + // Self-referential m:n is OOS (ADR-0045): the two FK column sets + // would collide on `{T}_{pkcol}`, needing directional names this + // beginner convenience deliberately avoids. + if t1.eq_ignore_ascii_case(t2) { + return Err(DbError::Unsupported(format!( + "an m:n relationship needs two different tables (got `{t1}` twice). \ + To link a table to itself, build the junction table by hand." + ))); + } + + let schema1 = read_schema(conn, t1)?; + let schema2 = read_schema(conn, t2)?; + + // Build one FK column per parent PK column (compound parents + // contribute one each, ADR-0043) + the compound PK + the two FKs. + let mut columns: Vec = Vec::new(); + let mut primary_key: Vec = Vec::new(); + let mut foreign_keys: Vec = Vec::new(); + for (tbl, schema) in [(t1, &schema1), (t2, &schema2)] { + // D7 parent-PK guard: advanced-mode SQL can create a PK-less + // table; it cannot anchor an m:n relationship. + if schema.primary_key.is_empty() { + return Err(DbError::Unsupported(format!( + "`{tbl}` has no primary key, so it cannot anchor an m:n relationship." + ))); + } + let mut child_columns: Vec = Vec::new(); + for pkcol in &schema.primary_key { + let pcol = schema + .columns + .iter() + .find(|c| &c.name == pkcol) + .ok_or_else(|| DbError::Sqlite { + message: format!("no such column: {tbl}.{pkcol}"), + kind: SqliteErrorKind::NoSuchColumn, + })?; + let pty = pcol.user_type.ok_or_else(|| { + DbError::Unsupported("primary-key column has no user type metadata".to_string()) + })?; + let col_name = format!("{tbl}_{pkcol}"); + columns.push(ColumnSpec::new(col_name.clone(), pty.fk_target_type())); + primary_key.push(col_name.clone()); + child_columns.push(col_name); + } + foreign_keys.push(SqlForeignKey { + name: None, + child_columns, + parent_table: tbl.to_string(), + parent_columns: Some(schema.primary_key.clone()), + on_delete: ReferentialAction::Cascade, + on_update: ReferentialAction::Cascade, + inline: false, + }); + } + + // Junction name: explicit `as ` or the auto-name `{t1}_{t2}`. + let junction = name.map_or_else(|| format!("{t1}_{t2}"), str::to_string); + debug!(junction = %junction, cols = columns.len(), "create_m2n_relationship: building junction table"); + + do_create_table( + conn, + persistence, + source, + &junction, + &columns, + &primary_key, + &[], + &[], + &foreign_keys, + ) +} + #[allow(clippy::too_many_arguments)] fn do_add_relationship( conn: &Connection, @@ -7249,6 +7460,7 @@ fn do_add_relationship( on_update: ReferentialAction, create_fk: bool, ) -> Result { + debug!(name = ?name, parent = %parent_table, child = %child_table, "add_relationship"); // Canonicalize both endpoints to their stored case (and refuse a // non-existent / internal `__rdbms_*` table as "no such table"), like // the sibling schema-mutation executors — so the relationship metadata @@ -7268,6 +7480,7 @@ fn do_add_relationship( &parent_schema.primary_key, Some(parent_columns), child_columns.len(), + false, // DSL `add relationship` is never an inline column FK )?; // 2. Read child schema; refuse missing columns unless --create-fk. @@ -7409,6 +7622,7 @@ fn do_drop_relationship( source: Option<&str>, selector: &RelationshipSelector, ) -> Result, DbError> { + debug!(selector = ?selector, "drop_relationship"); // Resolve to a single relationship row. let resolved: Option<(String, String, String, String, String)> = match selector { RelationshipSelector::Named { name } => conn @@ -7488,6 +7702,7 @@ fn do_alter_add_table_check( name: Option<&str>, expr_sql: &str, ) -> Result { + debug!(table = %table, name = ?name, "alter_add_table_check"); let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let old_schema = read_schema(conn, table)?; @@ -7593,6 +7808,7 @@ fn do_alter_add_unique( table: &str, columns: &[String], ) -> Result { + debug!(table = %table, cols = ?columns, "alter_add_unique"); let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let old_schema = read_schema(conn, table)?; @@ -7660,6 +7876,7 @@ fn do_drop_constraint_by_name( table: &str, name: &str, ) -> Result, DbError> { + debug!(table = %table, name = %name, "drop_constraint_by_name"); let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); @@ -7781,6 +7998,7 @@ fn do_alter_add_foreign_key( name: Option<&str>, fk: &SqlForeignKey, ) -> Result { + debug!(child = %child_table, name = ?name, "alter_add_foreign_key"); reject_internal_table_name(child_table)?; reject_internal_table_name(&fk.parent_table)?; // Resolve the parent columns: explicit must be the full PK (F-A); @@ -7792,6 +8010,7 @@ fn do_alter_add_foreign_key( &parent_pk, fk.parent_columns.as_deref(), fk.child_columns.len(), + fk.inline, // false for `ALTER … ADD FOREIGN KEY` (table-level) )?; // Every child column must already exist for `ALTER … ADD FOREIGN // KEY` — there is no SQL spelling to auto-create one (`--create-fk` @@ -7891,6 +8110,7 @@ fn do_add_index( columns: &[String], unique: bool, ) -> Result { + debug!(name = ?name, table = %table, cols = ?columns, unique, "add_index"); // 0. Canonicalize to the stored case (and refuse a non-existent / // internal `__rdbms_*` table) — both the simple `add index` and SQL // `CREATE INDEX` surfaces reach here, and the auto-index name embeds @@ -7979,6 +8199,7 @@ fn do_drop_index( source: Option<&str>, selector: &IndexSelector, ) -> Result { + debug!(selector = ?selector, "drop_index"); let (index_name, table_name) = match selector { IndexSelector::Named { name } => { let lookup = conn.query_row( @@ -8067,6 +8288,7 @@ fn do_describe_table_request( } fn do_describe_table(conn: &Connection, name: &str) -> Result { + debug!(name = %name, "describe_table"); // Column info — including the ADR-0029 constraints — comes // from `read_schema`, the single source of per-column truth // (it joins `pragma_table_info` with our type metadata and @@ -8422,6 +8644,7 @@ fn do_insert( user_columns: Option<&[String]>, user_values: &[Value], ) -> Result { + debug!(table = %table, "insert"); let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let schema = read_schema(conn, table)?; @@ -8500,6 +8723,14 @@ fn do_insert( )); } + debug!( + table = %table, + user_cols = user_cols.len(), + total_cols = bindings.len(), + autofilled = bindings.len() - user_cols.len(), + "insert: column bindings resolved (serial/shortid auto-fill applied)" + ); + let cols_csv = bindings .iter() .map(|(c, _)| quote_ident(c)) @@ -8579,6 +8810,7 @@ fn do_update( assignments: &[(String, Value)], filter: &RowFilter, ) -> Result { + debug!(table = %table, assignments = assignments.len(), "update"); if assignments.is_empty() { return Err(DbError::InvalidValue( "UPDATE requires at least one assignment".to_string(), @@ -8678,6 +8910,7 @@ fn do_delete( table: &str, filter: &RowFilter, ) -> Result { + debug!(table = %table, "delete"); let canonical_table = require_canonical_table(conn, table)?; let table = canonical_table.as_str(); let schema = read_schema(conn, table)?; @@ -8732,6 +8965,14 @@ fn do_delete( } } + debug!( + table = %table, + rows_affected, + cascaded_relationships = cascade.len(), + rewritten_tables = rewritten_tables.len(), + "delete: complete (cascade effects detected by child-count diff)" + ); + let changes = Changes { schema_dirty: false, rewritten_tables, @@ -9045,6 +9286,7 @@ fn do_sql_insert( returning: bool, literal_rows: &[Vec>], ) -> Result { + debug!(table = %target_table, returning, "sql_insert"); debug!(sql = %sql, table = %target_table, returning, "sql_insert"); let canonical_table = require_canonical_table(conn, target_table)?; let target_table = canonical_table.as_str(); @@ -9161,6 +9403,7 @@ fn do_sql_update( returning: bool, set_literals: &[(String, Option)], ) -> Result { + debug!(table = %target_table, returning, "sql_update"); debug!(sql = %sql, table = %target_table, returning, "sql_update"); let canonical_table = require_canonical_table(conn, target_table)?; let target_table = canonical_table.as_str(); @@ -9544,6 +9787,7 @@ fn do_query_data( filter: Option<&Expr>, limit: Option, ) -> Result { + debug!(table = %table, limit = ?limit, "query_data"); let schema = read_schema(conn, table)?; let column_names: Vec = schema.columns.iter().map(|c| c.name.clone()).collect(); let column_types: Vec> = @@ -9602,6 +9846,7 @@ fn format_cell(value: rusqlite::types::Value, ty: Option) -> Option Result { + debug!("explain_plan"); let (exec_sql, params) = match query { Command::ShowData { name, @@ -9855,6 +10100,7 @@ fn do_rebuild_from_text( source: Option<&str>, 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); @@ -10320,6 +10566,26 @@ mod tests { assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}"); } + #[tokio::test] + async fn create_table_rejects_an_internal_name() { + // A new table may not take an internal `__rdbms_*` name — it would + // be hidden from `list_tables`. The advanced-SQL path rejects this + // at parse; the shared executor guards every other path (the + // simple-mode DSL slot and `create m:n … as`, ADR-0045). + let db = db(); + let err = db + .create_table( + "__rdbms_sneaky".to_string(), + vec![col("id", Type::Int)], + vec!["id".to_string()], + None, + ) + .await + .unwrap_err(); + assert!(matches!(err, DbError::Sqlite { kind: SqliteErrorKind::NoSuchTable, .. }), "got {err:?}"); + assert!(db.list_tables().await.unwrap().is_empty()); + } + #[tokio::test] async fn drop_table_removes_it_from_list() { let db = db(); diff --git a/src/dsl/command.rs b/src/dsl/command.rs index a5b6b07..68046e4 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -45,6 +45,13 @@ pub struct SqlForeignKey { pub parent_columns: Option>, pub on_delete: ReferentialAction, pub on_update: ReferentialAction, + /// `true` for an inline column-level FK (`REFERENCES …`), + /// `false` for the table-level `FOREIGN KEY (…)` and `ALTER …` + /// forms. An inline FK is single-column by construction, so when + /// it references a compound key the resolver points the user at + /// the table-level form rather than emitting the generic arity + /// error (ADR-0043 D4). + pub inline: bool, } /// A column at table-creation time: a name, a user-facing @@ -270,6 +277,18 @@ pub enum Command { on_update: ReferentialAction, create_fk: bool, }, + /// Convenience: generate a junction table for a many-to-many + /// relationship between `t1` and `t2` (ADR-0045 / C4). The + /// executor builds a table with one FK column per parent PK + /// column (named `{table}_{pkcol}`, typed via `fk_target_type`), + /// a compound PK over all of them, and two `CASCADE` 1:n + /// relationships — all in one `create table` (one undo step). + /// `name` overrides the auto-generated junction name `{t1}_{t2}`. + CreateM2nRelationship { + t1: String, + t2: String, + name: Option, + }, /// Drop a relationship by either user-given/auto-generated /// name, or by positional reference to the FK endpoints. DropRelationship { @@ -908,6 +927,7 @@ impl Command { Self::RenameColumn { .. } => "rename column", Self::ChangeColumnType { .. } => "change column", Self::AddRelationship { .. } => "add relationship", + Self::CreateM2nRelationship { .. } => "create m:n relationship", Self::DropRelationship { .. } => "drop relationship", Self::AddIndex { .. } => "add index", Self::DropIndex { .. } => "drop index", @@ -984,6 +1004,9 @@ impl Command { // table's "Referenced by" entry, which is what the // user looks at to confirm the relationship. Self::AddRelationship { parent_table, .. } => parent_table, + // For m:n we focus on the first table; the executor builds + // and returns the junction's structure regardless. + Self::CreateM2nRelationship { t1, .. } => t1, Self::DropRelationship { selector } => match selector { RelationshipSelector::Endpoints { parent_table, .. } => parent_table, // For a named drop we don't know the parent table diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index 35b15b5..0167093 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -1362,6 +1362,75 @@ pub static CREATE: CommandNode = CommandNode { help_id: Some("ddl.create"), usage_ids: &["parse.usage.create_table"],}; +// ================================================================= +// create_m2n — `create m:n relationship from to [as ]` +// (ADR-0045 / C4). Generates an auto-named junction table with two FKs +// + two 1:n relationships. A *separate* `CommandNode` under the shared +// `create` entry word (the walker dispatches both); the `m` opener is a +// `Literal` (not a keyword) so it never shadows an identifier, mirroring +// the `1` in `add 1:n relationship`. +// ================================================================= + +const M2N_T1: Node = Node::Ident { + source: IdentSource::Tables, + role: "m2n_t1", + validator: None, + highlight_override: None, + writes_table: false, + writes_column: false, + writes_user_listed_column: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, +}; +const M2N_T2: Node = Node::Ident { + source: IdentSource::Tables, + role: "m2n_t2", + validator: None, + highlight_override: None, + writes_table: false, + writes_column: false, + writes_user_listed_column: false, + writes_table_alias: false, + writes_cte_name: false, + writes_projection_alias: false, +}; +// Optional `as ` — a *new* table name (the junction), +// so it reuses `TABLE_NAME_NEW` (role `table_name`, `NewName` source + +// hint). The only `table_name` role in this path, so the builder reads +// it directly as the junction name. +const M2N_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), TABLE_NAME_NEW]; +const M2N_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(M2N_AS_NAME_NODES)); + +const CREATE_M2N_NODES: &[Node] = &[ + Node::Literal("m"), + Node::Punct(':'), + Node::Word(Word::keyword("n")), + Node::Word(Word::keyword("relationship")), + Node::Word(Word::keyword("from")), + M2N_T1, + Node::Word(Word::keyword("to")), + M2N_T2, + M2N_AS_NAME_OPT, +]; +const CREATE_M2N_SHAPE: Node = Node::Seq(CREATE_M2N_NODES); + +fn build_create_m2n(path: &MatchedPath, _source: &str) -> Result { + Ok(Command::CreateM2nRelationship { + t1: require_ident(path, "m2n_t1")?, + t2: require_ident(path, "m2n_t2")?, + name: ident(path, "table_name").map(str::to_string), + }) +} + +pub static CREATE_M2N: CommandNode = CommandNode { + entry: Word::keyword("create"), + shape: CREATE_M2N_SHAPE, + ast_builder: build_create_m2n, + help_id: Some("ddl.create_m2n"), + usage_ids: &["parse.usage.create_m2n"], +}; + /// The friendly error for a column type without a preceding name — /// a structural impossibility given the grammar, defended anyway. fn sql_col_type_without_name() -> ValidationError { @@ -1557,7 +1626,7 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result] foreign key () // references [()] [on …]` (ADR-0035 §5, 4b). @@ -1587,7 +1656,8 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result( items: &mut std::iter::Peekable, name: Option, child_columns: Vec, + inline: bool, ) -> SqlForeignKey where I: Iterator, @@ -1752,6 +1823,7 @@ where parent_columns, on_delete, on_update, + inline, } } @@ -2454,7 +2526,8 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey { if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) { items.next(); } - consume_fk_reference(&mut items, None, child_columns) + // `ALTER TABLE … ADD FOREIGN KEY (…)` is the table-level form. + consume_fk_reference(&mut items, None, child_columns, false) } pub static SQL_ALTER_TABLE: CommandNode = CommandNode { diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index d68fc3b..30a5b3b 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -657,6 +657,12 @@ pub fn usage_key_for_input_in_mode( if source.as_bytes().get(after).is_some_and(u8::is_ascii_digit) { 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`). + 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. @@ -706,6 +712,7 @@ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[ (&ddl::RENAME, CommandCategory::Simple), (&ddl::CHANGE, CommandCategory::Simple), (&ddl::CREATE, CommandCategory::Simple), + (&ddl::CREATE_M2N, CommandCategory::Simple), (&data::SHOW, CommandCategory::Simple), (&data::INSERT, CommandCategory::Simple), (&data::UPDATE, CommandCategory::Simple), @@ -852,6 +859,13 @@ mod usage_key_tests { ), ("show data T", "parse.usage.show_data"), ("show table T", "parse.usage.show_table"), + // `create` is multi-form (table vs m:n, ADR-0045): each typed + // form resolves to its own usage key. + ("create table T with pk id(int)", "parse.usage.create_table"), + ( + "create m:n relationship from A to B", + "parse.usage.create_m2n", + ), ]; for (input, expected) in cases { assert_eq!( diff --git a/src/dsl/grammar/sql_create_table.rs b/src/dsl/grammar/sql_create_table.rs index 1a09883..49415c3 100644 --- a/src/dsl/grammar/sql_create_table.rs +++ b/src/dsl/grammar/sql_create_table.rs @@ -1004,6 +1004,16 @@ mod builder_tests { assert_eq!(fk.parent_columns, Some(vec!["id".to_string()])); assert_eq!(fk.on_delete, ReferentialAction::NoAction); assert_eq!(fk.on_update, ReferentialAction::NoAction); + assert!(fk.inline, "a column-level `references` is an inline FK (ADR-0043 D4)"); + } + + #[test] + fn table_level_fk_is_not_inline() { + // The table-level `FOREIGN KEY (...)` form is not inline, so it can + // carry a multi-column reference and never triggers the inline + // "use the table-level form" hint (ADR-0043 D4). + let fks = parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))"); + assert!(!fks[0].inline, "table-level FOREIGN KEY is not inline"); } #[test] diff --git a/src/dsl/parser.rs b/src/dsl/parser.rs index 2a1e650..9e260c8 100644 --- a/src/dsl/parser.rs +++ b/src/dsl/parser.rs @@ -12,6 +12,8 @@ //! synthetic "unknown command" error when the input's first //! identifier-shape token isn't a registered entry word. +use tracing::trace; + use crate::dsl::command::Command; use crate::mode::Mode; @@ -150,13 +152,27 @@ fn parse_command_inner( schema: Option<&crate::completion::SchemaCache>, mode: Mode, ) -> Result { + // `trace`, not `debug`: parsing is a hot path — the live overlay / + // completion (completion.rs) re-parse per keystroke, probing + // candidates in a loop, so a per-parse `debug` line would flood. The + // executed-command story lives at `debug` in db.rs (one per submit). + trace!( + len = input.len(), + mode = ?mode, + schema_aware = schema.is_some(), + "parse: begin" + ); if input.trim().is_empty() { + trace!("parse: empty input"); return Err(ParseError::Empty); } - if let Some(result) = try_walker_route(input, schema, mode) { - return result; + let result = + try_walker_route(input, schema, mode).unwrap_or_else(|| Err(unknown_command_error(input))); + match &result { + Ok(cmd) => trace!(command = cmd.verb(), "parse: ok"), + Err(e) => trace!(error = %e, "parse: rejected"), } - Err(unknown_command_error(input)) + result } /// Synthetic ParseError for inputs whose first identifier-shape diff --git a/src/dsl/walker/highlight.rs b/src/dsl/walker/highlight.rs index e5a4b9a..f2bd732 100644 --- a/src/dsl/walker/highlight.rs +++ b/src/dsl/walker/highlight.rs @@ -211,6 +211,21 @@ mod tests { assert_eq!(run("quit"), vec![(0, 4, HighlightClass::Keyword)]); } + #[test] + fn create_m2n_relationship_highlights_cleanly() { + // ADR-0045: a valid `create m:n relationship` line classifies + // with no Error runs; keywords are keywords and the table names + // are identifiers (the `m:n` opener is a Literal, keyword-classed). + let runs = run("create m:n relationship from A to B"); + assert!( + !runs.iter().any(|(_, _, c)| *c == HighlightClass::Error), + "no Error highlight on a valid m:n line: {runs:?}" + ); + let kinds: Vec = runs.iter().map(|(_, _, c)| *c).collect(); + assert!(kinds.contains(&HighlightClass::Keyword), "keywords highlighted: {runs:?}"); + assert!(kinds.contains(&HighlightClass::Identifier), "table names highlighted: {runs:?}"); + } + #[test] fn keyword_plus_identifier_via_walker() { // `show data Customers` walks end-to-end. diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index d22e750..d3cf55b 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -406,13 +406,28 @@ pub fn completion_probe_in_mode( // Mismatch and is naturally skipped — the viability check is the // gate, not the cursor depth. let mut expected_modes = vec![crate::completion::ModeClass::Both; expected.len()]; - if mode == crate::mode::Mode::Advanced { + { let s = skip_whitespace(source, 0); if let Some((kw_start, kw_end)) = consume_ident(source, s) { let entry = &source[kw_start..kw_end]; let candidates = grammar::commands_for_entry_word(entry); - if candidates.len() > 1 { - use crate::dsl::grammar::CommandCategory; + use crate::dsl::grammar::CommandCategory; + // Advanced mode merges DSL + SQL continuations across all + // candidate nodes; Simple mode merges only when an entry word + // has more than one DSL form (e.g. `create table` vs + // `create m:n relationship`, ADR-0045). With a single DSL form + // the committed node already carries every continuation, so + // that case is left untouched (its `Both` mode-class too) — + // keeping this zero-ripple for every existing command. + let simple_count = candidates + .iter() + .filter(|(_, _, c)| *c == CommandCategory::Simple) + .count(); + let run_merge = match mode { + crate::mode::Mode::Advanced => candidates.len() > 1, + crate::mode::Mode::Simple => simple_count > 1, + }; + if run_merge { // (continuation word, produced-by-simple, produced-by-advanced) let mut tally: Vec<(&'static str, bool, bool)> = Vec::new(); // Continuations that aren't keyword/literal-shaped @@ -422,6 +437,13 @@ pub fn completion_probe_in_mode( // for punctuation defaults to `Both`. let mut punct_tally: Vec = Vec::new(); for (_, node, category) in candidates { + // Simple mode never offers advanced SQL continuations + // (ADR-0030 §2); only DSL forms contribute. + if mode == crate::mode::Mode::Simple + && category == CommandCategory::Advanced + { + continue; + } let mut sctx = context::WalkContext::with_schema(schema); sctx.mode = mode; let (res, _) = @@ -2720,13 +2742,46 @@ fn decide( // appended at the rendering layer (see // `advanced_alternative_note`), combining the DSL fix with // the mode hint. - match simple.first() { - Some(&(sidx, snode)) => Decision::Commit { idx: sidx, node: snode }, - None => { - let primary = candidates.first().map_or("", |(_, n, _)| n.entry.primary); - Decision::ThisIsSql { primary } + if simple.is_empty() { + let primary = candidates.first().map_or("", |(_, n, _)| n.entry.primary); + return Decision::ThisIsSql { primary }; + } + // An entry word may register more than one DSL form + // (e.g. `create table` and `create m:n relationship`, + // ADR-0045). Commit the first that fully matches or is + // content-rejected (a `ValidationFailed` means the shape + // fits but the content is invalid — that error must + // surface), mirroring the advanced branch below. With a + // single DSL form this reduces to "commit it": a lone + // non-matching candidate falls through to the + // furthest-progress step and is committed anyway, so its + // positioned DSL error still surfaces (unchanged behaviour). + for &(idx, node) in &simple { + if matches!( + scratch_outcome(effective_source, kw_start, kw_end, node, mode, schema), + WalkOutcome::Match { .. } | WalkOutcome::ValidationFailed { .. } + ) { + return Decision::Commit { idx, node }; } } + // None matched — commit the furthest-progress candidate + // (first on ties) so the surfaced DSL error is the most + // informative. + let mut best = simple[0]; + let mut best_progress = + scratch_progress(effective_source, kw_start, kw_end, best.1, mode, schema); + for &(idx, node) in &simple[1..] { + let progress = + scratch_progress(effective_source, kw_start, kw_end, node, mode, schema); + if progress > best_progress { + best = (idx, node); + best_progress = progress; + } + } + Decision::Commit { + idx: best.0, + node: best.1, + } } crate::mode::Mode::Advanced => { // Advanced candidates first, DSL as the fallback. diff --git a/src/echo.rs b/src/echo.rs index e320cb7..04461ca 100644 --- a/src/echo.rs +++ b/src/echo.rs @@ -15,6 +15,7 @@ use crate::app::EffectiveMode; use crate::dsl::ReferentialAction; +use crate::dsl::types::Type; use crate::dsl::Command; use crate::dsl::command::{ ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter, @@ -286,6 +287,31 @@ pub(crate) fn render_add_relationship( s } +/// The advanced-mode DSL→SQL teaching echo (ADR-0038) for `create m:n +/// relationship` (ADR-0045): the single `CREATE TABLE` the junction +/// expands to — every FK column, the compound primary key over them, +/// and the two `CASCADE` foreign keys (m:n always cascades, D2). Built +/// from the post-exec junction description (the resolved columns don't +/// exist on the command), so it shows exactly what was created. +pub(crate) fn render_create_m2n( + junction: &str, + columns: &[(String, Type)], + primary_key: &[String], + foreign_keys: &[(Vec, String, Vec)], +) -> String { + let mut parts: Vec = + columns.iter().map(|(n, ty)| format!("{n} {}", ty.keyword())).collect(); + parts.push(format!("PRIMARY KEY ({})", primary_key.join(", "))); + for (child_columns, parent_table, parent_columns) in foreign_keys { + parts.push(format!( + "FOREIGN KEY ({}) REFERENCES {parent_table} ({}) ON DELETE CASCADE ON UPDATE CASCADE", + child_columns.join(", "), + parent_columns.join(", "), + )); + } + format!("CREATE TABLE {junction} ({})", parts.join(", ")) +} + /// `ALTER TABLE DROP CONSTRAINT ` — the `drop relationship` /// echo (ADR-0038 §7 Bucket B). The runtime resolves both `name` (for an /// `Endpoints` selector) and `child_table` (for a `Named` selector) **pre- @@ -1077,6 +1103,34 @@ mod tests { ); } + #[test] + fn create_m2n_echo_renders_junction_and_round_trips() { + // The advanced-mode teaching echo for `create m:n relationship` + // (ADR-0045): the single CREATE TABLE the junction expands to, + // compound PK + the two CASCADE FKs — and it is valid SQL. + let sql = render_create_m2n( + "Students_Courses", + &[ + ("Students_id".to_string(), Type::Int), + ("Courses_id".to_string(), Type::Int), + ], + &["Students_id".to_string(), "Courses_id".to_string()], + &[ + (vec!["Students_id".to_string()], "Students".to_string(), vec!["id".to_string()]), + (vec!["Courses_id".to_string()], "Courses".to_string(), vec!["id".to_string()]), + ], + ); + assert_eq!( + sql, + "CREATE TABLE Students_Courses (Students_id int, Courses_id int, \ + PRIMARY KEY (Students_id, Courses_id), \ + FOREIGN KEY (Students_id) REFERENCES Students (id) ON DELETE CASCADE ON UPDATE CASCADE, \ + FOREIGN KEY (Courses_id) REFERENCES Courses (id) ON DELETE CASCADE ON UPDATE CASCADE)" + ); + // The echoed SQL is valid advanced-mode SQL (round-trips). + assert!(matches!(reparse(&sql), Ok(Command::SqlCreateTable { .. }))); + } + // --- expr / literal rendering ------------------------------------ #[test] diff --git a/src/event.rs b/src/event.rs index 293dacf..51b2be2 100644 --- a/src/event.rs +++ b/src/event.rs @@ -165,6 +165,10 @@ pub enum AppEvent { /// posts this alongside `TablesRefreshed` after project /// load and after every successful DDL. SchemaCacheRefreshed(crate::completion::SchemaCache), + /// Refreshed list of relationships as full schema records, for the + /// sidebar relationships panel (ADR-0046 DB2). Posted by the runtime + /// alongside `SchemaCacheRefreshed` after every schema refresh. + RelationshipsRefreshed(Vec), /// A persistence failure occurred (ADR-0015 §8). The /// application surfaces a fatal banner and exits cleanly so /// the message remains above the shell prompt. diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index 4855855..389a22a 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -190,6 +190,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("help.app.redo", &[]), ("help.app.copy", &[]), ("help.ddl.create", &[]), + ("help.ddl.create_m2n", &[]), ("help.ddl.sql_create_table", &[]), ("help.ddl.sql_drop_table", &[]), ("help.ddl.sql_create_index", &[]), @@ -277,6 +278,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("parse.usage.add_relationship", &[]), ("parse.usage.change_column", &[]), ("parse.usage.create_table", &[]), + ("parse.usage.create_m2n", &[]), ("parse.usage.sql_create_table", &[]), ("parse.usage.sql_drop_table", &[]), ("parse.usage.sql_create_index", &[]), @@ -441,6 +443,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("panel.hint_empty", &[]), ("panel.hint_title", &[]), ("panel.output_title", &[]), + ("panel.relationships_empty", &[]), + ("panel.relationships_title", &[]), ("panel.tables_empty", &[]), ("panel.tables_title", &[]), ("status.no_project", &[]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 2f2e432..88778ee 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -204,6 +204,9 @@ help: project's stored mode. Without it, the project's last-used mode is restored (default: simple). + --demo Demonstration mode: show on-screen badges + for otherwise-invisible keys (Tab, Enter, + ...) — for screencasts and live teaching. App-level commands (typed inside the app, available in both modes): quit Exit cleanly. @@ -279,6 +282,9 @@ help: ddl: create: |- create table with pk [(), ...] — create a table + create_m2n: |- + create m:n relationship from to [as ] + — build a junction table linking two tables sql_create_table: |- create table [if not exists] ( [not null] [unique] [primary key] [default ] [check ()] [references

[(

)]], ... @@ -523,6 +529,7 @@ parse: # placeholders. ADR-0009's surface conventions apply. usage: create_table: "create table with pk [()[, ...]]" + create_m2n: "create m:n relationship from to [as ]" # Terse one-line synopsis (issue #12): the full grammar — every # column- and table-level constraint — lives in `help.ddl.sql_create_table`. sql_create_table: "create table [if not exists] ( [constraints], ...)" @@ -849,6 +856,8 @@ status: panel: tables_title: "Tables" tables_empty: "(none yet)" + relationships_title: "Relationships" + relationships_empty: "(none)" hint_empty: "Type a command — press Tab for options, `help` for a list" # Panel titles for the output and hint panels (rendered inside # the rounded border, hence the leading/trailing space). diff --git a/src/friendly/translate.rs b/src/friendly/translate.rs index cbbe194..74cdb00 100644 --- a/src/friendly/translate.rs +++ b/src/friendly/translate.rs @@ -882,6 +882,30 @@ mod tests { assert!(f.headline.contains("`99`")); } + #[test] + fn fk_child_side_renders_every_column_of_a_compound_key() { + // ADR-0043 residual: a compound-FK violation carries the + // comma-joined column + value lists in the single-column facts + // slots, so the headline names every pair, not just the first. + let err = sqlite( + "FOREIGN KEY constraint failed", + SqliteErrorKind::UniqueViolation, + ); + let mut ctx = ctx_with(Operation::Insert); + ctx.parent_table = Some("Region".to_string()); + ctx.parent_column = Some("country, code".to_string()); + ctx.value = Some("7, 8".to_string()); + let f = translate(&err, &ctx); + assert!(f.headline.contains("no parent row"), "child-side: {}", f.headline); + assert!(f.headline.contains("Region")); + assert!( + f.headline.contains("country, code"), + "both parent columns must appear: {}", + f.headline + ); + assert!(f.headline.contains("`7, 8`"), "joined value: {}", f.headline); + } + #[test] fn fk_with_delete_op_renders_parent_side_wording() { let err = sqlite( diff --git a/src/logging.rs b/src/logging.rs index ad00703..6183e49 100644 --- a/src/logging.rs +++ b/src/logging.rs @@ -6,6 +6,38 @@ //! environment variable; if neither is set we default to //! `~/.rdbms-playground/playground.log` and create directories as //! needed. +//! +//! ## Level conventions (X1 — `requirements.md`) +//! +//! Instrumentation across the tree follows a consistent level +//! discipline so the default `info` filter stays quiet and +//! `RDBMS_PLAYGROUND_LOG=debug` (or `=trace`) is a rich, layered +//! diagnostic stream. The env filter (`RDBMS_PLAYGROUND_LOG`, +//! full `EnvFilter` syntax) controls this independently of the +//! file path above; the default is `info`. +//! +//! - **`error!`** — unrecoverable failure (fatal persistence, a +//! panic-equivalent). The process is going down or a command is +//! hard-failing. +//! - **`warn!`** — recoverable failure or a fallback taken (a +//! snapshot couldn't be staged, a `PRAGMA` couldn't be restored, +//! an integrity check rolled a rebuild back). +//! - **`info!`** — low-volume lifecycle, visible by default: db +//! worker start/exit, project create/open, "logging initialised". +//! - **`debug!`** — the bulk of instrumentation, one line per +//! *executed* command and the decision points within it (executor +//! entry with key params, autofill/cascade summaries, the +//! rebuild-table primitive, persistence writes, render-mode +//! choice). Off by default. +//! - **`trace!`** — hot paths only: per-keystroke parsing +//! (`dsl::parser`), per-key input handling (`app`), per-refresh +//! table reads. A firehose; never on except when debugging that +//! specific layer. +//! +//! Rule of thumb for new code: a loop logs a single summary count, +//! never per-iteration at `debug`/`info`. Logs are developer-facing, +//! so naming the engine (SQLite/PRAGMA) is fine here even though the +//! "no engine name" rule (ADR-0002) forbids it in user-facing strings. use std::fs::{File, OpenOptions, create_dir_all}; use std::path::{Path, PathBuf}; diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 5197b4f..96ccec7 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -19,6 +19,8 @@ use std::fs; use std::io::Write as _; use std::path::{Path, PathBuf}; +use tracing::debug; + use crate::dsl::action::ReferentialAction; use crate::dsl::types::Type; use crate::mode::Mode; @@ -338,6 +340,7 @@ impl Persistence { /// renames over the destination. pub fn write_schema(&self, schema: &SchemaSnapshot) -> Result<(), PersistenceError> { let body = yaml::serialize_schema(schema); + debug!(bytes = body.len(), "persist: write project.yaml (atomic)"); atomic_write(&self.project_path.join(PROJECT_YAML), body.as_bytes()) } @@ -355,8 +358,10 @@ impl Persistence { /// with files they didn't ask for. pub fn write_table_data(&self, table: &TableSnapshot) -> Result<(), PersistenceError> { if table.rows.is_empty() { + debug!(table = %table.name, "persist: table empty -> removing CSV (no data, no CSV)"); return self.delete_table_data(&table.name); } + debug!(table = %table.name, rows = table.rows.len(), "persist: write data/
.csv (atomic)"); let data_dir = self.project_path.join(DATA_DIR); fs::create_dir_all(&data_dir).map_err(|source| PersistenceError::Io { operation: "create", @@ -394,6 +399,7 @@ impl Persistence { pub fn append_history(&self, command_text: &str) -> 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"); history::append(&path, &line) } @@ -411,6 +417,7 @@ impl Persistence { history::utc_iso8601_now(), history::STATUS_ERR, ); + debug!(len = command_text.len(), "persist: append err record to history.log"); history::append(&path, &line) } diff --git a/src/runtime.rs b/src/runtime.rs index 9124b19..ba9c056 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -11,7 +11,7 @@ use std::io; use std::path::PathBuf; -use std::time::Duration; +use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use crossterm::event::{Event as CtEvent, EventStream}; @@ -53,6 +53,24 @@ const SHUTDOWN_GRACE: Duration = Duration::from_millis(100); /// reappears once typing stops (ADR-0027 §3). const INDICATOR_DEBOUNCE: Duration = Duration::from_millis(1000); +/// How long a demo-mode keystroke badge stays on screen before it +/// fades on its own (ADR-0047 D5). Long enough to read in a screencast +/// or in front of a class; short enough that a trailing `wait` in a +/// cast ends on a clean frame. +const DEMO_BADGE_TTL: Duration = Duration::from_millis(1500); + +/// The nearest (soonest) of two optional deadlines (ADR-0047 D5) — the +/// instant the event loop should next wake to service a timer. `None` +/// when neither is set (the loop then blocks on `recv`). Pure, so the +/// scheduling decision is unit-testable without the loop. +fn nearest_deadline(a: Option, b: Option) -> Option { + match (a, b) { + (Some(a), Some(b)) => Some(a.min(b)), + (Some(a), None) => Some(a), + (None, b) => b, + } +} + /// The input-validity indicator's debounce state machine /// (ADR-0027 §3, step E). /// @@ -216,6 +234,9 @@ pub async fn run(args: Args) -> Result<()> { let db_existed = db_path.exists(); // Undo is on unless `--no-undo` (ADR-0006 Amendment 1). let undo_enabled = !args.no_undo; + // Demonstration mode under `--demo` / `RDBMS_PLAYGROUND_DEMO` + // (ADR-0047). Off by default; threaded onto the `App` in run_loop. + let demo_mode = args.demo; let database = Database::open_with_persistence_and_undo(db_path.as_path(), persistence, undo_enabled) .context("open database")?; @@ -273,6 +294,7 @@ pub async fn run(args: Args) -> Result<()> { initial_events, undo_enabled, resolved_mode, + demo_mode, ) .await; if let Err(e) = teardown_terminal(&mut terminal) { @@ -331,6 +353,7 @@ async fn run_loop( initial_events: Vec, undo_enabled: bool, initial_mode: crate::mode::Mode, + demo_mode: bool, ) -> Result> { let (event_tx, mut event_rx) = mpsc::channel::(EVENT_CHANNEL_CAPACITY); let reader_handle = spawn_event_reader(event_tx.clone()); @@ -339,6 +362,8 @@ async fn run_loop( app.project_name = Some(project_display_name); app.project_is_temp = project_is_temp; app.undo_enabled = undo_enabled; + // ADR-0047: enable the demo overlays for this session under `--demo`. + app.demo_mode = demo_mode; // Start in the resolved input mode (ADR-0015 mode-restore // amendment, issue #14): `--mode` > stored project mode > // default. `Persistence` already carries the same value, so the @@ -376,6 +401,17 @@ async fn run_loop( // no wake-ups. See `IndicatorDebounce` for the decision // logic; `app.input_indicator` mirrors it for the renderer. let mut debounce = IndicatorDebounce::default(); + // ADR-0027 §3 + ADR-0047 D5: absolute deadlines for the two timed + // wake-ups — the indicator debounce and the demo keystroke-badge + // expiry. The loop time-boxes `recv` on the *nearest* of them and, + // on elapse, services whichever actually fired. Tracking them as + // `Instant`s (rather than one fixed `timeout` duration) lets the + // shorter badge timer fire without prematurely settling the longer + // debounce, and vice-versa. Both `None` ⇒ block on `recv` (no idle + // wake-ups). + let mut debounce_deadline: Option = None; + let mut badge_deadline: Option = None; + let mut last_badge_seq: u64 = app.demo_badge_seq; // Long-lived native clipboard for the `copy` command (ADR-0041). // Created lazily on first copy (so an OSC-52-only session never // opens an X11 connection) and kept alive for the session — the @@ -383,25 +419,36 @@ async fn run_loop( // handle, so it must outlive each write. let mut native_clipboard = crate::clipboard::SystemClipboard::new(); loop { - let event = if debounce.is_armed() { - match tokio::time::timeout(INDICATOR_DEBOUNCE, event_rx.recv()).await { - Ok(Some(event)) => event, - Ok(None) => break, - Err(_elapsed) => { - // Typing has been quiet for the debounce - // interval — settle the indicator. - debounce.settle(app.input_validity_verdict()); - app.input_indicator = debounce.visible(); - terminal - .draw(|f| ui::render(&mut app, &theme, f)) - .context("redraw")?; - continue; - } - } - } else { - match event_rx.recv().await { + let event = match nearest_deadline(debounce_deadline, badge_deadline) { + None => match event_rx.recv().await { Some(event) => event, None => break, + }, + Some(deadline) => { + let wait = deadline.saturating_duration_since(Instant::now()); + match tokio::time::timeout(wait, event_rx.recv()).await { + Ok(Some(event)) => event, + Ok(None) => break, + Err(_elapsed) => { + let now = Instant::now(); + // ADR-0047 D5: the keystroke badge has aged out. + if badge_deadline.is_some_and(|d| d <= now) { + app.demo_badge = None; + badge_deadline = None; + } + // ADR-0027 §3: typing has paused for the debounce + // interval — settle the validity indicator. + if debounce_deadline.is_some_and(|d| d <= now) { + debounce.settle(app.input_validity_verdict()); + app.input_indicator = debounce.visible(); + debounce_deadline = None; + } + terminal + .draw(|f| ui::render(&mut app, &theme, f)) + .context("redraw")?; + continue; + } + } } }; let is_key = matches!(event, AppEvent::Key(_)); @@ -584,6 +631,23 @@ async fn run_loop( // pauses; non-key events leave it untouched. debounce.note_event(is_key); app.input_indicator = debounce.visible(); + // Keep the debounce deadline in lock-step with `is_armed()`, + // restarting it on every event while armed (preserving the prior + // behaviour) and clearing it once the indicator is visible again. + debounce_deadline = debounce + .is_armed() + .then(|| Instant::now() + INDICATOR_DEBOUNCE); + // ADR-0047 D5: (re)arm the badge timer whenever `update()` set a + // fresh badge. `demo_badge_seq` bumps even for the same label + // twice, so a repeated key restarts the timer rather than letting + // a stale deadline expire it early. + if app.demo_badge_seq != last_badge_seq { + last_badge_seq = app.demo_badge_seq; + badge_deadline = app + .demo_badge + .is_some() + .then(|| Instant::now() + DEMO_BADGE_TTL); + } terminal .draw(|f| ui::render(&mut app, &theme, f)) .context("redraw")?; @@ -1079,6 +1143,13 @@ async fn refresh_schema_cache( ) { let cache = build_schema_cache(database).await; let _ = event_tx.send(AppEvent::SchemaCacheRefreshed(cache)).await; + // ADR-0046 DB2: full relationship records for the sidebar panel. + // Best-effort — a failed read leaves the panel empty. + if let Ok(relationships) = database.read_all_relationships().await { + let _ = event_tx + .send(AppEvent::RelationshipsRefreshed(relationships)) + .await; + } } /// Build a `SchemaCache` snapshot from the live database. @@ -1832,6 +1903,24 @@ fn build_schema_echo( .map(|(name, child_table)| { vec![crate::echo::render_drop_relationship(name, child_table)] }), + // `create m:n relationship` (ADR-0045): the resolved junction + // columns/FKs only exist on the post-exec description, so the + // teaching echo is rendered from it (not `command_to_sql`). + Command::CreateM2nRelationship { .. } => description.map(|desc| { + let columns: Vec<(String, crate::dsl::types::Type)> = desc + .columns + .iter() + .filter_map(|c| c.user_type.map(|ty| (c.name.clone(), ty))) + .collect(); + let primary_key: Vec = + desc.columns.iter().filter(|c| c.primary_key).map(|c| c.name.clone()).collect(); + let foreign_keys: Vec<(Vec, String, Vec)> = desc + .outbound_relationships + .iter() + .map(|r| (r.local_columns.clone(), r.other_table.clone(), r.other_columns.clone())) + .collect(); + vec![crate::echo::render_create_m2n(&desc.name, &columns, &primary_key, &foreign_keys)] + }), // Everything else (Bucket A pure-Command, plus the no-echo Bucket C // variants like `Sql*` / `ShowTable`) routes through the existing // `echo::command_to_sql` — wrapping its `Option` to fit the @@ -2017,22 +2106,33 @@ async fn enrich_fk_violation( }; facts.table = Some(table.clone()); for rel in outbound { - // The friendly FK-error facts model is single-column - // (ADR-0019); for a compound FK (ADR-0043) we enrich - // from the first column pair — the error still surfaces, - // richer multi-column enrichment is a later refinement. - let Some(local_col) = rel.local_columns.first().cloned() else { + // Identify the violated FK by the first local column the + // user supplied a value for (SQLite names no column in the + // error). The single-column facts slots then carry the + // comma-joined lists so a compound FK (ADR-0043) names + // *every* child->parent column pair, not just the first. + let Some(first_local) = rel.local_columns.first().cloned() else { continue; }; - let value = - user_value_for_column_with_schema(database, command, table, &local_col).await; - if let Some(v) = value { - facts.column = Some(local_col); - facts.parent_table = Some(rel.other_table); - facts.parent_column = rel.other_columns.into_iter().next(); - facts.value = Some(v.to_string()); - break; + let Some(first_val) = + user_value_for_column_with_schema(database, command, table, &first_local).await + else { + continue; + }; + // Matched. Gather the remaining pairs' values in order. + let mut values = vec![first_val.to_string()]; + for local_col in rel.local_columns.iter().skip(1) { + if let Some(v) = + user_value_for_column_with_schema(database, command, table, local_col).await + { + values.push(v.to_string()); + } } + facts.column = Some(rel.local_columns.join(", ")); + facts.parent_table = Some(rel.other_table); + facts.parent_column = Some(rel.other_columns.join(", ")); + facts.value = Some(values.join(", ")); + break; } // For UPDATE, if no outbound match was found we may // be in the parent-side case (updating a column @@ -2531,6 +2631,7 @@ async fn execute_command_typed( command: Command, source: String, ) -> Result { + debug!(verb = command.verb(), "execute command (routing to worker)"); let src = Some(source); match command { Command::CreateTable { @@ -2645,6 +2746,10 @@ async fn execute_command_typed( ) .await .map(|d| CommandOutcome::Schema(Some(d))), + Command::CreateM2nRelationship { t1, t2, name } => database + .create_m2n_relationship(t1, t2, name, src) + .await + .map(|d| CommandOutcome::Schema(Some(d))), Command::DropRelationship { selector } => database .drop_relationship(selector, src) .await @@ -2964,8 +3069,24 @@ fn teardown_terminal( #[cfg(test)] mod tests { - use super::IndicatorDebounce; + use super::{IndicatorDebounce, nearest_deadline}; use crate::dsl::walker::Severity; + use std::time::{Duration, Instant}; + + #[test] + fn nearest_deadline_picks_the_soonest_or_none() { + let now = Instant::now(); + let soon = now + Duration::from_millis(100); + let later = now + Duration::from_millis(500); + // Neither armed ⇒ block (None). + assert_eq!(nearest_deadline(None, None), None); + // One armed ⇒ that one, regardless of order. + assert_eq!(nearest_deadline(Some(soon), None), Some(soon)); + assert_eq!(nearest_deadline(None, Some(soon)), Some(soon)); + // Both armed ⇒ the soonest, regardless of order. + assert_eq!(nearest_deadline(Some(soon), Some(later)), Some(soon)); + assert_eq!(nearest_deadline(Some(later), Some(soon)), Some(soon)); + } #[test] fn starts_hidden_and_disarmed() { 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 8bc79f9..46a7503 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap @@ -1,29 +1,29 @@ --- source: src/ui.rs -assertion_line: 1540 +assertion_line: 2326 expression: snapshot --- -╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ -│(none yet) ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ ADVANCED ────────────────────────────────────────╮ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ Hint ────────────────────────────────────────────╮ -│ ││Type a command — press Tab for options, `help` │ -│ ││for a list │ -╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +╭ Output ──────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭ ADVANCED ────────────────────────────────────────────────────────────────────╮ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ────────────────────────────────────────────────────────────────────────╮ +│Type a command — press Tab for options, `help` for a list │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner Enter submit · mode simple switch · Ctrl-C quit 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 51a442a..c49a798 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap @@ -1,29 +1,29 @@ --- source: src/ui.rs -assertion_line: 1523 +assertion_line: 2309 expression: snapshot --- -╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ -│(none yet) ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ SIMPLE ──────────────────────────────────────────╮ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ Hint ────────────────────────────────────────────╮ -│ ││Type a command — press Tab for options, `help` │ -│ ││for a list │ -╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +╭ Output ──────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭ SIMPLE ──────────────────────────────────────────────────────────────────────╮ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ────────────────────────────────────────────────────────────────────────╮ +│Type a command — press Tab for options, `help` for a list │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit 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 7253e06..4a41ef7 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap @@ -1,29 +1,29 @@ --- source: src/ui.rs -assertion_line: 1531 +assertion_line: 2317 expression: snapshot --- -╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ -│(none yet) ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ SIMPLE ──────────────────────────────────────────╮ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ Hint ────────────────────────────────────────────╮ -│ ││Type a command — press Tab for options, `help` │ -│ ││for a list │ -╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +╭ Output ──────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭ SIMPLE ──────────────────────────────────────────────────────────────────────╮ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ────────────────────────────────────────────────────────────────────────╮ +│Type a command — press Tab for options, `help` for a list │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit 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 new file mode 100644 index 0000000..7f82289 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap @@ -0,0 +1,30 @@ +--- +source: src/ui.rs +expression: snapshot +--- +╭ Output ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ [TAB] │ +│ │ +│ │ +│ Completing the name │ +│ │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ +│Type a command — press Tab for options, `help` for a list │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +Project: Term Planner +Enter submit · : advanced once · mode advanced switch · Ctrl-C quit 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 new file mode 100644 index 0000000..7120bbd --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap @@ -0,0 +1,30 @@ +--- +source: src/ui.rs +expression: snapshot +--- +╭ Output ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ [ENTER] │ +│ │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ +│Type a command — press Tab for options, `help` for a list │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +Project: Term Planner +Enter submit · : advanced once · mode advanced switch · Ctrl-C quit 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 new file mode 100644 index 0000000..d6358c1 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap @@ -0,0 +1,30 @@ +--- +source: src/ui.rs +expression: snapshot +--- +╭ Output ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ [TAB] │ +│ │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ +│Type a command — press Tab for options, `help` for a list │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +Project: Term Planner +Enter submit · : advanced once · mode advanced switch · Ctrl-C quit 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 new file mode 100644 index 0000000..b132bbd --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap @@ -0,0 +1,30 @@ +--- +source: src/ui.rs +expression: snapshot +--- +╭ Output ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ Now press Tab to complete the table name │ +│ │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ +│Type a command — press Tab for options, `help` for a list │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +Project: Term Planner +Enter submit · : advanced once · mode advanced switch · Ctrl-C quit 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 new file mode 100644 index 0000000..9d2184d --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap @@ -0,0 +1,30 @@ +--- +source: src/ui.rs +expression: snapshot +--- +╭ Output ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ This is a deliberately long step caption │ +│ that must wrap onto several lines and │ +│ then be clipped to three with an… │ +│ │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ SIMPLE ────────────────────────────────────────────────────────────────────────────────╮ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ +│Type a command — press Tab for options, `help` for a list │ +│ │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ +Project: Term Planner +Enter submit · : advanced once · mode advanced switch · Ctrl-C quit 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 5d836be..34a6f6a 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,29 +1,29 @@ --- source: src/ui.rs -assertion_line: 1583 +assertion_line: 2369 expression: snapshot --- -╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ -│(none yet) ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ SIMPLE ──────────────────────────────────────────╮ -│ ││insert into T values (1, 'hi', null) --all-r │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ Hint ────────────────────────────────────────────╮ -│ ││after `insert into T values (1, 'hi', null)`, │ -│ ││expected end of input — usage: insert into │ -│ ││
[([, ...])] [values] ([, ...])│ -╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +╭ Output ──────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭ SIMPLE ──────────────────────────────────────────────────────────────────────╮ +│insert into T values (1, 'hi', null) --all-rows $ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ────────────────────────────────────────────────────────────────────────╮ +│after `insert into T values (1, 'hi', null)`, expected end of input — usage: │ +│insert into
[([, ...])] [values] ([, ...]) │ +╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit 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 new file mode 100644 index 0000000..57a76f8 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap @@ -0,0 +1,29 @@ +--- +source: src/ui.rs +assertion_line: 2967 +expression: snapshot +--- +╭ Tables ───────────────────────────────────╮ ─────────────────────────────────╮ +│Customers │ │ +│Orders │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +│ │ ─────────────────────────────────╯ +│ │ ─────────────────────────────────╮ +╰───────────────────────────────────────────╯ │ +╭ Relationships ────────────────────────────╮ ─────────────────────────────────╯ +│Customers_Orders │ ─────────────────────────────────╮ +│ Customers.id -> │ ` for a list │ +│ Orders.customer_id │ │ +╰───────────────────────────────────────────╯ ─────────────────────────────────╯ +Project: Term Planner +Enter submit · : advanced once · mode advanced switch · Ctrl-C quit 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 cb62313..5afcd79 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,28 +1,29 @@ --- source: src/ui.rs +assertion_line: 2385 expression: snapshot --- -╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ -│(none yet) ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ Advanced: ───────────────────────────────────────╮ -│ ││: sel │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ Hint ────────────────────────────────────────────╮ -│ ││select │ -╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +╭ Output ──────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭ Advanced: ───────────────────────────────────────────────────────────────────╮ +│: sel │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ────────────────────────────────────────────────────────────────────────╮ +│select │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner Enter submit · Backspace cancel one-shot · Ctrl-C quit 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 4536ba5..012b295 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,29 +1,29 @@ --- source: src/ui.rs -assertion_line: 1841 +assertion_line: 2679 expression: snapshot --- -╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ -│Customers ││[simple] create table Customers ✓ │ -│Orders ││[system] Customers │ -│ ││[system] id serial [PK] │ -│ ││[system] Name text │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ SIMPLE ──────────────────────────────────────────╮ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ Hint ────────────────────────────────────────────╮ -│ ││Type a command — press Tab for options, `help` │ -│ ││for a list │ -╰──────────────────────────╯╰──────────────────────────────────────────────────╯ -Project: Term Planner +╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮ +│Customers ││[simple] create table Customers ✓ │ +│Orders ││[system] Customers │ +│ ││[system] id serial [PK] │ +│ ││[system] Name text │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ │╰────────────────────────────────────────────────────────────────────────────────╯ +│ │╭ SIMPLE ────────────────────────────────────────────────────────────────────────╮ +╰──────────────────────────╯│ │ +╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯ +│(none) │╭ Hint ──────────────────────────────────────────────────────────────────────────╮ +│ ││Type a command — press Tab for options, `help` for a list │ +│ ││ │ +╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯ +Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit 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 8b3a591..2b36e30 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,15 +1,15 @@ --- source: src/ui.rs -assertion_line: 1613 +assertion_line: 2399 expression: snapshot --- -╭ Tables ──────────────────╮╭ Output ──────────────────────────────────────────╮ -│(none yet) ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ -│ ││ │ +╭ Output ──────────────────────────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ │ ╭ Rebuild project ─────────────────────────────────────────╮ │ │ │ │ │ │ │3 tables and 47 rows will be reconstructed; the existing │ │ @@ -17,13 +17,13 @@ expression: snapshot │ │ │ │ │ │Continue? │ │ │ │ │ │ -│ │[Y] Yes [N] No Esc cancel │─────────╯ -│ ╰──────────────────────────────────────────────────────────╯─────────╮ -│ ││ │ -│ │╰──────────────────────────────────────────────────╯ -│ │╭ Hint ────────────────────────────────────────────╮ -│ ││Type a command — press Tab for options, `help` │ -│ ││for a list │ -╰──────────────────────────╯╰──────────────────────────────────────────────────╯ +╰─────────│[Y] Yes [N] No Esc cancel │─────────╯ +╭ SIMPLE ─╰──────────────────────────────────────────────────────────╯─────────╮ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭ Hint ────────────────────────────────────────────────────────────────────────╮ +│Type a command — press Tab for options, `help` for a list │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner Enter submit · : advanced once · mode advanced switch · Ctrl-C quit diff --git a/src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap b/src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap new file mode 100644 index 0000000..3840ae1 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap @@ -0,0 +1,29 @@ +--- +source: src/ui.rs +assertion_line: 2789 +expression: snapshot +--- +╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮ +│Customers ││ │ +│Orders ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ │╰────────────────────────────────────────────────────────────────────────────────╯ +│ │╭ SIMPLE ────────────────────────────────────────────────────────────────────────╮ +╰──────────────────────────╯│ │ +╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯ +│Customers_Orders │╭ Hint ──────────────────────────────────────────────────────────────────────────╮ +│ Customers.id -> ││Type a command — press Tab for options, `help` for a list │ +│ Orders.customer_id ││ │ +╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯ +Project: Term Planner +Enter submit · : advanced once · mode advanced switch · Ctrl-C quit 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 new file mode 100644 index 0000000..9168780 --- /dev/null +++ b/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap @@ -0,0 +1,49 @@ +--- +source: src/ui.rs +assertion_line: 2265 +expression: snapshot +--- +╭ Output ──────────────────────────────────────────────────╮ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────╯ +╭ SIMPLE ──────────────────────────────────────────────────╮ +│select * from Customers where id = 12345 and name = │ +│'Alice Wonderland' │ +╰──────────────────────────────────────────────────────────╯ +╭ Hint ────────────────────────────────────────────────────╮ +│`select` is SQL — available in advanced mode. Switch │ +│with `mode advanced`, or prefix the line with `:` to run… │ +╰──────────────────────────────────────────────────────────╯ +Project: Term Planner +Enter submit · : advanced once · mode advanced switch · diff --git a/src/theme.rs b/src/theme.rs index 925e48d..ad2a424 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -20,6 +20,16 @@ use ratatui::style::Color; use crate::dsl::grammar::HighlightClass; +/// Foreground of the demonstration-mode overlays (ADR-0047 D4). +/// +/// Deliberately a fixed, theme-independent high-contrast pair — black +/// on yellow — so the badge / caption boxes are hard to overlook in a +/// screencast on any background. +pub const DEMO_OVERLAY_FG: Color = Color::Black; +/// Background of the demonstration-mode overlays (ADR-0047 D4); see +/// [`DEMO_OVERLAY_FG`]. +pub const DEMO_OVERLAY_BG: Color = Color::Rgb(0xFF, 0xD7, 0x00); + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Background { Light, diff --git a/src/ui.rs b/src/ui.rs index cd60b95..e4df268 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -12,7 +12,9 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Paragraph, Wrap}; -use crate::app::{App, EchoStatus, EffectiveMode, OutputKind, OutputLine, OutputStyleClass}; +use crate::app::{ + App, EchoStatus, EffectiveMode, NavFocus, OutputKind, OutputLine, OutputStyleClass, +}; use crate::mode::Mode; use crate::theme::Theme; @@ -23,6 +25,37 @@ use crate::theme::Theme; /// computation — without that feedback, scrolling past the top /// of the buffer would slide the visible window off and /// "eat" lines from the bottom on subsequent renders. +/// Minimum terminal width at which the schema sidebar (the left items +/// column) is shown by default (ADR-0046 DB1). At or below this the +/// sidebar is hidden so the output/input panels get the full width — +/// notably the 90-column screencasts. Tunable. +const SIDEBAR_MIN_WIDTH: u16 = 90; + +/// Whether the schema sidebar is visible — a pure function of terminal +/// width (ADR-0046 DB1). Phase C will also reveal it while a sidebar +/// panel is focused via the Ctrl-O peek. +const fn sidebar_visible(total_width: u16) -> bool { + total_width > SIDEBAR_MIN_WIDTH +} + +/// Height (including borders) of the Relationships sub-panel within the +/// left column (ADR-0046 DB4): floored at 5 rows (so an empty panel +/// shows "(none)"), grown with `content_rows` up to half the column, +/// and never so tall that the Tables panel above drops below 3 rows. +const fn relationships_panel_height(col_h: u16, content_rows: u16) -> u16 { + let want = content_rows + 2; // + top/bottom borders + let mut h = if want < 5 { 5 } else { want }; + let cap = col_h / 2; // never more than half the column + if h > cap { + h = cap; + } + let max_h = col_h.saturating_sub(3); // leave Tables at least 3 rows + if h > max_h { + h = max_h; + } + h +} + pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) { let area = frame.area(); paint_background(theme, frame, area); @@ -39,22 +72,219 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) { ]) .split(area); - let columns = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Length(28), Constraint::Min(20)]) - .split(outer[0]); - - render_items_panel(app, theme, frame, columns[0]); - render_right_column(app, theme, frame, columns[1]); + // ADR-0046 DB1: on a wide terminal the schema sidebar takes a fixed + // left column; at or below SIDEBAR_MIN_WIDTH it is hidden and the + // right column spans the full width. + if sidebar_visible(area.width) { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(28), Constraint::Min(20)]) + .split(outer[0]); + // ADR-0046 DB4: the sidebar stacks Tables (top) over a + // Relationships panel (bottom), the latter content-sized within + // [5 rows, half the column]. + let rel_content = (app.relationships.len() as u16).saturating_mul(3); + let rel_h = relationships_panel_height(columns[0].height, rel_content); + let sidebar = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(rel_h)]) + .split(columns[0]); + render_items_panel(app, theme, frame, sidebar[0]); + render_relationships_panel(app, theme, frame, sidebar[1]); + render_right_column(app, theme, frame, columns[1]); + } else { + render_right_column(app, theme, frame, outer[0]); + } render_project_label(app, theme, frame, outer[1]); render_status_bar(app, theme, frame, outer[2]); + // ADR-0046 DC2: in navigation mode, draw the focused sidebar as an + // expanded overlay over the (unchanged) base render — revealing it + // if it was hidden (peek) and widening it for browsing. Drawn below + // the modal layer; a modal can't open in navigation mode, but if one + // is somehow up it still wins. + if app.nav_focus.in_sidebar() { + render_nav_sidebar_overlay(app, theme, frame, outer[0]); + } + // Modal dialogs (rebuild confirm, save-as prompt, load // picker, …) are drawn last so they overlay the rest of // the frame. if let Some(modal) = app.modal.as_ref() { render_modal(modal, theme, frame, area); } + + // ADR-0047 D4: the demo overlays draw last of all — over modals — so + // a keystroke badge (and, in Phase C, a step caption) stays visible + // while the load picker (the #24 cast) or any modal is up. + if app.demo_mode { + render_demo_overlays(app, frame); + } +} + +/// The fixed high-contrast style for every demo overlay (ADR-0047 D4): +/// bold black text on a yellow background. +fn demo_overlay_style() -> Style { + Style::default() + .bg(crate::theme::DEMO_OVERLAY_BG) + .fg(crate::theme::DEMO_OVERLAY_FG) + .add_modifier(Modifier::BOLD) +} + +/// Draw the demonstration-mode overlays anchored to the output panel's +/// inner bottom-right corner (ADR-0047 D4): the step caption (if any) at +/// the bottom, the keystroke badge stacked directly above it (or at the +/// bottom when there is no caption). Both are inset one cell and skipped +/// rather than overflowing when the area is too small. +fn render_demo_overlays(app: &App, frame: &mut Frame<'_>) { + let area = app.last_output_area; + if area.width == 0 || area.height == 0 { + return; // not measured yet + } + // Caption first — it owns the bottom-right corner. The badge then + // stacks above whatever the caption actually occupied. + let caption_rect = app + .demo_caption + .as_deref() + .and_then(|text| render_caption_box(text, area, frame)); + if let Some(label) = app.demo_badge { + render_badge_box(label, area, caption_rect, frame); + } +} + +/// Paint a flat filled overlay rectangle — a solid yellow block with no +/// border glyphs (ADR-0047 D4) — and lay `body` inside a one-cell +/// margin. The borderless solid block is deliberately *unlike* the app's +/// bordered panels, so the demo overlays read as a distinct callout. +fn fill_overlay_rect(rect: Rect, body: String, frame: &mut Frame<'_>) { + frame.render_widget(ratatui::widgets::Clear, rect); + // `Block` with no borders fills the whole rect with the overlay + // background (same mechanism as `paint_background`). + frame.render_widget(Block::default().style(demo_overlay_style()), rect); + let inner = rect.inner(Margin { + horizontal: 1, + vertical: 1, + }); + frame.render_widget(Paragraph::new(body).style(demo_overlay_style()), inner); +} + +/// A small high-contrast keystroke badge (`[TAB]`, `[ENTER]`, …) inset +/// one cell from the bottom-right of `area` (ADR-0047 D2/D4) — the label +/// on a flat yellow rectangle with a one-cell margin. When a caption box +/// is present (`above`), the badge sits directly on top of it, right +/// edges aligned; otherwise it takes the bottom-right corner. Skipped +/// rather than overflowing if it cannot fit. +fn render_badge_box(label: &str, area: Rect, above: Option, frame: &mut Frame<'_>) { + let box_w = label.chars().count() as u16 + 2; // one-cell margin each side + let box_h = 3; // text row + a margin row above and below + if box_w + 1 > area.width { + return; + } + let x = area.x + area.width - box_w - 1; + let y = match above { + // Directly above the caption, right edges aligned. + Some(c) => { + if c.y < area.y + box_h { + return; // no room above the caption + } + c.y - box_h + } + None => { + if box_h + 1 > area.height { + return; + } + area.y + area.height - box_h - 1 + } + }; + fill_overlay_rect(Rect { x, y, width: box_w, height: box_h }, label.to_string(), frame); +} + +/// A step-caption box inset one cell from the bottom-right of `area` +/// (ADR-0047 D3/D4): the text word-wrapped to at most 3 lines within a +/// corner-sized width, bold black on a flat yellow rectangle. Returns +/// the rect it drew, or `None` if it was too small to place (so the +/// badge can fall back to the bottom-right corner). +fn render_caption_box(text: &str, area: Rect, frame: &mut Frame<'_>) -> Option { + // Content width capped so the box stays corner-sized; the caption + // wraps to ≤ 3 lines and ellipsises beyond (D4). + let content_w = 40.min(area.width.saturating_sub(4)) as usize; + if content_w < 4 { + return None; // output too narrow for a useful caption + } + let lines = clamp_wrapped(text, content_w, 3); + let inner_w = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0); + let box_w = inner_w as u16 + 2; // one-cell margin each side + let box_h = lines.len() as u16 + 2; // text rows + a margin row above and below + if box_w + 1 > area.width || box_h + 1 > area.height { + return None; + } + let rect = Rect { + x: area.x + area.width - box_w - 1, + y: area.y + area.height - box_h - 1, + width: box_w, + height: box_h, + }; + fill_overlay_rect(rect, lines.join("\n"), frame); + Some(rect) +} + +/// Width (columns) of the navigation-mode expanded sidebar overlay +/// (ADR-0046 DC2). Wide enough that most relationship endpoints fit on +/// one line, turning horizontal truncation into vertical scrolling. +const NAV_EXPANDED_WIDTH: u16 = 45; + +/// Blank columns cleared to the right of the expanded sidebar overlay +/// (ADR-0046 DC2), separating it from the base panels left visible +/// behind it so the overlay's right edge reads cleanly. +const NAV_OVERLAY_GUTTER: u16 = 1; + +/// Draw the focused sidebar, expanded, as an overlay over the left of +/// the main content area (ADR-0046 DC2/DC3). `Clear` + a background +/// repaint hide the base render underneath; the two panels keep the +/// DB4 split. The focused panel is accent-bordered (DC3). +fn render_nav_sidebar_overlay(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { + // ADR-0046 DC2: clear the sidebar strip plus a one-column gutter and + // paint the expanded sidebar over it. The base output / input / hint + // stay visible to the right — unchanged, just partially occluded — + // and the gutter keeps them from butting against the sidebar's + // border. They are restored fully on the next frame when navigation + // mode exits. + let width = NAV_EXPANDED_WIDTH.min(area.width); + let cleared_w = (width + NAV_OVERLAY_GUTTER).min(area.width); + let cleared = Rect { + x: area.x, + y: area.y, + width: cleared_w, + height: area.height, + }; + frame.render_widget(ratatui::widgets::Clear, cleared); + paint_background(theme, frame, cleared); + let sidebar = Rect { + x: area.x, + y: area.y, + width, + height: area.height, + }; + let rel_content = (app.relationships.len() as u16).saturating_mul(3); + let rel_h = relationships_panel_height(sidebar.height, rel_content); + let parts = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(3), Constraint::Length(rel_h)]) + .split(sidebar); + render_items_panel(app, theme, frame, parts[0]); + 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. +fn panel_border_style(theme: &Theme, focused: bool) -> Style { + if focused { + Style::default() + .fg(theme.fg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.border) + } } fn render_modal(modal: &crate::app::Modal, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { @@ -493,12 +723,66 @@ fn wrap_lines(s: &str, width: usize) -> Vec { lines } -/// Maximum content rows the Hint panel may grow to before its last -/// visible row is ellipsis-truncated (issue #12). The panel starts -/// at one row and grows only as far as a wrapped hint needs, up to -/// this cap, reclaiming the space when the hint is short. +/// Absolute ceiling on Hint-panel content rows. Per ADR-0046 (DA1/DA2) +/// the panel's height is no longer driven by the hint *content* — it is +/// a pure function of terminal geometry (`hint_rows`), fixed between +/// resizes, so it cannot resize mid-typing and shove the input/output +/// panels (#20). This is the most rows `hint_rows` will ever allocate; +/// a hint longer than the allocation is ellipsis-truncated (issue #12's +/// overflow signalling is retained — only the *sizing* changed). const MAX_HINT_ROWS: usize = 3; +/// Terminal heights below this are "compact" (covers the ~25-row +/// screencasts); at or above it the terminal is "comfortable" and can +/// afford taller panels (ADR-0046 DA2). Tunable. +const COMFORTABLE_MIN_HEIGHT: u16 = 40; + +/// A 3rd hint row is only ever needed when the hint column's inner +/// width is narrow enough to wrap the longest catalog hint past two +/// lines; at or above this width two rows always suffice (ADR-0046 DA2, +/// measured against `src/friendly/strings/en-US.yaml`). +const HINT_THIRD_ROW_MAX_INNER: u16 = 54; + +/// Input- and hint-panel content-row counts as a pure function of the +/// right column's geometry (ADR-0046 DA1/DA2/DA4) — NOT of the hint or +/// input text. That is the point of #20: heights fixed per terminal +/// size cannot jump as the user types. Returns `(input_rows, +/// hint_rows)`; add 2 to each for the panel borders. +/// +/// - Compact height (`< COMFORTABLE_MIN_HEIGHT`): input 1 row, hint 2. +/// - Comfortable height: input 2 rows (DA4 two-row display); hint 2, or +/// 3 when the column is narrow enough (`inner < +/// HINT_THIRD_ROW_MAX_INNER`) that the longest hint needs a third +/// line. +/// - Degradation: on a terminal too short to honour the output panel's +/// `Min(5)` plus both panels, the hint shrinks first, then the input, +/// so the output keeps its floor. +const fn panel_heights(area: Rect) -> (u16, u16) { + let comfortable = area.height >= COMFORTABLE_MIN_HEIGHT; + let inner_w = area.width.saturating_sub(2); + let mut input_c: u16 = if comfortable { 2 } else { 1 }; + let mut hint_c: u16 = if !comfortable { + 2 + } else if inner_w < HINT_THIRD_ROW_MAX_INNER { + MAX_HINT_ROWS as u16 + } else { + 2 + }; + // Honour the output panel's Min(5) first on a very short terminal: + // 5 (output) + (input_c + 2) + (hint_c + 2) must fit in the column. + // Shrink the hint first, then the input. + while 5 + (input_c + 2) + (hint_c + 2) > area.height { + if hint_c > 1 { + hint_c -= 1; + } else if input_c > 1 { + input_c -= 1; + } else { + break; + } + } + (input_c, hint_c) +} + /// Word-wrap `text` to `width`, then clamp to at most `max_rows` /// rows. If wrapping produced more rows than the cap, the last kept /// row is truncated to end with an ellipsis so the overflow is @@ -551,21 +835,21 @@ fn render_project_label(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: R } fn render_right_column(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { - // Resolve the hint first so the layout can size the Hint panel to - // the wrapped hint (issue #12): one content row by default, - // growing up to MAX_HINT_ROWS, reclaiming the space when short. - // The hint panel spans the full column width, so `area.width` is - // its width too. - let hint_lines = resolve_hint_lines(app, theme, area.width); - let hint_content = - (hint_lines.len().clamp(1, MAX_HINT_ROWS) as u16).saturating_add(2); + // ADR-0046 DA1/DA2: the Hint panel's height is a pure function of + // the column geometry, fixed between resizes — it no longer tracks + // the hint content, so typing cannot make it resize and shove the + // input/output panels (#20). The hint is then clamped to that fixed + // row budget. The hint panel spans the full column width, so + // `area.width` is its width too. + let (input_c, hint_c) = panel_heights(area); + let hint_lines = resolve_hint_lines(app, theme, area.width, hint_c as usize); let rows = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Min(5), // Output panel - Constraint::Length(3), // Input panel - Constraint::Length(hint_content), // Hint panel (dynamic) + Constraint::Min(5), // Output panel + Constraint::Length(input_c + 2), // Input panel (1 row, or 2 when tall) + Constraint::Length(hint_c + 2), // Hint panel (geometry-fixed) ]) .split(area); @@ -579,11 +863,14 @@ fn paint_background(theme: &Theme, frame: &mut Frame<'_>, area: Rect) { frame.render_widget(block, area); } -fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { +fn render_items_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let block = Block::default() .borders(Borders::ALL) .border_type(BorderType::Rounded) - .border_style(Style::default().fg(theme.border)) + .border_style(panel_border_style( + theme, + app.nav_focus == NavFocus::SidebarTables, + )) .title(Span::styled( format!(" {} ", crate::t!("panel.tables_title")), Style::default() @@ -592,6 +879,13 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec )) .style(Style::default().bg(theme.bg).fg(theme.fg)); + // ADR-0046 DC3: clamp + store the scroll before the (borrowing) + // lines are built. Visible rows and the content total are computed + // by counting (one row per table + one per index), so the `&mut + // app` writes finish before the immutable line borrows begin. + let visible = area.height.saturating_sub(2) as usize; + app.last_tables_visible = visible; + if app.tables.is_empty() { let placeholder = Paragraph::new(Line::from(Span::styled( crate::t!("panel.tables_empty"), @@ -604,6 +898,14 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec return; } + let total: usize = app + .tables + .iter() + .map(|t| 1 + app.schema_cache.table_indexes.get(t).map_or(0, Vec::len)) + .sum(); + let offset = app.tables_scroll.min(total.saturating_sub(visible)); + app.tables_scroll = offset; + let highlight = app .current_table .as_ref() @@ -633,10 +935,84 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec } } } - let paragraph = Paragraph::new(lines).block(block); + let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0)); frame.render_widget(paragraph, area); } +/// The Relationships sub-panel below the Tables list (ADR-0046 DB2). In +/// the narrow (unfocused) column each relationship is three lines — its +/// name, then the endpoints broken at the arrow to fit — every line +/// ellipsized past the inner width. Phase C adds focus + scroll for the +/// overflow; for now content beyond the panel's height is clipped. +fn render_relationships_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(panel_border_style( + theme, + app.nav_focus == NavFocus::SidebarRelationships, + )) + .title(Span::styled( + format!(" {} ", crate::t!("panel.relationships_title")), + Style::default() + .fg(theme.fg) + .add_modifier(Modifier::BOLD), + )) + .style(Style::default().bg(theme.bg).fg(theme.fg)); + + // ADR-0046 DC3: clamp + store the scroll before the borrowing lines + // (three rows per relationship). + let visible = area.height.saturating_sub(2) as usize; + app.last_relationships_visible = visible; + + if app.relationships.is_empty() { + let placeholder = Paragraph::new(Line::from(Span::styled( + crate::t!("panel.relationships_empty"), + Style::default() + .fg(theme.muted) + .add_modifier(Modifier::ITALIC), + ))) + .block(block); + frame.render_widget(placeholder, area); + return; + } + + let total = app.relationships.len() * 3; + let offset = app.relationships_scroll.min(total.saturating_sub(visible)); + app.relationships_scroll = offset; + + let inner_w = area.width.saturating_sub(2) as usize; + let name_style = Style::default().fg(theme.fg); + let detail_style = Style::default().fg(theme.muted); + let mut lines: Vec> = Vec::new(); + for rel in &app.relationships { + lines.push(Line::from(Span::styled( + ellipsize(&rel.name, inner_w), + name_style, + ))); + let parent = format!(" {}.{} ->", rel.parent_table, rel.parent_columns.join(", ")); + lines.push(Line::from(Span::styled(ellipsize(&parent, inner_w), detail_style))); + let child = format!(" {}.{}", rel.child_table, rel.child_columns.join(", ")); + lines.push(Line::from(Span::styled(ellipsize(&child, inner_w), detail_style))); + } + let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0)); + frame.render_widget(paragraph, area); +} + +/// Truncate `s` to `width` display columns, appending an ellipsis when +/// it overflows (ADR-0046 DB2). Assumes one column per character. +fn ellipsize(s: &str, width: usize) -> String { + if width == 0 { + return String::new(); + } + if s.chars().count() <= width { + return s.to_string(); + } + let mut out: String = s.chars().take(width.saturating_sub(1)).collect(); + out.push('…'); + out +} + fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let block = Block::default() .borders(Borders::ALL) @@ -670,6 +1046,9 @@ fn render_output_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area // ADR-0044 §3: record the panel width so a later `show relationship` // diagram (rendered App-side) can choose side-by-side vs vertical. app.last_output_width = inner.width; + // ADR-0047 D4: record the full inner area so the top-level draw can + // anchor the demo overlays to the output panel's bottom-right corner. + app.last_output_area = inner; let lines: Vec> = app .output @@ -909,7 +1288,41 @@ fn render_output_line<'a>(line: &'a OutputLine, theme: &Theme) -> Line<'a> { ]) } -fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { +/// Horizontal scroll offset (in display columns) for a single-line +/// input that may overflow its `text_width`-column viewport (ADR-0046 +/// DA3). Keeps the cursor visible; when the line overflows, reserves one +/// column on each side for the `<` / `>` overflow markers so a marker +/// never hides the cursor. `cursor_col` is the column of the cursor +/// cell (which can be `line_cols`, one past the last char, when the +/// cursor sits at the end). Returns the new offset given the previous +/// one, so the view only scrolls when the cursor would leave the window. +const fn input_scroll_offset( + line_cols: usize, + cursor_col: usize, + text_width: usize, + offset: usize, +) -> usize { + // The line (including the cursor-at-end cell) fits: no scroll. + if line_cols < text_width || text_width == 0 { + return 0; + } + // Reserve a column each side for the `<` / `>` markers. + let eff = if text_width > 2 { text_width - 2 } else { 1 }; + let mut off = offset; + if cursor_col < off { + off = cursor_col; + } else if cursor_col >= off + eff { + off = cursor_col + 1 - eff; + } + // Never scroll past the point where the cursor-at-end cell shows. + let max_off = (line_cols + 1).saturating_sub(eff); + if off > max_off { + off = max_off; + } + off +} + +fn render_input_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let effective = app.effective_mode(); let (border_color, mode_color, label) = match effective { EffectiveMode::Simple => ( @@ -949,41 +1362,42 @@ fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec .title(title) .style(Style::default().bg(theme.bg).fg(theme.fg)); - // Cursor block: render the character at the cursor position - // inverted so the cursor is visible without enabling a real - // terminal cursor. - // - // Per-token colouring (ADR-0022 §3 / ADR-0030 §8) in both - // modes — `render_input_runs_in_mode` runs the highlight - // walker with the active mode so SQL keywords / operators / - // CASE / function calls colour correctly in Advanced mode. let cursor = app.input_cursor.min(app.input.len()); + + // ADR-0027 §4: the rightmost six columns of the input row + // (a five-column label plus a one-column gap) are reserved + // unconditionally, so the first row's text area is always + // `inner.width - 6` and the typed command never shifts sideways + // when the validity indicator appears or hides. A two-row input + // (DA4) lets the *second* row use the full width. + let inner = block.inner(area); + let text_area = Rect { + width: inner.width.saturating_sub(6), + ..inner + }; + + // Per-token colouring (ADR-0022 §3 / ADR-0030 §8) in both modes — + // the highlight walker runs with the active mode so SQL keywords / + // operators / CASE / function calls colour correctly. The cursor + // cell is rendered inverted (an empty-range run) so it is visible + // without a real terminal cursor. let mode_for_render = match effective { EffectiveMode::Simple => crate::mode::Mode::Simple, EffectiveMode::AdvancedPersistent | EffectiveMode::AdvancedOneShot => { crate::mode::Mode::Advanced } }; - let runs = crate::input_render::render_input_runs_in_mode( - &app.input, - cursor, - theme, - &app.schema_cache, - mode_for_render, - ); - let spans = runs_to_spans(&app.input, &runs); - // ADR-0027 §4: the rightmost six columns of the input row - // (a five-column label plus a one-column gap) are reserved - // unconditionally, so the text area is always - // `inner.width - 6` and the typed command never shifts - // sideways when the validity indicator appears or hides. - let inner = block.inner(area); + frame.render_widget(block, area); - let text_area = Rect { - width: inner.width.saturating_sub(6), - ..inner - }; - frame.render_widget(Paragraph::new(Line::from(spans)), text_area); + + // ADR-0046 DA3/DA4: render the single logical line across one row + // (compact terminals) or two (comfortable, height ≥ 40), scrolling + // horizontally in either case so the cursor stays visible. + if inner.height >= 2 { + render_input_two_rows(app, theme, frame, inner, text_area, cursor, mode_for_render); + } else { + render_input_one_row(app, theme, frame, text_area, cursor, mode_for_render); + } if let Some(severity) = app.input_indicator { let (indicator_label, color) = match severity { @@ -1005,6 +1419,190 @@ fn render_input_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec } } +/// One-row input rendering (ADR-0046 DA3): the single logical line is +/// horizontally scrolled so the cursor stays visible, with `<` / `>` +/// markers (muted) at the reserved edge columns signalling hidden +/// content. The offset is stored *before* the highlight spans borrow +/// `app.input`, so the `&mut app` write does not clash. +fn render_input_one_row( + app: &mut App, + theme: &Theme, + frame: &mut Frame<'_>, + text_area: Rect, + cursor: usize, + mode_for_render: crate::mode::Mode, +) { + let line_cols = app.input.chars().count(); + let cursor_col = app.input[..cursor].chars().count(); + let tw = text_area.width as usize; + let offset = input_scroll_offset(line_cols, cursor_col, tw, app.input_scroll_offset); + app.input_scroll_offset = offset; + + let runs = crate::input_render::render_input_runs_in_mode( + &app.input, + cursor, + theme, + &app.schema_cache, + mode_for_render, + ); + let spans = runs_to_spans(&app.input, &runs); + + if line_cols > tw || offset > 0 { + // Overflow: reserve one column each side for `<` / `>` markers, + // render the windowed text between them, then draw the markers + // for whichever side still has hidden content. + let eff = if tw > 2 { tw - 2 } else { 1 }; + let mid = Rect { + x: text_area.x + 1, + width: eff as u16, + ..text_area + }; + frame.render_widget( + Paragraph::new(Line::from(spans)).scroll((0, offset as u16)), + mid, + ); + let marker = Style::default().fg(theme.muted); + if offset > 0 { + frame.render_widget( + Paragraph::new(Span::styled("<", marker)), + Rect { width: 1, ..text_area }, + ); + } + if offset + eff < line_cols { + frame.render_widget( + Paragraph::new(Span::styled(">", marker)), + Rect { + x: text_area.x + text_area.width.saturating_sub(1), + width: 1, + ..text_area + }, + ); + } + } else { + frame.render_widget(Paragraph::new(Line::from(spans)), text_area); + } +} + +/// Two-row input rendering (ADR-0046 DA4): on a comfortable terminal the +/// single logical line is soft-wrapped across two visual rows — the +/// first row stops 6 columns short (the ADR-0027 indicator reserve), the +/// second uses the full width. When the line overflows both rows it +/// scrolls horizontally (one column each side reserved for `<` / `>` +/// markers) so the cursor stays visible. `text_area` is the first +/// (narrower) row; `inner` spans both rows. +fn render_input_two_rows( + app: &mut App, + theme: &Theme, + frame: &mut Frame<'_>, + inner: Rect, + text_area: Rect, + cursor: usize, + mode_for_render: crate::mode::Mode, +) { + let row0_w = text_area.width as usize; // first row reserves the indicator + let row1_w = inner.width as usize; // second row uses the full width + let capacity = row0_w + row1_w; + let line_cols = app.input.chars().count(); + let cursor_col = app.input[..cursor].chars().count(); + let offset = input_scroll_offset(line_cols, cursor_col, capacity, app.input_scroll_offset); + app.input_scroll_offset = offset; + + let runs = crate::input_render::render_input_runs_in_mode( + &app.input, + cursor, + theme, + &app.schema_cache, + mode_for_render, + ); + let cells = expand_runs_to_cells(&app.input, &runs); + let len = cells.len(); + + // Overflowing both rows reserves a marker column on each row's + // outer edge; otherwise both rows use their full text width. + let overflow = line_cols >= capacity; + let row0_text_w = if overflow { row0_w.saturating_sub(1) } else { row0_w }; + let row1_text_w = if overflow { row1_w.saturating_sub(1) } else { row1_w }; + let eff_cap = row0_text_w + row1_text_w; + + let start = offset.min(len); + let end = (offset + eff_cap).min(len); + let window = &cells[start..end]; + let split = row0_text_w.min(window.len()); + let to_line = |cs: &[(String, Style)]| { + Line::from( + cs.iter() + .map(|(s, st)| Span::styled(s.clone(), *st)) + .collect::>(), + ) + }; + + let row0_x = if overflow { text_area.x + 1 } else { text_area.x }; + frame.render_widget( + Paragraph::new(to_line(&window[..split])), + Rect { + x: row0_x, + y: inner.y, + width: row0_text_w as u16, + height: 1, + }, + ); + frame.render_widget( + Paragraph::new(to_line(&window[split..])), + Rect { + x: inner.x, + y: inner.y + 1, + width: row1_text_w as u16, + height: 1, + }, + ); + + let marker = Style::default().fg(theme.muted); + if overflow && offset > 0 { + frame.render_widget( + Paragraph::new(Span::styled("<", marker)), + Rect { + x: text_area.x, + y: inner.y, + width: 1, + height: 1, + }, + ); + } + if overflow && end < len { + frame.render_widget( + Paragraph::new(Span::styled(">", marker)), + Rect { + x: inner.x + inner.width.saturating_sub(1), + y: inner.y + 1, + width: 1, + height: 1, + }, + ); + } +} + +/// Expand styled runs into one owned `(grapheme, style)` cell per +/// display column, including the inverted cursor cell (ADR-0046 DA4). +/// The two-row renderer places cells across two visual rows and so +/// needs them individually rather than as byte-range spans. +fn expand_runs_to_cells( + input: &str, + runs: &[crate::input_render::StyledRun], +) -> Vec<(String, Style)> { + let mut cells = Vec::new(); + for r in runs { + if r.byte_range.0 == r.byte_range.1 { + // Cursor sentinel (empty range) → inverted space cell. + cells.push((" ".to_string(), r.style)); + } else { + for ch in input[r.byte_range.0..r.byte_range.1].chars() { + cells.push((ch.to_string(), r.style)); + } + } + } + cells +} + /// Convert `StyledRun`s into ratatui `Span`s borrowed from /// `input`. The end-of-input cursor sentinel (empty range) is /// rendered as an inverted space. @@ -1041,11 +1639,11 @@ fn strip_one_shot_prefix(input: &str, cursor: usize) -> (&str, usize) { } /// Resolve the Hint panel body into its rendered lines, pre-wrapped -/// to the panel's inner width and clamped to `MAX_HINT_ROWS` with an -/// ellipsis backstop (issue #12). The returned line count is the -/// content-row count `render_right_column` allocates for, so the -/// panel grows for a long hint and reclaims the space for a short -/// one. +/// to the panel's inner width and clamped to `max_rows` with an +/// ellipsis backstop (issue #12). `max_rows` is the geometry-fixed row +/// budget chosen by `hint_rows` (ADR-0046 DA1/DA2); the panel does not +/// resize to the hint, so a short hint simply leaves the spare rows +/// blank and a long one is ellipsized at the budget. /// /// Resolution order for the body: /// 1. An explicit app-set hint (e.g. modal contexts) wins. @@ -1064,11 +1662,16 @@ fn strip_one_shot_prefix(input: &str, cursor: usize) -> (&str, usize) { /// mode-aware walker (ADR-0030/0031/0032); the walker now speaks /// SQL, so `ambient_hint_in_mode` surfaces SQL slot hints + /// completion candidates in advanced mode too. -fn resolve_hint_lines(app: &App, theme: &Theme, area_width: u16) -> Vec> { +fn resolve_hint_lines( + app: &App, + theme: &Theme, + area_width: u16, + max_rows: usize, +) -> Vec> { let inner = area_width.saturating_sub(2) as usize; let muted = Style::default().fg(theme.muted); let prose = |text: &str| { - clamp_wrapped(text, inner, MAX_HINT_ROWS) + clamp_wrapped(text, inner, max_rows) .into_iter() .map(|l| Line::from(Span::styled(l, muted))) .collect::>>() @@ -1734,9 +2337,10 @@ mod tests { #[test] fn long_prose_hint_shows_tail_across_multiple_rows() { - // Before the fix the Hint panel was a fixed 1 content row, - // so this hint's useful tail was clipped. Now the panel - // grows (to MAX_HINT_ROWS) so the tail is visible. + // A multi-row hint panel (here 2 rows at a compact 80×20) shows + // the hint's useful tail rather than clipping it to one row. + // (Pre-#12 the panel was a fixed 1 row; ADR-0046 keeps it + // multi-row but now sizes by geometry, not content.) let mut app = App::new(); app.hint = Some(LONG_HINT.to_string()); let theme = Theme::dark(); @@ -1748,37 +2352,225 @@ mod tests { } #[test] - fn short_hint_keeps_panel_at_one_content_row() { - // Reclaim: a short hint must not inflate the panel. - let mut app = App::new(); - app.hint = Some("Type a command".to_string()); + fn hint_panel_height_is_fixed_by_geometry_not_content() { + // ADR-0046 DA1/DA2 (#20): the panel no longer shrinks to a + // short hint (the issue #12 "reclaim" behaviour is deliberately + // reversed). At a compact (height < 40) terminal it is a fixed + // 2 content rows whether the hint is short or long, so it never + // resizes mid-typing and shoves the input/output panels. let theme = Theme::dark(); - let out = render_to_string(&mut app, &theme, 80, 20); + + let mut short = App::new(); + short.hint = Some("Type a command".to_string()); + let short_out = render_to_string(&mut short, &theme, 80, 20); assert!( - out.lines().any(|l| l.contains("Type a command")), - "short hint visible:\n{out}" + short_out.lines().any(|l| l.contains("Type a command")), + "short hint visible:\n{short_out}" + ); + + let mut long = App::new(); + long.hint = Some(LONG_HINT.to_string()); + let long_out = render_to_string(&mut long, &theme, 80, 20); + + assert_eq!( + hint_content_rows(&short_out), + 2, + "compact terminal fixes the hint at 2 rows:\n{short_out}" ); assert_eq!( - hint_content_rows(&out), - 1, - "short hint should occupy exactly one content row:\n{out}" + hint_content_rows(&short_out), + hint_content_rows(&long_out), + "the hint panel height must not differ between a short and a \ + long hint at the same terminal size (#20 anti-jump):\n\ + short:\n{short_out}\nlong:\n{long_out}" ); } #[test] - fn long_hint_grows_panel_but_caps_at_max_rows() { + fn narrow_comfortable_terminal_allows_a_third_hint_row() { + // ADR-0046 DA2: a 3rd hint row appears only on a comfortable + // (height ≥ 40) terminal whose hint column is narrow enough + // (inner < 54) to wrap the longest hint past two lines; the + // ellipsis backstop still caps it at MAX_HINT_ROWS. (At a + // compact height the same hint is held to 2 rows.) let mut app = App::new(); app.hint = Some(LONG_HINT.to_string()); let theme = Theme::dark(); - // Narrow width forces more wrapped lines than the cap. - let out = render_to_string(&mut app, &theme, 44, 20); + let out = render_to_string(&mut app, &theme, 44, 50); assert_eq!( hint_content_rows(&out), MAX_HINT_ROWS, - "long hint caps at MAX_HINT_ROWS content rows:\n{out}" + "narrow + tall terminal caps the long hint at MAX_HINT_ROWS \ + content rows:\n{out}" ); } + #[test] + fn panel_heights_are_geometry_driven() { + // ADR-0046 DA1/DA2/DA4: the pure helper the renderer and these + // tests share. Height picks the bucket (input 1→2, hint floor); + // width gates the hint's 3rd row; a tiny terminal degrades hint + // then input to protect output `Min(5)`. + let at = |w: u16, h: u16| panel_heights(Rect::new(0, 0, w, h)); + // Compact height → input 1, hint 2, regardless of width. + assert_eq!(at(90, 25), (1, 2)); + assert_eq!(at(40, 25), (1, 2)); + // Comfortable height → input 2; hint 2 when wide (inner ≥ 54). + assert_eq!(at(90, 45), (2, 2)); + assert_eq!(at(56, 45), (2, 2)); // inner == 54 is "wide enough" + // Comfortable + narrow (inner < 54) → hint 3. + assert_eq!(at(55, 45), (2, 3)); // inner == 53 + assert_eq!(at(50, 45), (2, 3)); + // Very short terminal degrades hint first, then input, to keep + // the output panel's Min(5). + assert_eq!(at(90, 11), (1, 1)); + } + + // ---- ADR-0046 DA3: input horizontal scroll ------------------- + + #[test] + fn input_scroll_offset_keeps_the_cursor_in_view() { + // Fits (line shorter than the viewport) → never scrolls. + assert_eq!(input_scroll_offset(10, 10, 20, 0), 0); + assert_eq!(input_scroll_offset(19, 19, 20, 5), 0); + // Overflow, cursor at end → window shows the tail, reserving the + // two marker columns (eff = tw - 2 = 18): 50 + 1 - 18 = 33. + assert_eq!(input_scroll_offset(50, 50, 20, 0), 33); + // Cursor jumped left of the window → scroll left to the cursor. + assert_eq!(input_scroll_offset(50, 5, 20, 33), 5); + // Cursor still inside the current window → stable, no change. + assert_eq!(input_scroll_offset(50, 40, 20, 33), 33); + // Never scroll past the cursor-at-end cell, even from a stale + // over-large offset. + assert_eq!(input_scroll_offset(50, 50, 20, 999), 33); + } + + const LONG_INPUT: &str = + "select * from Customers where id = 12345 and name = 'Alice Wonderland'"; + + #[test] + fn long_input_scrolls_to_keep_the_tail_and_cursor_visible() { + // #23: a command longer than the input field must not clip the + // cursor off the right edge — it scrolls so the tail is visible, + // with a `<` marker for the hidden head. + let mut app = App::new(); + app.input.push_str(LONG_INPUT); + app.input_cursor = app.input.len(); + let theme = Theme::dark(); + // Narrow (sidebar hidden, DB1) so the line overflows the field. + let out = render_to_string(&mut app, &theme, 60, 24); + assert!( + out.contains("'Alice Wonderland'"), + "the tail around the cursor must be visible:\n{out}" + ); + assert!( + !out.lines().any(|l| l.contains("select * from Customers where")), + "the head must be scrolled off:\n{out}" + ); + assert!(out.contains('<'), "a left scroll marker signals the hidden head:\n{out}"); + } + + #[test] + fn input_at_home_shows_the_head_with_a_right_marker() { + // With the cursor at Home, the head is visible and a `>` marker + // signals the hidden tail. + let mut app = App::new(); + app.input.push_str(LONG_INPUT); + app.input_cursor = 0; + let theme = Theme::dark(); + // Narrow (sidebar hidden, DB1) so the line overflows the field. + let out = render_to_string(&mut app, &theme, 60, 24); + assert!(out.contains("select * from"), "head visible at Home:\n{out}"); + assert!(out.contains('>'), "a right scroll marker signals the hidden tail:\n{out}"); + assert!(!out.contains("Wonderland"), "the tail must be scrolled off:\n{out}"); + } + + // ---- ADR-0046 DA4: two-row input on tall terminals ----------- + + #[test] + fn comfortable_terminal_wraps_input_across_two_rows() { + // On a tall (height ≥ 40) terminal the input shows two rows, so + // a medium command wraps instead of scrolling — the whole + // command is visible at once, head above tail. + let mut app = App::new(); + app.input.push_str(LONG_INPUT); + app.input_cursor = app.input.len(); + let theme = Theme::dark(); + // Narrow (sidebar hidden, DB1) so the line wraps across the two + // rows rather than fitting on the first. + let out = render_to_string(&mut app, &theme, 60, 44); + let head = out + .lines() + .position(|l| l.contains("select * from Customers")); + let tail = out.lines().position(|l| l.contains("'Alice Wonderland'")); + assert!( + head.is_some() && tail.is_some(), + "both head and tail are visible across two rows:\n{out}" + ); + assert!( + tail.unwrap() > head.unwrap(), + "the tail wraps onto a row below the head:\n{out}" + ); + } + + #[test] + fn two_row_input_scrolls_when_it_overflows_both_rows() { + // A narrow-but-tall terminal: two rows, but the line is longer + // than both can hold, so it scrolls to keep the tail/cursor + // visible with a `<` marker for the hidden head. + let mut app = App::new(); + app.input.push_str(LONG_INPUT); + app.input_cursor = app.input.len(); + let theme = Theme::dark(); + // Very narrow + tall: two rows, but the line exceeds both. + let out = render_to_string(&mut app, &theme, 38, 44); + assert!(out.contains("Wonderland"), "the tail/cursor stays visible:\n{out}"); + assert!(out.contains('<'), "a left marker signals the hidden head:\n{out}"); + } + + #[test] + fn two_row_input_keeps_the_indicator_on_the_first_row() { + // ADR-0046 DA4 / ADR-0027: the [ERR]/[WRN] indicator stays + // anchored to the *first* input row (whose 6-column reserve it + // occupies); the wrapped tail on the second row is untouched. + let mut app = App::new(); + app.input.push_str(LONG_INPUT); + app.input_cursor = app.input.len(); + app.input_indicator = Some(crate::dsl::walker::Severity::Error); + let theme = Theme::dark(); + // Narrow (sidebar hidden, DB1) so the line wraps across two rows. + let out = render_to_string(&mut app, &theme, 60, 44); + let err_line = out + .lines() + .position(|l| l.contains("[ERR]")) + .expect("indicator visible"); + let head_line = out + .lines() + .position(|l| l.contains("select * from Customers")) + .expect("head visible"); + assert_eq!( + err_line, head_line, + "the indicator shares the first input row with the head:\n{out}" + ); + assert!( + out.contains("'Alice Wonderland'"), + "the wrapped tail on the second row is intact:\n{out}" + ); + } + + #[test] + fn two_row_input_snapshot() { + // Locks the DA4 two-row layout: head on the first (indicator- + // reserved) row, tail on the full-width second row. + let mut app = App::new(); + app.input.push_str(LONG_INPUT); + app.input_cursor = app.input.len(); + let theme = Theme::dark(); + // Narrow (sidebar hidden, DB1) so the command wraps across rows. + let snapshot = render_to_string(&mut app, &theme, 60, 44); + insta::assert_snapshot!("two_row_input_dark", snapshot); + } + /// Count the content rows inside the Hint panel of a rendered /// screen: the rows between the `╭ Hint …` title border and the /// next `╰…╯` bottom border. @@ -2097,7 +2889,9 @@ mod tests { }); let theme = Theme::dark(); - let snapshot = render_to_string(&mut app, &theme, 80, 24); + // Width > SIDEBAR_MIN_WIDTH so the sidebar (tables list) shows + // alongside the output panel (DB1). + let snapshot = render_to_string(&mut app, &theme, 110, 24); insta::assert_snapshot!("populated_with_table_dark", snapshot); } @@ -2117,10 +2911,301 @@ mod tests { ], ); let theme = Theme::dark(); - let out = render_to_string(&mut app, &theme, 80, 24); + // Width > SIDEBAR_MIN_WIDTH so the sidebar is shown (DB1). + let out = render_to_string(&mut app, &theme, 110, 24); assert!(out.contains("Customers"), "table listed:\n{out}"); assert!(out.contains("Orders"), "table listed:\n{out}"); assert!(out.contains("idx_email"), "index nested in panel:\n{out}"); assert!(out.contains("uidx_login [unique]"), "unique index marked:\n{out}"); } + + #[test] + fn sidebar_visible_is_width_gated() { + // ADR-0046 DB1: shown above SIDEBAR_MIN_WIDTH, hidden at/below. + assert!(!sidebar_visible(80)); + assert!(!sidebar_visible(90)); // the 90-col screencast: hidden + assert!(sidebar_visible(91)); + assert!(sidebar_visible(120)); + } + + #[test] + fn sidebar_hidden_at_or_below_threshold_width() { + // The Tables panel disappears at a narrow width (the output + // panel then spans the full width) and returns when wide. + let mut app = App::new(); + app.tables = vec!["Customers".to_string()]; + let theme = Theme::dark(); + let narrow = render_to_string(&mut app, &theme, 80, 24); + assert!(!narrow.contains("Tables"), "sidebar hidden at 80 wide:\n{narrow}"); + let wide = render_to_string(&mut app, &theme, 110, 24); + assert!(wide.contains("Tables"), "sidebar shown at 110 wide:\n{wide}"); + assert!(wide.contains("Customers"), "tables listed when shown:\n{wide}"); + } + + #[test] + fn relationships_panel_height_is_content_sized_within_bounds() { + // ADR-0046 DB4: empty floors at 5; grows with content; capped at + // half the column; leaves the Tables panel at least 3 rows. + assert_eq!(relationships_panel_height(40, 0), 5); // empty floor + assert_eq!(relationships_panel_height(40, 6), 8); // 6 content + borders + assert_eq!(relationships_panel_height(40, 30), 20); // capped at half + assert_eq!(relationships_panel_height(7, 0), 3); // tiny: Tables keeps 3 + } + + fn one_relationship() -> crate::persistence::RelationshipSchema { + use crate::dsl::action::ReferentialAction; + crate::persistence::RelationshipSchema { + name: "Customers_Orders".to_string(), + parent_table: "Customers".to_string(), + parent_columns: vec!["id".to_string()], + child_table: "Orders".to_string(), + child_columns: vec!["customer_id".to_string()], + on_delete: ReferentialAction::Cascade, + on_update: ReferentialAction::Cascade, + } + } + + #[test] + fn relationships_panel_lists_each_relationship() { + // ADR-0046 DB2: name, then endpoints broken at the arrow. + let mut app = App::new(); + app.tables = vec!["Customers".to_string(), "Orders".to_string()]; + app.relationships = vec![one_relationship()]; + let theme = Theme::dark(); + let out = render_to_string(&mut app, &theme, 110, 24); + assert!(out.contains("Relationships"), "panel title present:\n{out}"); + assert!(out.contains("Customers_Orders"), "relationship name:\n{out}"); + assert!( + out.lines().any(|l| l.contains("Customers.id ->")), + "parent endpoint, broken at the arrow:\n{out}" + ); + assert!( + out.lines().any(|l| l.contains("Orders.customer_id")), + "child endpoint, indented:\n{out}" + ); + } + + #[test] + fn empty_relationships_panel_shows_none() { + let mut app = App::new(); + app.tables = vec!["Customers".to_string()]; + let theme = Theme::dark(); + let out = render_to_string(&mut app, &theme, 110, 24); + assert!(out.contains("Relationships"), "panel title present:\n{out}"); + assert!(out.contains("(none)"), "empty placeholder:\n{out}"); + } + + #[test] + fn relationships_panel_snapshot() { + let mut app = App::new(); + app.tables = vec!["Customers".to_string(), "Orders".to_string()]; + app.relationships = vec![one_relationship()]; + let theme = Theme::dark(); + let snapshot = render_to_string(&mut app, &theme, 110, 24); + insta::assert_snapshot!("relationships_panel_dark", snapshot); + } + + #[test] + fn navigation_mode_reveals_and_expands_the_sidebar() { + // ADR-0046 DC1/DC2: at a narrow width the sidebar is hidden, but + // focusing a sidebar panel peeks it open as an expanded overlay. + let mut app = App::new(); + app.tables = vec!["Customers".to_string()]; + app.relationships = vec![one_relationship()]; + let theme = Theme::dark(); + let normal = render_to_string(&mut app, &theme, 80, 24); + assert!( + !normal.contains("Tables"), + "sidebar hidden at 80 wide when not browsing:\n{normal}" + ); + + app.nav_focus = NavFocus::SidebarTables; + let focused = render_to_string(&mut app, &theme, 80, 24); + assert!(focused.contains("Tables"), "sidebar revealed in nav mode:\n{focused}"); + assert!(focused.contains("Customers"), "tables in the overlay:\n{focused}"); + assert!( + focused.contains("Relationships"), + "relationships panel in the overlay:\n{focused}" + ); + assert!( + focused.contains("Customers_Orders"), + "relationship listed in the overlay:\n{focused}" + ); + } + + #[test] + fn focused_panel_gets_an_accent_border() { + // ADR-0046 DC3: the focused sidebar panel is accent-bordered. + 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!(normal.fg, Some(theme.border)); + assert!(!normal.add_modifier.contains(Modifier::BOLD)); + } + + #[test] + fn focused_tables_panel_scrolls_and_clamps() { + // ADR-0046 DC3: more tables than fit → a large offset reveals the + // lower entries and clamps so it can't scroll past the end. + let mut app = App::new(); + app.tables = (0..30).map(|i| format!("Table{i:02}")).collect(); + app.nav_focus = NavFocus::SidebarTables; + app.tables_scroll = 1000; // way past the end + let theme = Theme::dark(); + let out = render_to_string(&mut app, &theme, 80, 24); + assert!( + out.contains("Table29"), + "the last table is visible after the offset clamps:\n{out}" + ); + assert!( + !out.contains("Table00"), + "the top tables are scrolled off:\n{out}" + ); + assert!( + app.tables_scroll < 30, + "the stored offset was clamped to the content: {}", + app.tables_scroll + ); + } + + #[test] + fn navigation_overlay_snapshot() { + // The expanded overlay over a full-width base (sidebar hidden at + // 80), with the Relationships panel focused (accent border). + let mut app = App::new(); + app.tables = vec!["Customers".to_string(), "Orders".to_string()]; + app.relationships = vec![one_relationship()]; + app.nav_focus = NavFocus::SidebarRelationships; + let theme = Theme::dark(); + let snapshot = render_to_string(&mut app, &theme, 80, 24); + insta::assert_snapshot!("nav_overlay_relationships_focused_dark", snapshot); + } + + // ---- ADR-0047 (issue #22): demo-mode keystroke badge ---- + + /// Render to a `TestBackend` buffer (for cell-level style checks the + /// text-only `render_to_string` cannot make). + fn render_to_buffer( + app: &mut App, + theme: &Theme, + width: u16, + height: u16, + ) -> ratatui::buffer::Buffer { + if app.project_name.is_none() { + app.project_name = Some("Term Planner".to_string()); + } + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).expect("create terminal"); + terminal.draw(|f| render(app, theme, f)).expect("draw frame"); + terminal.backend().buffer().clone() + } + + #[test] + fn demo_badge_box_renders_at_output_bottom_right() { + // At the 90×26 cast geometry the sidebar is hidden and the badge + // box sits inset in the output panel's bottom-right corner. + let mut app = App::new(); + app.demo_mode = true; + app.demo_badge = Some("[TAB]"); + let theme = Theme::dark(); + let snapshot = render_to_string(&mut app, &theme, 90, 26); + insta::assert_snapshot!("demo_badge_tab_dark_90x26", snapshot); + } + + #[test] + fn demo_badge_box_renders_in_light_theme() { + let mut app = App::new(); + app.demo_mode = true; + app.demo_badge = Some("[ENTER]"); + let theme = Theme::light(); + let snapshot = render_to_string(&mut app, &theme, 90, 26); + insta::assert_snapshot!("demo_badge_enter_light_90x26", snapshot); + } + + #[test] + fn demo_badge_box_is_black_on_yellow() { + let mut app = App::new(); + app.demo_mode = true; + app.demo_badge = Some("[TAB]"); + let theme = Theme::dark(); + let buffer = render_to_buffer(&mut app, &theme, 90, 26); + // Collect the badge cells (the only ones painted with the fixed + // overlay background) and confirm the high-contrast pairing. + let mut badge_cells = 0; + let mut row_text: std::collections::BTreeMap = Default::default(); + for y in 0..buffer.area.height { + for x in 0..buffer.area.width { + let cell = &buffer[(x, y)]; + if cell.bg == crate::theme::DEMO_OVERLAY_BG { + badge_cells += 1; + assert_eq!( + cell.fg, + crate::theme::DEMO_OVERLAY_FG, + "badge cell at ({x},{y}) must be black-on-yellow" + ); + row_text.entry(y).or_default().push_str(cell.symbol()); + } + } + } + assert!(badge_cells > 0, "expected a yellow badge box to be drawn"); + // The label appears on the box's middle (text) row. + assert!( + row_text.values().any(|line| line.contains("[TAB]")), + "badge text not found among styled rows: {row_text:?}" + ); + } + + #[test] + fn demo_caption_box_renders_at_output_bottom_right() { + let mut app = App::new(); + app.demo_mode = true; + app.demo_caption = Some("Now press Tab to complete the table name".to_string()); + let theme = Theme::dark(); + let snapshot = render_to_string(&mut app, &theme, 90, 26); + insta::assert_snapshot!("demo_caption_dark_90x26", snapshot); + } + + #[test] + fn demo_badge_stacks_above_caption() { + let mut app = App::new(); + app.demo_mode = true; + app.demo_badge = Some("[TAB]"); + app.demo_caption = Some("Completing the name".to_string()); + let theme = Theme::dark(); + let snapshot = render_to_string(&mut app, &theme, 90, 26); + insta::assert_snapshot!("demo_badge_and_caption_stacked_90x26", snapshot); + } + + #[test] + fn demo_caption_wraps_to_three_lines_and_ellipsises() { + let mut app = App::new(); + app.demo_mode = true; + app.demo_caption = Some( + "This is a deliberately long step caption that must wrap onto \ + several lines and then be clipped to three with an ellipsis \ + so the corner box never grows without bound." + .to_string(), + ); + let theme = Theme::dark(); + let snapshot = render_to_string(&mut app, &theme, 90, 26); + insta::assert_snapshot!("demo_caption_wrapped_90x26", snapshot); + } + + #[test] + fn demo_badge_box_skipped_when_area_too_small() { + // ADR-0047 D4 clamp guard: a box that cannot fit the given area + // is not drawn rather than overflowing. + let backend = TestBackend::new(40, 10); + let mut terminal = Terminal::new(backend).expect("create terminal"); + terminal + .draw(|f| super::render_badge_box("[SHIFT-TAB]", Rect::new(0, 0, 5, 3), None, f)) + .expect("draw frame"); + let buffer = terminal.backend().buffer(); + let drew_badge = (0..buffer.area.height).any(|y| { + (0..buffer.area.width).any(|x| buffer[(x, y)].bg == crate::theme::DEMO_OVERLAY_BG) + }); + assert!(!drew_badge, "badge must be skipped when it cannot fit"); + } } diff --git a/tests/it/compound_fk.rs b/tests/it/compound_fk.rs index e8b539f..3ca22df 100644 --- a/tests/it/compound_fk.rs +++ b/tests/it/compound_fk.rs @@ -137,6 +137,7 @@ fn sql_create_table_compound_fk_executes_and_enforces() { parent_columns: Some(vec!["country".to_string(), "code".to_string()]), on_delete: ReferentialAction::NoAction, on_update: ReferentialAction::NoAction, + inline: false, }], false, None, @@ -363,6 +364,65 @@ fn compound_fk_arity_mismatch_is_refused() { }); } +#[test] +fn inline_fk_referencing_compound_pk_points_at_table_level_form() { + // ADR-0043 D4 residual: an *inline* single-column FK cannot express a + // multi-column reference, so referencing a parent's compound PK must + // refuse with a pointer to the table-level `FOREIGN KEY (...)` form — + // not the generic arity message. The grammar marks the FK `inline`. + let (_p, db, _dir) = open_project_db(); + let rt = rt(); + rt.block_on(async { + db.create_table( + "Region".to_string(), + vec![ + ColumnSpec::new("country", Type::Int), + ColumnSpec::new("code", Type::Int), + ], + vec!["country".to_string(), "code".to_string()], + None, + ) + .await + .expect("create Region"); + + // Parse the inline form so the `inline` flag is set by the grammar. + let cmd = parse_command( + "create table City (country int references Region(country, code))", + ) + .expect("parses"); + let Command::SqlCreateTable { + name, + columns, + primary_key, + unique_constraints, + check_constraints, + foreign_keys, + if_not_exists, + } = cmd + else { + panic!("expected SqlCreateTable"); + }; + let err = db + .sql_create_table( + name, + columns, + primary_key, + unique_constraints, + check_constraints, + foreign_keys, + if_not_exists, + None, + ) + .await + .expect_err("inline FK referencing a compound PK must be refused"); + let msg = format!("{err}"); + assert!( + msg.contains("FOREIGN KEY"), + "expected a pointer to the table-level `FOREIGN KEY (...)` form, got: {msg}" + ); + }); +} + #[test] fn compound_fk_type_mismatch_per_pair_is_refused() { let (_p, db, _dir) = open_project_db(); diff --git a/tests/it/friendly_enrichment.rs b/tests/it/friendly_enrichment.rs index ff289c9..30c46b0 100644 --- a/tests/it/friendly_enrichment.rs +++ b/tests/it/friendly_enrichment.rs @@ -464,6 +464,81 @@ fn enrich_fk_insert_resolves_parent_table_column_and_value() { }); } +#[test] +fn enrich_fk_insert_compound_names_every_column_pair() { + // ADR-0043 residual: a compound-FK violation must name *every* + // child->parent column pair, not just the first. The single-column + // facts slots carry the comma-joined lists. + let db = db(); + rt().block_on(async { + db.create_table( + "Region".to_string(), + vec![ + ColumnSpec::new("country".to_string(), Type::Int), + ColumnSpec::new("code".to_string(), Type::Int), + ], + vec!["country".to_string(), "code".to_string()], + None, + ) + .await + .unwrap(); + db.create_table( + "City".to_string(), + vec![ + ColumnSpec::new("country".to_string(), Type::Int), + ColumnSpec::new("region_code".to_string(), Type::Int), + ], + vec![], + None, + ) + .await + .unwrap(); + db.add_relationship( + None, + "Region".to_string(), + vec!["country".to_string(), "code".to_string()], + "City".to_string(), + vec!["country".to_string(), "region_code".to_string()], + ReferentialAction::NoAction, + ReferentialAction::NoAction, + false, + None, + ) + .await + .unwrap(); + + // Insert a City whose (country, region_code) has no parent Region. + let cmd = Command::Insert { + table: "City".to_string(), + columns: Some(vec!["country".to_string(), "region_code".to_string()]), + values: vec![ + Value::Number("7".to_string()), + Value::Number("8".to_string()), + ], + }; + let err = db + .insert( + "City".to_string(), + Some(vec!["country".to_string(), "region_code".to_string()]), + vec![ + Value::Number("7".to_string()), + Value::Number("8".to_string()), + ], + None, + ) + .await + .unwrap_err(); + + let facts = enrich_dsl_failure(&db, &cmd, &err).await; + assert_eq!(facts.table.as_deref(), Some("City")); + assert_eq!(facts.parent_table.as_deref(), Some("Region")); + // Both pairs named, not just the first. + assert_eq!(facts.column.as_deref(), Some("country, region_code")); + assert_eq!(facts.parent_column.as_deref(), Some("country, code")); + assert_eq!(facts.value.as_deref(), Some("7, 8")); + }); +} + #[test] fn enrich_fk_insert_natural_order_multi_value_resolves_via_schema() { // Regression: `insert into Orders values (4, 11.99)` — diff --git a/tests/it/iteration4b_lifecycle_commands.rs b/tests/it/iteration4b_lifecycle_commands.rs index a323799..bcd15dd 100644 --- a/tests/it/iteration4b_lifecycle_commands.rs +++ b/tests/it/iteration4b_lifecycle_commands.rs @@ -252,6 +252,91 @@ fn load_picker_renders_entries_and_navigates() { assert_eq!(source, "load"); } +/// Build a load picker with three entries for the vi-navigation tests. +fn three_entry_picker() -> App { + let mut app = App::new(); + app.update(AppEvent::LoadPickerReady { + entries: vec![ + LoadPickerEntry { + display_name: "First".to_string(), + modified: "2026-05-07 14:30".to_string(), + path: std::path::PathBuf::from("/tmp/first"), + is_temp: true, + }, + LoadPickerEntry { + display_name: "Second".to_string(), + modified: "2026-05-05 10:00".to_string(), + path: std::path::PathBuf::from("/tmp/second"), + is_temp: false, + }, + LoadPickerEntry { + display_name: "Third".to_string(), + modified: "2026-05-01 09:15".to_string(), + path: std::path::PathBuf::from("/tmp/third"), + is_temp: false, + }, + ], + }); + app +} + +fn picker_selected(app: &App) -> usize { + let Some(Modal::LoadPicker(picker)) = app.modal.as_ref() else { + panic!("expected LoadPicker modal"); + }; + picker.selected +} + +#[test] +fn load_picker_jk_navigates_like_arrows() { + // vi-style j/k mirror Down/Up so autocast (typeable keys only) can drive + // the load picker in documentation casts (#24). + let mut app = three_entry_picker(); + assert_eq!(picker_selected(&app), 0); + + // j moves the selection down. + app.update(key(KeyCode::Char('j'))); + assert_eq!(picker_selected(&app), 1); + app.update(key(KeyCode::Char('j'))); + assert_eq!(picker_selected(&app), 2); + + // j at the last entry does not wrap past the end. + app.update(key(KeyCode::Char('j'))); + assert_eq!(picker_selected(&app), 2); + + // k moves the selection up. + app.update(key(KeyCode::Char('k'))); + assert_eq!(picker_selected(&app), 1); + + // k at the first entry does not wrap past the start. + app.update(key(KeyCode::Char('k'))); + assert_eq!(picker_selected(&app), 0); + app.update(key(KeyCode::Char('k'))); + assert_eq!(picker_selected(&app), 0); +} + +#[test] +fn load_picker_g_jumps_to_first_and_last() { + // g → first entry, G → last entry (vi convention). + let mut app = three_entry_picker(); + + // G jumps to the last entry from the top. + app.update(key(KeyCode::Char('G'))); + assert_eq!(picker_selected(&app), 2); + + // G again is idempotent at the end. + app.update(key(KeyCode::Char('G'))); + assert_eq!(picker_selected(&app), 2); + + // g jumps back to the first entry. + app.update(key(KeyCode::Char('g'))); + assert_eq!(picker_selected(&app), 0); + + // g again is idempotent at the start. + app.update(key(KeyCode::Char('g'))); + assert_eq!(picker_selected(&app), 0); +} + #[test] fn load_picker_b_enters_path_entry_submode() { let mut app = App::new(); diff --git a/tests/it/m2n.rs b/tests/it/m2n.rs new file mode 100644 index 0000000..972a8b3 --- /dev/null +++ b/tests/it/m2n.rs @@ -0,0 +1,455 @@ +//! Integration tests for the m:n convenience command (C4 / ADR-0045): +//! `create m:n relationship from to [as ]`. +//! +//! Covers parse, junction generation (columns / compound PK / two +//! enforced FKs), the `as ` override, a compound-PK parent, +//! CASCADE delete, one-undo-step, self-m:n refusal, and the PK-less +//! parent guard. + +use rdbms_playground::db::Database; +use rdbms_playground::dsl::command::RowFilter; +use rdbms_playground::dsl::{parse_command, ColumnSpec, Command, Type, Value}; +use rdbms_playground::persistence::Persistence; +use rdbms_playground::project::{self, PLAYGROUND_DB}; + +fn rt() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio rt") +} + +fn open() -> (project::Project, Database, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("tempdir"); + let project = project::open_or_create(None, Some(dir.path())).expect("project"); + let db = Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf())) + .expect("db"); + (project, db, dir) +} + +fn open_with_undo() -> (project::Project, Database, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("tempdir"); + let project = project::open_or_create(None, Some(dir.path())).expect("project"); + let db = Database::open_with_persistence_and_undo( + project.db_path(), + Persistence::new(project.path().to_path_buf()), + true, + ) + .expect("db"); + (project, db, dir) +} + +/// A parent table `(id serial PK, label text)` — the `label` gives an +/// insertable non-PK column (a serial-PK-only table has nothing to put +/// in a short-form INSERT). +async fn serial_pk_table(db: &Database, name: &str) { + db.create_table( + name.to_string(), + vec![ColumnSpec::new("id", Type::Serial), ColumnSpec::new("label", Type::Text)], + vec!["id".to_string()], + None, + ) + .await + .unwrap_or_else(|e| panic!("create {name}: {e}")); +} + +/// Insert one row into a `serial_pk_table`, returning its auto-assigned id. +async fn add_row(db: &Database, table: &str, label: &str) { + db.insert( + table.to_string(), + Some(vec!["label".to_string()]), + vec![Value::Text(label.to_string())], + None, + ) + .await + .unwrap_or_else(|e| panic!("insert into {table}: {e}")); +} + +// ---- parse layer ----------------------------------------------- + +#[test] +fn parses_to_create_m2n_relationship() { + match parse_command("create m:n relationship from Students to Courses").expect("parses") { + Command::CreateM2nRelationship { t1, t2, name } => { + assert_eq!(t1, "Students"); + assert_eq!(t2, "Courses"); + assert_eq!(name, None); + } + other => panic!("expected CreateM2nRelationship, got {other:?}"), + } +} + +#[test] +fn parses_with_as_name() { + match parse_command("create m:n relationship from Students to Courses as Enrollments") + .expect("parses") + { + Command::CreateM2nRelationship { name, .. } => assert_eq!(name.as_deref(), Some("Enrollments")), + other => panic!("expected CreateM2nRelationship, got {other:?}"), + } +} + +// ---- junction generation --------------------------------------- + +#[test] +fn generates_junction_with_compound_pk_and_two_enforced_fks() { + let (_p, db, _d) = open(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + serial_pk_table(&db, "Courses").await; + + db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None) + .await + .expect("create m:n"); + + // Auto-named `Students_Courses` exists. + let tables = db.list_tables().await.unwrap(); + 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 cols: Vec<(&str, bool)> = + desc.columns.iter().map(|c| (c.name.as_str(), c.primary_key)).collect(); + assert_eq!( + cols, + vec![("Students_id", true), ("Courses_id", true)], + "expected two FK columns forming the compound PK" + ); + // Two outbound relationships (one per parent). + assert_eq!(desc.outbound_relationships.len(), 2, "expected two FKs"); + + // FK enforcement: a junction row needs existing parents. + add_row(&db, "Students", "s1").await; + add_row(&db, "Courses", "c1").await; + db.insert( + "Students_Courses".to_string(), + Some(vec!["Students_id".to_string(), "Courses_id".to_string()]), + vec![Value::Number("1".to_string()), Value::Number("1".to_string())], + None, + ) + .await + .expect("valid link"); + // Duplicate link refused by the compound PK. + let dup = db + .insert( + "Students_Courses".to_string(), + Some(vec!["Students_id".to_string(), "Courses_id".to_string()]), + vec![Value::Number("1".to_string()), Value::Number("1".to_string())], + None, + ) + .await; + assert!(dup.is_err(), "duplicate (Students_id, Courses_id) must be refused"); + // A link to a non-existent parent is refused by the FK. + let orphan = db + .insert( + "Students_Courses".to_string(), + Some(vec!["Students_id".to_string(), "Courses_id".to_string()]), + vec![Value::Number("1".to_string()), Value::Number("99".to_string())], + None, + ) + .await; + assert!(orphan.is_err(), "link to a non-existent Course must be refused"); + }); +} + +#[test] +fn as_name_overrides_the_junction_table_name() { + let (_p, db, _d) = open(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + serial_pk_table(&db, "Courses").await; + db.create_m2n_relationship( + "Students".to_string(), + "Courses".to_string(), + Some("Enrollments".to_string()), + None, + ) + .await + .expect("create m:n as Enrollments"); + let tables = db.list_tables().await.unwrap(); + assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}"); + assert!(!tables.contains(&"Students_Courses".to_string())); + }); +} + +#[test] +fn compound_parent_pk_contributes_one_fk_column_each() { + let (_p, db, _d) = open(); + rt().block_on(async { + // Sections has a 2-column PK (course_id, term). + db.create_table( + "Sections".to_string(), + vec![ColumnSpec::new("course_id", Type::Int), ColumnSpec::new("term", Type::Int)], + vec!["course_id".to_string(), "term".to_string()], + None, + ) + .await + .unwrap(); + serial_pk_table(&db, "Students").await; + + db.create_m2n_relationship("Students".to_string(), "Sections".to_string(), None, None) + .await + .expect("create m:n"); + + let desc = db.describe_table("Students_Sections".to_string(), None).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. + assert!(desc.columns.iter().all(|c| c.primary_key), "all columns are PK: {names:?}"); + }); +} + +#[test] +fn deleting_a_parent_cascades_to_the_junction() { + let (_p, db, _d) = open(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + serial_pk_table(&db, "Courses").await; + db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None) + .await + .unwrap(); + add_row(&db, "Students", "s1").await; + add_row(&db, "Courses", "c1").await; + db.insert( + "Students_Courses".to_string(), + Some(vec!["Students_id".to_string(), "Courses_id".to_string()]), + vec![Value::Number("1".to_string()), Value::Number("1".to_string())], + None, + ) + .await + .unwrap(); + + // 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(); + assert!(rows.rows.is_empty(), "junction rows should cascade-delete, got {:?}", rows.rows); + }); +} + +#[test] +fn create_m2n_is_one_undo_step() { + let (_p, db, _d) = open_with_undo(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + serial_pk_table(&db, "Courses").await; + // A real source makes the command undoable (a source-less call is + // treated as an internal, non-undoable op). + db.create_m2n_relationship( + "Students".to_string(), + "Courses".to_string(), + None, + Some("create m:n relationship from Students to Courses".to_string()), + ) + .await + .unwrap(); + assert!(db.list_tables().await.unwrap().contains(&"Students_Courses".to_string())); + + // One undo removes the junction table AND both relationships. + db.undo().await.unwrap(); + 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(); + assert!(students.inbound_relationships.is_empty(), "no leftover relationship after undo"); + }); +} + +// ---- guards ---------------------------------------------------- + +#[test] +fn self_referential_m2n_is_refused() { + let (_p, db, _d) = open(); + rt().block_on(async { + serial_pk_table(&db, "Users").await; + let err = db + .create_m2n_relationship("Users".to_string(), "Users".to_string(), None, None) + .await + .expect_err("self m:n must be refused"); + assert!(format!("{err}").contains("two different tables"), "got: {err}"); + }); +} + +#[test] +fn missing_parent_table_is_refused() { + let (_p, db, _d) = open(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + let err = db + .create_m2n_relationship("Students".to_string(), "Nonexistent".to_string(), None, None) + .await + .expect_err("a missing parent table must be refused"); + // The standard "no such table" guard (require_canonical_table). + assert!(format!("{err}").to_lowercase().contains("no such table"), "got: {err}"); + }); +} + +#[test] +fn junction_name_collision_is_refused() { + let (_p, db, _d) = open(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + serial_pk_table(&db, "Courses").await; + db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None) + .await + .expect("first m:n"); + // A second identical m:n collides on the auto-name `Students_Courses`. + let err = db + .create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None) + .await + .expect_err("a junction-name collision must be refused"); + assert!(format!("{err}").to_lowercase().contains("exist"), "got: {err}"); + }); +} + +// ---- the junction is a normal table ---------------------------- + +#[test] +fn the_junction_can_be_renamed() { + // C4 requirement text: "an auto-named junction table the user can + // rename." It is a normal table, so `rename table` works. + let (_p, db, _d) = open(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + serial_pk_table(&db, "Courses").await; + db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None) + .await + .unwrap(); + db.rename_table("Students_Courses".to_string(), "Enrollments".to_string(), None) + .await + .expect("rename the junction"); + let tables = db.list_tables().await.unwrap(); + 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(); + assert_eq!(desc.outbound_relationships.len(), 2, "FKs preserved across rename"); + }); +} + +#[test] +fn junction_survives_save_and_rebuild() { + // Persistence round-trip: the junction + both relationships are + // reconstructed from project.yaml after the .db is discarded. + let dir = tempfile::tempdir().expect("tempdir"); + let project_path = { + let project = project::open_or_create(None, Some(dir.path())).unwrap(); + let path = project.path().to_path_buf(); + let db = Database::open_with_persistence(project.db_path(), Persistence::new(path.clone())) + .unwrap(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + serial_pk_table(&db, "Courses").await; + db.create_m2n_relationship( + "Students".to_string(), + "Courses".to_string(), + None, + Some("create m:n relationship from Students to Courses".to_string()), + ) + .await + .unwrap(); + }); + drop(db); + drop(project); + path + }; + // Discard the derived .db so the next open rebuilds from text. + std::fs::remove_file(project_path.join(PLAYGROUND_DB)).unwrap(); + let project = project::Project::open(&project_path).unwrap(); + let db = + Database::open_with_persistence(project.db_path(), Persistence::new(project.path().to_path_buf())) + .unwrap(); + rt().block_on(async { + 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(); + assert_eq!(desc.outbound_relationships.len(), 2, "both FKs reconstructed"); + assert!(desc.columns.iter().all(|c| c.primary_key), "compound PK reconstructed"); + }); +} + +#[test] +fn as_an_internal_name_is_refused() { + // The junction must be a real, listable table — an `as __rdbms_*` + // name would be filtered out of `list_tables` (a hidden orphan). + // Guarded in the shared `do_create_table` (ADR-0045 /runda finding). + let (_p, db, _d) = open(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + serial_pk_table(&db, "Courses").await; + let err = db + .create_m2n_relationship( + "Students".to_string(), + "Courses".to_string(), + Some("__rdbms_evil".to_string()), + None, + ) + .await + .expect_err("an internal junction name must be refused"); + assert!(format!("{err}").contains("no such table"), "got: {err}"); + assert!(!db.list_tables().await.unwrap().contains(&"__rdbms_evil".to_string())); + }); +} + +#[test] +fn pk_less_parent_is_refused() { + let (_p, db, _d) = open(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + // A PK-less table via the advanced SQL path. + db.sql_create_table( + "Loose".to_string(), + vec![ColumnSpec::new("a", Type::Int)], + vec![], + vec![], + vec![], + vec![], + false, + None, + ) + .await + .unwrap(); + let err = db + .create_m2n_relationship("Students".to_string(), "Loose".to_string(), None, None) + .await + .expect_err("a PK-less parent must be refused"); + assert!(format!("{err}").contains("no primary key"), "got: {err}"); + }); +} + +/// ADR-0046 DB2: the worker's `read_all_relationships` returns full +/// schema records (name, parent/child tables + columns, actions) — the +/// data source for the sidebar relationships panel. Exercised through +/// the real worker thread after an m:n junction creates two of them. +#[test] +fn read_all_relationships_returns_the_junction_relationships() { + let (_project, db, _dir) = open(); + rt().block_on(async { + serial_pk_table(&db, "Students").await; + serial_pk_table(&db, "Courses").await; + db.create_m2n_relationship("Students".to_string(), "Courses".to_string(), None, None) + .await + .expect("create m:n"); + + let rels = db + .read_all_relationships() + .await + .expect("read all relationships"); + assert_eq!( + rels.len(), + 2, + "the m:n junction creates two relationships: {rels:?}" + ); + // Both have the junction (Students_Courses) as their child. + for r in &rels { + assert_eq!(r.child_table, "Students_Courses", "child is the junction: {r:?}"); + } + // One points back to each parent. + let parents: std::collections::BTreeSet<&str> = + rels.iter().map(|r| r.parent_table.as_str()).collect(); + assert!( + parents.contains("Students") && parents.contains("Courses"), + "one relationship per parent: {rels:?}" + ); + }); +} diff --git a/tests/it/main.rs b/tests/it/main.rs index f2e9a57..a6d300d 100644 --- a/tests/it/main.rs +++ b/tests/it/main.rs @@ -19,6 +19,7 @@ mod iteration4a_rebuild_command; mod iteration4b_lifecycle_commands; mod iteration5_export_import; mod iteration6_resume_history; +mod m2n; mod parse_error_pedagogy; mod project_lifecycle; mod replay_command; diff --git a/tests/it/sql_alter_table.rs b/tests/it/sql_alter_table.rs index 1d6f360..70c1a97 100644 --- a/tests/it/sql_alter_table.rs +++ b/tests/it/sql_alter_table.rs @@ -65,6 +65,50 @@ fn replay_is_refused(script: &str) -> bool { matches!(events.last(), Some(AppEvent::ReplayFailed { .. })) } +/// Like [`replay_is_refused`] but returns the failure message, so a test +/// can assert the command was refused *for the expected reason* rather +/// than e.g. a parse error. +fn replay_failure_message(script: &str) -> Option { + let (project, db, _d) = open(); + let r = rt(); + std::fs::write(project.path().join("conv.commands"), script).expect("write script"); + let events = r.block_on(run_replay(&db, project.path(), "conv.commands")); + match events.last() { + Some(AppEvent::ReplayFailed { error, .. }) => Some(error.clone()), + _ => None, + } +} + +#[test] +fn e2e_alter_drop_primary_key_column_is_refused() { + // Issue #19: dropping a PK column must be refused on the advanced + // ALTER surface too (it reaches the shared `do_drop_column` guard). + let msg = replay_failure_message( + "create table T (id int primary key, v text)\n\ + alter table T drop column id\n", + ) + .expect("dropping a PK column must be refused"); + assert!( + msg.to_lowercase().contains("primary"), + "refused for the wrong reason: {msg}" + ); +} + +#[test] +fn e2e_alter_drop_compound_primary_key_member_is_refused() { + // A member of a *compound* PK is still a PK column, so dropping it is + // refused identically (each member reports primary_key = true). + let msg = replay_failure_message( + "create table T (a int, b int, v text, primary key (a, b))\n\ + alter table T drop column a\n", + ) + .expect("dropping a compound-PK member must be refused"); + assert!( + msg.to_lowercase().contains("primary"), + "refused for the wrong reason: {msg}" + ); +} + /// 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)) diff --git a/tests/it/sql_create_table.rs b/tests/it/sql_create_table.rs index 2d0b097..0d07f54 100644 --- a/tests/it/sql_create_table.rs +++ b/tests/it/sql_create_table.rs @@ -839,6 +839,7 @@ fn fk(child_column: &str, parent_table: &str, parent_column: Option<&str>) -> Sq parent_columns: parent_column.map(|c| vec![c.to_string()]), on_delete: ReferentialAction::NoAction, on_update: ReferentialAction::NoAction, + inline: false, } } diff --git a/tests/it/sql_drop_table.rs b/tests/it/sql_drop_table.rs index b71be7a..a94195f 100644 --- a/tests/it/sql_drop_table.rs +++ b/tests/it/sql_drop_table.rs @@ -109,6 +109,7 @@ fn dropping_a_referenced_parent_is_refused() { parent_columns: Some(vec!["id".to_string()]), on_delete: rdbms_playground::dsl::ReferentialAction::NoAction, on_update: rdbms_playground::dsl::ReferentialAction::NoAction, + inline: true, }], false, Some("create table child (id serial primary key, pid int references parent(id))".to_string()), diff --git a/tests/it/walking_skeleton.rs b/tests/it/walking_skeleton.rs index 24646e7..98b8400 100644 --- a/tests/it/walking_skeleton.rs +++ b/tests/it/walking_skeleton.rs @@ -301,7 +301,8 @@ fn create_table_flow_updates_tables_list_and_structure_view() { assert_eq!(app.tables, vec!["Customers".to_string()]); assert_eq!(app.current_table, Some(desc)); - let rendered = rendered_text(&mut app, &theme, 80, 24); + // Width > 90 so the sidebar (items panel) is shown (ADR-0046 DB1). + let rendered = rendered_text(&mut app, &theme, 110, 24); assert!( rendered.contains("Customers"), "items panel should list Customers:\n{rendered}" @@ -397,7 +398,8 @@ fn drop_table_flow_clears_items_list() { assert!(app.tables.is_empty()); assert!(app.current_table.is_none()); - let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); + // Width > 90 so the (now-empty) sidebar is shown (ADR-0046 DB1). + let rendered = rendered_text(&mut app, &Theme::dark(), 110, 24); assert!(rendered.contains("(none yet)")); // ADR-0040: `drop table` is content-less, so the echo's ✓ marker // is the entire success signal (replacing `[ok] drop table …`). diff --git a/tests/typing_surface/create_m2n.rs b/tests/typing_surface/create_m2n.rs new file mode 100644 index 0000000..10abc06 --- /dev/null +++ b/tests/typing_surface/create_m2n.rs @@ -0,0 +1,73 @@ +//! Matrix coverage for `create m:n relationship from to +//! [as ]` (C4 / ADR-0045). Exercises the full typing surface — +//! completion candidates, ambient hint, highlighting, and parse state — +//! at each stage, so a regression in any of those surfaces is caught. + +use crate::typing_surface::*; +use rdbms_playground::input_render::InputState; + +#[test] +fn after_create_offers_table_and_m2n() { + let schema = schema_multi_table(); + let a = assess_at_end("create ", &schema); + assert!(matches!(a.state, InputState::IncompleteAtEof)); + // `create` branches to `table` (create table) or the `m:n` composite. + assert_candidate_present(&a, &["table", "m:n"]); + crate::snap!("after_create", a); +} + +#[test] +fn m2n_relationship_keyword_sequence_is_incomplete() { + let schema = schema_multi_table(); + let a = assess_at_end("create m:n relationship ", &schema); + assert!(matches!(a.state, InputState::IncompleteAtEof)); + assert_candidate_present(&a, &["from"]); + crate::snap!("after_relationship_keyword", a); +} + +#[test] +fn after_from_offers_table_names() { + let schema = schema_multi_table(); + let a = assess_at_end("create m:n relationship from ", &schema); + assert!(matches!(a.state, InputState::IncompleteAtEof)); + assert_candidate_present(&a, &["Customers", "Orders"]); + crate::snap!("after_from", a); +} + +#[test] +fn after_to_offers_table_names() { + let schema = schema_multi_table(); + let a = assess_at_end("create m:n relationship from Customers to ", &schema); + assert!(matches!(a.state, InputState::IncompleteAtEof)); + assert_candidate_present(&a, &["Customers", "Orders"]); + crate::snap!("after_to", a); +} + +#[test] +fn complete_create_m2n_parses() { + let schema = schema_multi_table(); + let a = assess_at_end("create m:n relationship from Customers to Orders", &schema); + assert!(matches!(a.state, InputState::Valid)); + assert_eq!(a.parse_result.as_deref(), Ok("CreateM2nRelationship")); + crate::snap!("complete", a); +} + +#[test] +fn create_m2n_with_as_name_parses() { + let schema = schema_multi_table(); + let a = assess_at_end( + "create m:n relationship from Customers to Orders as CustomerOrders", + &schema, + ); + assert!(matches!(a.state, InputState::Valid)); + assert_eq!(a.parse_result.as_deref(), Ok("CreateM2nRelationship")); + crate::snap!("with_as_name", a); +} + +#[test] +fn after_as_keyword_is_incomplete() { + let schema = schema_multi_table(); + let a = assess_at_end("create m:n relationship from Customers to Orders as ", &schema); + assert!(matches!(a.state, InputState::IncompleteAtEof)); + crate::snap!("after_as", a); +} diff --git a/tests/typing_surface/mod.rs b/tests/typing_surface/mod.rs index 4551501..c2d4307 100644 --- a/tests/typing_surface/mod.rs +++ b/tests/typing_surface/mod.rs @@ -35,6 +35,7 @@ pub mod create_table; pub mod drop_column; pub mod drop_relationship; pub mod add_relationship; +pub mod create_m2n; pub mod index_ops; pub mod constraints; pub mod rename_change_column; @@ -224,6 +225,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String { RenameColumn { .. } => "RenameColumn".into(), ChangeColumnType { .. } => "ChangeColumnType".into(), AddRelationship { .. } => "AddRelationship".into(), + CreateM2nRelationship { .. } => "CreateM2nRelationship".into(), DropRelationship { .. } => "DropRelationship".into(), AddIndex { .. } => "AddIndex".into(), DropIndex { .. } => "DropIndex".into(), diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_as_keyword_is_incomplete@after_as.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_as_keyword_is_incomplete@after_as.snap new file mode 100644 index 0000000..11f0118 --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_as_keyword_is_incomplete@after_as.snap @@ -0,0 +1,20 @@ +--- +source: tests/typing_surface/create_m2n.rs +assertion_line: 72 +description: "input=\"create m:n relationship from Customers to Orders as \" cursor=52" +expression: "& a" +--- +Assessment { + input: "create m:n relationship from Customers to Orders as ", + cursor: 52, + state: IncompleteAtEof, + hint: Some( + Prose( + "Type a name", + ), + ), + completion: None, + parse_result: Err( + "Invalid(at_eof)", + ), +} diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_create_offers_table_and_m2n@after_create.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_create_offers_table_and_m2n@after_create.snap new file mode 100644 index 0000000..4a0f4f9 --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_create_offers_table_and_m2n@after_create.snap @@ -0,0 +1,52 @@ +--- +source: tests/typing_surface/create_m2n.rs +assertion_line: 16 +description: "input=\"create \" cursor=7" +expression: "& a" +--- +Assessment { + input: "create ", + cursor: 7, + state: IncompleteAtEof, + hint: Some( + Candidates { + items: [ + Candidate { + text: "table", + kind: Keyword, + mode: Simple, + }, + Candidate { + text: "m:n", + kind: Keyword, + mode: Both, + }, + ], + selected: None, + }, + ), + completion: Some( + Completion { + replaced_range: ( + 7, + 7, + ), + partial_prefix: "", + candidates: [ + Candidate { + text: "table", + kind: Keyword, + mode: Simple, + }, + Candidate { + text: "m:n", + kind: Keyword, + mode: Both, + }, + ], + }, + ), + parse_result: Err( + "Invalid(at_eof)", + ), +} diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_from_offers_table_names@after_from.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_from_offers_table_names@after_from.snap new file mode 100644 index 0000000..a0c555b --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_from_offers_table_names@after_from.snap @@ -0,0 +1,52 @@ +--- +source: tests/typing_surface/create_m2n.rs +assertion_line: 34 +description: "input=\"create m:n relationship from \" cursor=29" +expression: "& a" +--- +Assessment { + input: "create m:n relationship from ", + cursor: 29, + state: IncompleteAtEof, + hint: Some( + Candidates { + items: [ + Candidate { + text: "Customers", + kind: Identifier, + mode: Both, + }, + Candidate { + text: "Orders", + kind: Identifier, + mode: Both, + }, + ], + selected: None, + }, + ), + completion: Some( + Completion { + replaced_range: ( + 29, + 29, + ), + partial_prefix: "", + candidates: [ + Candidate { + text: "Customers", + kind: Identifier, + mode: Both, + }, + Candidate { + text: "Orders", + kind: Identifier, + mode: Both, + }, + ], + }, + ), + parse_result: Err( + "Invalid(at_eof)", + ), +} diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_to_offers_table_names@after_to.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_to_offers_table_names@after_to.snap new file mode 100644 index 0000000..4055c73 --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__after_to_offers_table_names@after_to.snap @@ -0,0 +1,52 @@ +--- +source: tests/typing_surface/create_m2n.rs +assertion_line: 43 +description: "input=\"create m:n relationship from Customers to \" cursor=42" +expression: "& a" +--- +Assessment { + input: "create m:n relationship from Customers to ", + cursor: 42, + state: IncompleteAtEof, + hint: Some( + Candidates { + items: [ + Candidate { + text: "Customers", + kind: Identifier, + mode: Both, + }, + Candidate { + text: "Orders", + kind: Identifier, + mode: Both, + }, + ], + selected: None, + }, + ), + completion: Some( + Completion { + replaced_range: ( + 42, + 42, + ), + partial_prefix: "", + candidates: [ + Candidate { + text: "Customers", + kind: Identifier, + mode: Both, + }, + Candidate { + text: "Orders", + kind: Identifier, + mode: Both, + }, + ], + }, + ), + parse_result: Err( + "Invalid(at_eof)", + ), +} diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__complete_create_m2n_parses@complete.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__complete_create_m2n_parses@complete.snap new file mode 100644 index 0000000..d4b1054 --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__complete_create_m2n_parses@complete.snap @@ -0,0 +1,20 @@ +--- +source: tests/typing_surface/create_m2n.rs +assertion_line: 52 +description: "input=\"create m:n relationship from Customers to Orders\" cursor=48" +expression: "& a" +--- +Assessment { + input: "create m:n relationship from Customers to Orders", + cursor: 48, + state: Valid, + hint: Some( + Prose( + "Submit with Enter", + ), + ), + completion: None, + parse_result: Ok( + "CreateM2nRelationship", + ), +} diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__create_m2n_with_as_name_parses@with_as_name.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__create_m2n_with_as_name_parses@with_as_name.snap new file mode 100644 index 0000000..05773bd --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__create_m2n_with_as_name_parses@with_as_name.snap @@ -0,0 +1,20 @@ +--- +source: tests/typing_surface/create_m2n.rs +assertion_line: 64 +description: "input=\"create m:n relationship from Customers to Orders as CustomerOrders\" cursor=66" +expression: "& a" +--- +Assessment { + input: "create m:n relationship from Customers to Orders as CustomerOrders", + cursor: 66, + state: Valid, + hint: Some( + Prose( + "Type a name", + ), + ), + completion: None, + parse_result: Ok( + "CreateM2nRelationship", + ), +} diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__m2n_relationship_keyword_sequence_is_incomplete@after_relationship_keyword.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__m2n_relationship_keyword_sequence_is_incomplete@after_relationship_keyword.snap new file mode 100644 index 0000000..62fb479 --- /dev/null +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_m2n__m2n_relationship_keyword_sequence_is_incomplete@after_relationship_keyword.snap @@ -0,0 +1,42 @@ +--- +source: tests/typing_surface/create_m2n.rs +assertion_line: 25 +description: "input=\"create m:n relationship \" cursor=24" +expression: "& a" +--- +Assessment { + input: "create m:n relationship ", + cursor: 24, + state: IncompleteAtEof, + hint: Some( + Candidates { + items: [ + Candidate { + text: "from", + kind: Keyword, + mode: Simple, + }, + ], + selected: None, + }, + ), + completion: Some( + Completion { + replaced_range: ( + 24, + 24, + ), + partial_prefix: "", + candidates: [ + Candidate { + text: "from", + kind: Keyword, + mode: Simple, + }, + ], + }, + ), + parse_result: Err( + "Invalid(at_eof)", + ), +} diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_create_expects_table@after_create.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_create_expects_table@after_create.snap index f2e64e4..39cca2e 100644 --- a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_create_expects_table@after_create.snap +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_create_expects_table@after_create.snap @@ -1,5 +1,6 @@ --- source: tests/typing_surface/create_table.rs +assertion_line: 13 description: "input=\"create \" cursor=7" expression: "& a" --- @@ -13,6 +14,11 @@ Assessment { Candidate { text: "table", kind: Keyword, + mode: Simple, + }, + Candidate { + text: "m:n", + kind: Keyword, mode: Both, }, ], @@ -30,6 +36,11 @@ Assessment { Candidate { text: "table", kind: Keyword, + mode: Simple, + }, + Candidate { + text: "m:n", + kind: Keyword, mode: Both, }, ], diff --git a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_with_expects_pk@after_with.snap b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_with_expects_pk@after_with.snap index 1da5358..600150c 100644 --- a/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_with_expects_pk@after_with.snap +++ b/tests/typing_surface/snapshots/typing_surface_matrix__typing_surface__create_table__after_with_expects_pk@after_with.snap @@ -1,5 +1,6 @@ --- source: tests/typing_surface/create_table.rs +assertion_line: 48 description: "input=\"create table Customers with \" cursor=28" expression: "& a" --- @@ -13,7 +14,7 @@ Assessment { Candidate { text: "pk", kind: Keyword, - mode: Both, + mode: Simple, }, ], selected: None, @@ -30,7 +31,7 @@ Assessment { Candidate { text: "pk", kind: Keyword, - mode: Both, + mode: Simple, }, ], },