From 7e4bc122be9dabfa03ee2b9b315214d2300ac131 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 14:03:00 +0000 Subject: [PATCH 01/50] fix(completion): treat a bare in-scope table alias as an alias, not an unknown column (#31) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare table alias typed where a column is expected — `… GROUP BY o`, with `o` aliasing `FROM Orders o` — was a blind spot: completion offered nothing for `o`, and the hint panel called the in-scope alias an unknown column (`no such column o on table Orders, ...`). Completion now offers each FROM source's qualifier (alias-if-present-else table-name) at a bare sql_expr_ident slot, folded into the column candidate list; on an exact-qualifier partial the alias source steps aside so the diagnostic can surface. The bare-reference diagnostic arm emits a targeted `alias_used_as_column` / `table_used_as_column` hint ("`o` is a table alias — write `o.` ...") after the projection-alias check, so ORDER-BY alias refs still win and a genuine unknown column still reports `unknown_column`. Two guards keep the qualified-form advice correct: SQL only (role `sql_expr_ident`, so the DSL `expr_column` path keeps `unknown_column` since the DSL has no `table.column` syntax) and effective-qualifier match (alias-if-present-else-table, so an aliased source referenced by its shadowed real name falls through rather than being advised as `name.`). The diagnostic is a drop-in replacement for `unknown_column` at the same span/Error severity, so verdict/overlay/hint paths are unchanged. ADR-0032 Amendment 3; +10 tests. --- docs/adr/0032-sql-select-grammar.md | 70 ++++++++++++ docs/adr/README.md | 2 +- src/completion.rs | 110 ++++++++++++++++++ src/dsl/walker/mod.rs | 169 ++++++++++++++++++++++++++++ src/friendly/keys.rs | 2 + src/friendly/strings/en-US.yaml | 6 + src/input_render.rs | 59 ++++++++++ 7 files changed, 417 insertions(+), 1 deletion(-) diff --git a/docs/adr/0032-sql-select-grammar.md b/docs/adr/0032-sql-select-grammar.md index 35930a7..21bfe43 100644 --- a/docs/adr/0032-sql-select-grammar.md +++ b/docs/adr/0032-sql-select-grammar.md @@ -1480,6 +1480,76 @@ accumulators), the per-keystroke re-walk (ADR-0027's debounced cadence), and the ORDER BY no-fixup-needed clarification. +## Amendment 3 — bare table aliases in expression slots (2026-06-12) + +Issue #31. A bare in-scope table alias typed where the grammar +expects a column — `… GROUP BY o`, with `o` aliasing +`FROM Orders o` — was a blind spot in two surfaces: + +- **Completion (§10).** §10.5 narrows columns *past* a + `qualifier .`, but the bare-ident slot before the dot offered + only columns and function names, never the aliases themselves. + A learner mid-typing `o` toward `o.` got no Tab help. +- **Diagnostics (§11.2).** §11.2 added `projection_alias_misplaced` + for a *projection* alias used in a forbidden clause, but a bare + *table* alias fell through to the generic `unknown_column` + bare-reference check (§11.2's `matched.len() == 0` arm), which + reported `no such column \`o\` on table \`Orders, …\`` — calling + an in-scope alias an unknown column. + +### What changes + +1. **Completion offers in-scope FROM qualifiers at a bare + `sql_expr_ident` slot** (one not already past a `qualifier .`). + Each binding contributes its *qualifier* — the alias if it has + one, else the table name (an aliased source must be referenced + by its alias). Folded into the existing `IdentSource::Columns` + candidate list so it sorts / dedups / colours uniformly. When + the partial *exactly* matches an in-scope qualifier the alias + source steps aside: discoverability is already served, and + suppressing sibling aliases lets the diagnostic below surface + (rather than being hidden by the `typing_over_diag` path). + +2. **A bare ident matching an in-scope qualifier now emits a + targeted diagnostic** instead of `unknown_column`, checked in + the `matched.len() == 0` arm *after* the projection-alias check + (so an ORDER-BY projection-alias reference still wins). It is a + drop-in replacement at the same span and `Error` severity — only + the message text changes — so the validity verdict, token + overlay, and hint-panel paths behave exactly as they did for + `unknown_column`: + - `diagnostic.alias_used_as_column` — `` `o` is a table alias — + write `o.` to reference one of its columns `` (the + binding has an alias), or + - `diagnostic.table_used_as_column` — same shape, "is a table" + (an un-aliased table source). + + Two guards keep the qualified-form advice correct (both covered + by regression tests): + - **SQL only.** The branch fires only for `role == + "sql_expr_ident"`. The DSL `Expr` (role `expr_column`) reaches + the same arm but has no `table.column` syntax, so a DSL bare + table-name ref keeps the generic `unknown_column` — advising + the qualified form there would be wrong. + - **Effective-qualifier match.** It matches the binding's + *effective qualifier* — the alias if present, else the table + name — not the table name independently. An aliased source + must be referenced by its alias (`FROM a x … GROUP BY a` is + invalid SQL), so the shadowed real name `a` falls through to + `unknown_column` rather than being advised as `a.`. + This mirrors the completion side's qualifier rule exactly. + + A genuine unknown column (matching no alias, table, or column) + still reports `unknown_column` verbatim. + + The message tail is deliberately clause-neutral ("to reference + one of its columns") rather than GROUP-BY-specific, because the + bare-reference arm fires across the projection, `WHERE`, + `GROUP BY`, and `HAVING`. + +This is an additive refinement of §10 and §11.2; no grammar node +changes. + ## See also - ADR-0005 — the ten-type vocabulary §10 resolves back to. diff --git a/docs/adr/README.md b/docs/adr/README.md index 101ee05..0ded724 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -37,7 +37,7 @@ This directory contains the project's ADRs, recorded per - [ADR-0029 — Column constraints (NOT NULL / UNIQUE / CHECK / DEFAULT)](0029-column-constraints.md) — **Accepted**, the four column-level constraints declared in the column-spec suffix (`create table` / `add column`) and modified on existing columns via `add constraint …` / `drop constraint …`; a pre-flight dry-run guards populated columns; `CHECK` reuses the ADR-0026 expression grammar via `Subgrammar` (`C3`) - [ADR-0030 — Advanced mode: the standard-SQL surface](0030-advanced-mode-sql-surface.md) — **Accepted**, SQL added as grammar *within the unified grammar tree* (ADR-0024), not a separate batch parser — so SQL gets the same completion / highlighting / hints / parse-errors as the DSL; mode gates the SQL forms; DDL routes through the typed `Command` executor (metadata + type vocabulary preserved), DML and `SELECT` execute as validated SQL; engine-neutral posture, the DSL→SQL teaching echo; supersedes ADR-0001's `sqlparser-rs` reservation; phased plan (`Q1` / `Q2` / `Q4`); **§13 OOS-2 (EXPLAIN of advanced SQL) superseded by ADR-0039** - [ADR-0031 — The SQL expression grammar](0031-sql-expression-grammar.md) — **Accepted**, the stratified SQL expression grammar fragment commissioned by ADR-0030 §3: a single precedence ladder (`OR`/`AND`/`NOT`, the comparison/`LIKE`/`IN`/`BETWEEN`/`IS NULL` predicate set, arithmetic incl. `||`, function calls, `CASE`) — the superset of ADR-0026's DSL `WHERE` grammar, authored as a parallel fragment so simple mode is untouched; pure validation, builds **no** AST (consumers run/store SQL as text per ADR-0030 §4/§6); reuses ADR-0026's `Subgrammar` recursion + depth cap unchanged; subquery expressions and qualified column refs deferred to ADR-0030 Phase 2; **status note (2026-05-30)** records that ADR-0022 Amendment 6 layers a curated known-function list on the `sql_expr_ident` slot (§1) for completion + the typing-time typo hint (issues #15/#16) — the grammar itself is unchanged, and the no-validation-allowlist posture stands -- [ADR-0032 — The full SQL `SELECT` grammar](0032-sql-select-grammar.md) — **Accepted**, the Phase-2 grammar commissioned by ADR-0030 §3: full `SELECT` with `INNER`/`LEFT`/`RIGHT`/`FULL OUTER`/`CROSS` joins, `GROUP BY`/`HAVING`, all four set ops (`UNION`/`UNION ALL`/`INTERSECT`/`EXCEPT`), `WITH` and `WITH RECURSIVE` CTEs, `LIMIT … OFFSET`, `DISTINCT`, `t.*`, and bare-alias projection (lifting Phase-1 §4.2); additive extensions to ADR-0031's `sql_expr` for scalar subqueries, `IN (SELECT …)`, `[NOT] EXISTS`, and qualified column refs (redeeming ADR-0031 §7 OOS-1/OOS-2); grammar-recursion via `Subgrammar(&SQL_SELECT_COMPOUND)` reuses ADR-0026's `MAX_SUBGRAMMAR_DEPTH = 64` cap unchanged; **softens ADR-0030 §8's "ambient assistance comes for free" claim**: completion scope needs new `WalkContext` accumulators (a `from_scope_stack` of `ScopeFrame`s holding `from_scope` / `cte_bindings` / `projection_aliases`), a **new walker node variant `Node::ScopedSubgrammar(&Node)`** as the push/pop trigger (existing `Node::Subgrammar` unchanged so DSL `Expr` and `sql_expr` recursion are unaffected), qualified-prefix completion narrowing, body-projection-derived CTE column resolution (so `SELECT *` and explicit-projection CTE bodies both yield real column completion past `cte_alias.|`), and a **post-walk fixup pass** that re-resolves projection-list identifier highlighting/validity once `FROM` is parsed (the projection-before-FROM problem); classifies every Phase-2 validation case against ADR-0027's ERROR/WARNING guideline (§11): five new `diagnostic.*` keys for parse-time-detectable cases (unknown qualifier, ambiguous column, projection-alias misplaced, CTE/compound arity mismatch) plus eight `engine.*` translation keys; a MatchedPath-walking predicate-warnings variant that closes the Phase-1 gap where SQL `WHERE` expressions emitted no `LIKE`-on-numeric / `= NULL` / type-mismatch warnings (ADR-0027 Amendment 1 finally extends to the SQL surface); adds a worker-side post-prepare type-resolution pass via engine column-origin metadata so bare column refs recover their playground type (partially lifting Phase-1 §4.5, the bool→0/1 case) — `Cargo.toml` gains `column_metadata` to rusqlite features (verified against pinned 0.39.0); `__rdbms_*` rejection extended to every new table-source slot; Amendment 1 narrows §12's resolution rule from a grammar-side structural classification to "trust the engine's column-origin metadata verbatim" after an empirical probe showed origin metadata follows through non-recursive CTEs, scalar subqueries, derived tables, set ops, and joins — the one structural exception is recursive CTE result columns, which return None and stay typeless; Amendment 2 records that §10.6's "rewrite the highlight class" prescription is realised via the two-pass schema-existence diagnostic + the renderer's diagnostic-overlay path (no separate per-byte rewrite step needed; no new HighlightClass variant), and that the projection-before-FROM completion narrowing has been improved by an `src/completion.rs` look-ahead probe when the leading walk's `from_scope` is empty but the full input parses +- [ADR-0032 — The full SQL `SELECT` grammar](0032-sql-select-grammar.md) — **Accepted**, the Phase-2 grammar commissioned by ADR-0030 §3: full `SELECT` with `INNER`/`LEFT`/`RIGHT`/`FULL OUTER`/`CROSS` joins, `GROUP BY`/`HAVING`, all four set ops (`UNION`/`UNION ALL`/`INTERSECT`/`EXCEPT`), `WITH` and `WITH RECURSIVE` CTEs, `LIMIT … OFFSET`, `DISTINCT`, `t.*`, and bare-alias projection (lifting Phase-1 §4.2); additive extensions to ADR-0031's `sql_expr` for scalar subqueries, `IN (SELECT …)`, `[NOT] EXISTS`, and qualified column refs (redeeming ADR-0031 §7 OOS-1/OOS-2); grammar-recursion via `Subgrammar(&SQL_SELECT_COMPOUND)` reuses ADR-0026's `MAX_SUBGRAMMAR_DEPTH = 64` cap unchanged; **softens ADR-0030 §8's "ambient assistance comes for free" claim**: completion scope needs new `WalkContext` accumulators (a `from_scope_stack` of `ScopeFrame`s holding `from_scope` / `cte_bindings` / `projection_aliases`), a **new walker node variant `Node::ScopedSubgrammar(&Node)`** as the push/pop trigger (existing `Node::Subgrammar` unchanged so DSL `Expr` and `sql_expr` recursion are unaffected), qualified-prefix completion narrowing, body-projection-derived CTE column resolution (so `SELECT *` and explicit-projection CTE bodies both yield real column completion past `cte_alias.|`), and a **post-walk fixup pass** that re-resolves projection-list identifier highlighting/validity once `FROM` is parsed (the projection-before-FROM problem); classifies every Phase-2 validation case against ADR-0027's ERROR/WARNING guideline (§11): five new `diagnostic.*` keys for parse-time-detectable cases (unknown qualifier, ambiguous column, projection-alias misplaced, CTE/compound arity mismatch) plus eight `engine.*` translation keys; a MatchedPath-walking predicate-warnings variant that closes the Phase-1 gap where SQL `WHERE` expressions emitted no `LIKE`-on-numeric / `= NULL` / type-mismatch warnings (ADR-0027 Amendment 1 finally extends to the SQL surface); adds a worker-side post-prepare type-resolution pass via engine column-origin metadata so bare column refs recover their playground type (partially lifting Phase-1 §4.5, the bool→0/1 case) — `Cargo.toml` gains `column_metadata` to rusqlite features (verified against pinned 0.39.0); `__rdbms_*` rejection extended to every new table-source slot; Amendment 1 narrows §12's resolution rule from a grammar-side structural classification to "trust the engine's column-origin metadata verbatim" after an empirical probe showed origin metadata follows through non-recursive CTEs, scalar subqueries, derived tables, set ops, and joins — the one structural exception is recursive CTE result columns, which return None and stay typeless; Amendment 2 records that §10.6's "rewrite the highlight class" prescription is realised via the two-pass schema-existence diagnostic + the renderer's diagnostic-overlay path (no separate per-byte rewrite step needed; no new HighlightClass variant), and that the projection-before-FROM completion narrowing has been improved by an `src/completion.rs` look-ahead probe when the leading walk's `from_scope` is empty but the full input parses; **Amendment 3, 2026-06-12** (issue #31): a bare in-scope **table alias** at an expression slot (`… GROUP BY o`, `o` aliasing `FROM Orders o`) is no longer a blind spot — completion now offers each FROM source's qualifier (alias-if-present-else-table-name) at a bare `sql_expr_ident` slot (folded into the column candidate list; the alias source steps aside on an exact-qualifier partial so the diagnostic can surface), and the `matched.len()==0` bare-reference arm emits a targeted `diagnostic.alias_used_as_column` / `diagnostic.table_used_as_column` ("`o` is a table alias — write `o.` …") instead of the misleading `unknown_column` (a drop-in replacement at the same span/`Error` severity, so verdict/overlay/hint paths are unchanged), checked after the projection-alias check so ORDER-BY alias refs still win; two guards keep the advice correct — **SQL-only** (`role == "sql_expr_ident"`, so the DSL `expr_column` path keeps `unknown_column` since the DSL has no `table.column` syntax) and **effective-qualifier match** (alias-if-present-else-table, so an aliased source referenced by its shadowed real name falls through rather than being advised as `name.`); a genuine unknown column still reports `unknown_column` - [ADR-0033 — The full SQL DML grammar (`INSERT` / `UPDATE` / `DELETE`)](0033-sql-dml-grammar.md) — **Accepted** (implemented + verified through sub-phase 3k, 2026-05-23; phase-exit report `docs/handoff/20260523-phase-3-verification.md`), the Phase-3 grammar commissioned by ADR-0030 §3: single- and multi-row `INSERT` (incl. `INSERT … SELECT` recursing through ADR-0032's `SQL_SELECT_COMPOUND`), `UPDATE` with `SET` assignment list, `DELETE`, all three optionally followed by `RETURNING projection_list`, plus full `ON CONFLICT … DO NOTHING / DO UPDATE` UPSERT on INSERT; **fixes the DSL-vs-SQL dispatch architecture for shared entry words (`insert`/`update`/`delete`)**: SQL-first / DSL-fallback in Advanced mode via a `Choice(SQL_shape, DSL_shape)` per shape, gated by a new walker capability `Node::Guard(fn)` — a zero-byte-consumption gating node that fails the enclosing Seq with a `ValidationError`; carries `Command::SqlInsert` / `SqlUpdate` / `SqlDelete` variants and `do_sql_*` worker handlers each of which knows the target table (for re-persistence) and the `returning: bool` flag (for DataResult routing); `shortid` auto-fill mirrors the DSL `do_insert` mechanism via worker post-fill; SQL DELETE produces the same per-relationship cascade summary the DSL DELETE does (ADR-0014 parity); three new walker diagnostics (`insert_arity_mismatch` ERROR, `auto_column_overridden` WARNING, `not_null_missing` WARNING) with positive + negative tests each; OOS list explicitly carves out `DEFAULT VALUES` (the project's planned seed feature), SQLite-specific `OR REPLACE` / `OR IGNORE` / `OR ABORT` / `OR FAIL` / `OR ROLLBACK` prefixes, `UPDATE FROM` multi-table updates, and WITH-prefixed DML; the `excluded` keyword inside `ON CONFLICT DO UPDATE` is a deliberate carve-out from ADR-0030 §7's engine-neutral posture (no standard-SQL UPSERT spelling exists that SQLite and PostgreSQL share); eleven phased sub-phases each with explicit exit gates + written DA gate, opening with the dispatch mechanism before any DML grammar lands; initial DA review recorded seven critiques that were resolved before status moved to Proposed; **Amendment 1 supersedes §2's dispatch mechanism**: the originally-chosen `Node::Guard(fn)` + `Choice(SQL_shape, DSL_shape)` was found during 3a to be unworkable as framed (any guard-in-`Choice` mechanism forces a `walk_choice` change — `walk_choice` only falls through on `NoMatch`, so Simple-mode valid-DSL would wrongly surface "this is SQL", and `walk_seq` treats a `NoMatch` past `idx 0` as a hard `Failed`, breaking Advanced-mode DSL fall-through); replaced by **category-grouped, mode-aware dispatch** in `walker::walk` (each `REGISTRY` entry tagged `CommandCategory::{Simple, Advanced}`, generalising the existing whole-command `is_advanced_only` gate), shared entry words carrying a node in both groups, no `Node::Guard` and no `walk_choice`/`walk_seq` change, advanced-mode completion SQL-first with DSL as a full-line fallback; **Amendment 2 (sub-phase 3f) supersedes §7's cascade mechanism**: the WHERE-injected per-child pre-count rested on a premise that was factually wrong about the DSL handler (which detects cascades by before/after row-count diffing inside a transaction, not by `Expr`-derived pre-count subqueries) and would have broken the §2 parity promise by reporting `SET NULL` the DSL path doesn't; replaced by mirroring `do_delete`'s count-diff exactly (verbatim DELETE executes, child-count diff observes the cascade — `ON DELETE CASCADE` row removals only, SET NULL deferred for both paths to preserve parity), which shares the render-layer formatter for free via `CommandOutcome::Delete` and **withdraws risk R2** (no WHERE-byte extraction, no N+1 subquery); **Amendment 3 (sub-phase 3j) records the command-identity model and defers the execution-mode side-channel**: a command is the typed outcome of a *mode-rooted* grammar path and its identity is intrinsic (Advanced mode tries SQL first, falls back to the *Simple* DSL command when no SQL branch matches a token, e.g. `delete … --all-rows`; note `update … --all-rows` does *not* fall back — the SQL `SET` expression eats `--all-rows`, harmless since the engine treats it as a comment); **Simple mode commits the DSL candidate for shared words** so the *real* DSL error surfaces, and when that line would also run in advanced mode the rendering layer **combines** them — DSL error **plus** an `advanced_mode.also_valid_sql` pointer ("… (valid as SQL in advanced mode)") — keeping the actionable DSL fix while pointing at advanced mode; bare "this is SQL" is reserved for entry words with no DSL form (`select`/`with`); a fully-overlapping input (`insert … values …`) legitimately yields *two distinct commands* (`Command::Insert` typed-AST vs `Command::SqlInsert` validated-text) that do the same thing but execute differently (ADR-0030 §4), so each is tested in the mode that produces it; **corrects the plan's 3j exit-gate premise** that the DSL DML tests run in Simple mode (they call `parse_command`, which defaults to Advanced) — the real invariant is "Simple-mode behaviour unchanged, Advanced mode SQL-first, DSL grammar tested in Simple mode, both variants tested in their producing mode", with §6/§7 parity keeping the paths observably equivalent; and **defers to its own future ADR** the execution-time mode side-channel (three-way `Mode`: simple/advanced/advanced-one-shot threaded through `Action`→worker, for mode-dependent *output* like echoing generated SQL) — today only the *rendering* side-channel `OutputLine.mode_at_submission` exists, and the three-way distinction is not required for Phase 3 dispatch correctness; **Amendment 4, 2026-05-27** (design agreed, pending impl): **reverses Amendment 3's `update … --all-rows` counter-example as a bug** — surfaced by the ADR-0038 echo design. The walker has no `--` comment support (it lexes two minus operators) while the engine treats `--` as a comment, so `update T set x=42 --all-rows` was silently parsed as the expression `42 - -all - rows` over **non-existent columns** `all`/`rows` (an ADR-0027 "flag-if-it-will-fail" case) and matched `SqlUpdate`. Decision: the `--all-rows` sequence makes the SQL `UPDATE` shape **fail**, so dispatch **falls back to the DSL `Update { AllRows }`** — symmetry with `delete … --all-rows`; no `--` comment feature introduced (trailing comments stay unsupported). Inverts `sql_dml_e2e.rs::e2e_update_all_rows_in_advanced_does_not_fall_back_to_dsl`; mechanism settled test-first in the build; folded into the ADR-0038 effort (makes `update … --all-rows` echoable); **Amendment 5, 2026-05-28** (implemented + verified, user-confirmed): `advanced_mode.also_valid_sql` (the cross-mode pointer from Amendment 3) fires on **validity**, not just **parse** — *"valid"* meaning `input_verdict_in_mode(input, schema, Mode::Advanced) == None` in the ADR-0027 sense (parse succeeds *and* no Warning/Error diagnostic from any pass). Surfaced by **issue #1**: a positional `INSERT INTO T VALUES (…)` (no column list) with a value count that didn't match the target's column count parsed in advanced but failed at the engine, so the syntactic-only Amendment-3 gate promised a mode switch that wouldn't help. Closes the gap by (a) extending `dml_insert_arity_diagnostics` (§8.1, previously Form A only — its own doc-comment deferred Form B) to also check the no-column-list form against the schema's column count, emitting a new `diagnostic.insert_arity_mismatch_form_b` ERROR per offending tuple, and (b) refactoring `advanced_alternative_note` to read the validity verdict instead of running its own bespoke check — any static diagnostic added to the pipeline in the future automatically participates in the pointer gate. Side benefit: the `[ERR]` validity indicator now lights up at typing time for the reported scenario, no longer needing a submit to learn the line is wrong. Tests pinned: `insert_form_b_arity_mismatch_under_supply_fires` / `_over_supply_fires` / `_match_is_silent` / `_unknown_table_is_silent` (walker); `ambient_hint_omits_advanced_pointer_when_form_b_value_count_wouldnt_match` (gate); `simple_mode_submit_of_sql_construct_appends_advanced_pointer` (pointer still fires for genuine SQL-only constructs against a known schema). Amendment 3's "would parse in advanced mode" should henceforth be read as a synonym for "valid in advanced mode" in this stricter sense; the user-confirmed behavioural change is exactly the issue #1 bug case (no other input flips its pointer state) - [ADR-0034 — `history.log` as a complete command journal; replay reads success-only](0034-history-journal-and-replay-filter.md) — **Accepted**, resolves a three-way tension in `history.log`'s roles found while implementing ADR-0033 3f: (1) the persistent log is success-only while the in-memory Up/Down recall ring records *every* submission (success or failure, "so users can recall and edit typo'd commands"), and the ring is re-seeded from the log on project open — so **failed commands are recallable within a session but silently lost across sessions**; (2) replay wants the state-building (successful) commands while recall wants everything typed, which one success-only file cannot serve; (3) `replay history.log` never actually worked — `run_replay` parses each whole line through the DSL parser with no understanding of the `||` record shape, so a real log fails on line 1, and **no test ever fed the pipe format to replay** (the `replay_history_log_records_subcommands_only` test only checks what replay *writes*, never replays the log as input). Decision: `history.log` becomes a **complete journal** — every submission recorded, tagged `ok`/`err` via the status field the format already reserved (ADR-0015 §5) — and **each consumer filters**: hydration reads all records (cross-session recall matches in-session), replay reads `ok` only (and learns the journal format, while still accepting bare-command `.commands` scripts; detection by the leading timestamp+status prefix so a `|` inside a bare command isn't misread). Successful commands stay journalled transactionally by the worker; failed commands are journalled `err` best-effort from the runtime/app error path (a parse failure never reaches the worker). Amends ADR-0006's "successfully executed" wording and ADR-0015 §5 ("status always `ok`") / §12 (hydration). Code deferred to two tracked test-first sub-tasks (journal-failures+filtering; replay-parses-journal-format); existing all-`ok` logs need no migration; **implemented 2026-05-24** (plan `docs/plans/20260524-adr-0034-history-journal.md`); **Amendment 1 (2026-05-24): replay filters out app-lifecycle commands** — a working `replay history.log` (the §3 fix) exposed that the journal also records `save as`/`load`/`new`/`export`/`import`/`rebuild`/`mode` (which would panic the worker dispatch or abort the replay), so replay now re-applies **only** schema/data write commands and **skips** every `Command::App` + nested `Command::Replay`; **all skips continue** (never abort — reversing the prior nested-`replay` refusal, so a journal containing a once-run `replay` needs no hand-editing, and the infinite-loop footgun is closed by construction), with a `[skip]` **warning** on `import` and nested-`replay` skips (their omission can leave replayed state incomplete) and silent skips for the rest; `replay.error_nested` removed, `replay.skipped_import`/`replay.skipped_replay` added, `ReplayCompleted` carries `warnings` - [ADR-0035 — Advanced-mode SQL DDL](0035-advanced-mode-sql-ddl.md) — **Accepted** (design agreed 2026-05-24; validated end-to-end by sub-phases 4a/4a.2/4a.3/4b `CREATE TABLE` (incl. foreign keys) + 4c `DROP TABLE [IF EXISTS]` + 4d `CREATE [UNIQUE] INDEX` / `DROP INDEX [IF EXISTS]` + 4e `ALTER TABLE` add/drop/rename column + 4f `ALTER TABLE … ALTER COLUMN TYPE` + 4g `ALTER TABLE` add/drop constraint + add FK + 4h `ALTER TABLE … RENAME TO` + 4i verification sweep (completion merge + simple/advanced completion colour + describe of table-level constraints + self-ref FK indicator + CREATE-TABLE help/usage), implemented 2026-05-25/26 — **Phase 4 complete**; **Amendment 1, 2026-05-26**: drop a composite UNIQUE via a derived, engine-neutral `unique_` name that reuses the existing `DROP CONSTRAINT ` grammar — no new syntax, no metadata, §4g anonymity intact; `describe` shows the name; dropping a UNIQUE-covered *column* now refuses with that name + the drop command), **Phase 4** of the ADR-0030 roadmap (peer of 0031/0032/0033) and **clarifies ADR-0030 §4**. Advanced-mode `CREATE`/`DROP`/`ALTER TABLE` + `CREATE`/`DROP INDEX` get their **own per-statement commands** (`SqlCreateTable`/`SqlAlterTable`/`SqlDropTable`/`SqlCreateIndex`/`SqlDropIndex`), like DML's `Sql*` set — but unlike DML they **execute *structurally*, not verbatim** (raw execution would lose the playground's types, named relationships, and `STRICT`; "verbatim" was a DML convenience, not a rule). Handlers **reuse the low-level schema/metadata helpers** where the operation matches simple mode and **stand alone where the SQL surface is richer** (clarity over forced refactoring); simple mode is untouched (additive). Dispatch: `create`/`drop` reuse ADR-0033 Amendment 1's category-grouped mode-aware dispatch (SQL-first, simple fallback); `alter` is a new advanced-only entry word. Full surface (no pre-emptive cuts, `Q4`): `CREATE TABLE` with column + table constraints, single/compound `PRIMARY KEY`, inline + table-level `FOREIGN KEY` → **named relationships** (one statement = one command = **one undo step**, ADR-0006); `ALTER TABLE` add/drop/rename column, `ALTER COLUMN TYPE`, add/drop constraint, add FK, **`RENAME TO`** (advanced-only table rename — new low-level op renaming the table + its CSV + the relationship and table-CHECK metadata, closing the rename half of `C1`); `CREATE [UNIQUE] INDEX` / `DROP INDEX`. Type slot accepts the ten playground keywords **and** standard-SQL aliases (`integer`→`int`, `varchar`→`text`, `timestamp`→`datetime`, …; length args accepted-and-ignored; no engine type names in/out — ADR-0030 §5). `CHECK`/`DEFAULT` reuse ADR-0031 `sql_expr`. **Pre-implementation `/runda` refinements (2026-05-24, user-confirmed):** `CREATE TABLE`/`DROP TABLE` **admit `IF [NOT] EXISTS`** (no-op-that-succeeds-with-a-note — a near-universal cross-vendor idiom, reclassified *into* scope, not engine-specific); `INTEGER PRIMARY KEY` maps to a **plain `int`** PK, *not* auto-increment (`serial` stays the sole auto-increment type). **Column-type-conversion is unified** (ADR-0017 engine, mode-appropriate policy): clean auto-converts and incompatible/own-type-static cases refuse in both modes, but a **lossy** change refuses-by-default in simple mode (`--force-conversion` opts in) while advanced mode **performs it with a loss note** and relies on **`undo` as the safety net** — no force flag, no dropping to simple mode (a payoff of shipping ADR-0006 first). OOS: views/triggers/txn-control/PRAGMA/etc. (ADR-0030 §3), the Postgres `USING` clause, and the DSL→SQL teaching echo (ADR-0030 Phase 5). Sub-phases 4a–4i, plus **4a.2** (per-column `CHECK`/`DEFAULT` via raw `sql_expr` text — `sql_expr` is validate-only, no `Expr` AST — + composite `UNIQUE(a,b)`; no new internal table) and **4a.3** (table-level/multi-column `CHECK`, landed via the new `__rdbms_playground_table_checks` metadata table because SQLite has no PRAGMA for CHECK; the builder tells a table-level CHECK from a column-level one by element position) and **4b** (foreign keys — inline `REFERENCES` + table-level `FOREIGN KEY` → ADR-0013 named relationships in the create transaction, one undo step; self-references + bare `REFERENCES ` supported, user-confirmed) and **4c** (`DROP TABLE [IF EXISTS]` → `SqlDropTable`, reusing `do_drop_table`; `IF EXISTS` is a no-op-with-note via `DropOutcome::Skipped`) and **4d** (`CREATE [UNIQUE] INDEX [IF NOT EXISTS] [] ON (cols)` → `SqlCreateIndex` and `DROP INDEX [IF EXISTS] ` → `SqlDropIndex`, reusing `do_add_index`/`do_drop_index`; **`CREATE UNIQUE INDEX` admitted** — ADR-0025 **Amendment 1** — via an additive `IndexSchema.unique` flag that round-trips through `project.yaml` and rebuild, with `[unique]` markers in the structure view + items panel, while simple-mode `add unique index` stays deferred; `IF [NOT] EXISTS` reuses the 4c skip path; `create`/`drop` each gain a *second* advanced node, exercising the all-candidates dispatch) and **4e** (`ALTER TABLE` add/drop/rename column → `SqlAlterTable`; `alter` is a new advanced-**only** entry word, runtime-decomposed to the existing `do_add_column`/`do_drop_column`/`do_rename_column` — no new worker layer; `do_add_column` extended to consume raw `default_sql`/`check_sql` so ADD COLUMN reaches CREATE-TABLE constraint parity; drop/rename refuse a column any CHECK references (table-level AND column-level, incl. a column's own self-check on rename) — the 4a.3 deferral, via a raw-CHECK-text tokenizer in the shared executors, so it guards both surfaces and fixes a latent rename-drift bug; SQL DROP COLUMN refuses an index-covered column with no `--cascade` spelling; the column executors + `do_add_index` gained an internal-`__rdbms_*`-table guard — all user-confirmed) and **4f** (`ALTER TABLE … ALTER COLUMN TYPE` → a fourth `AlterTableAction`, runtime-decomposed to the existing `change_column_type` with `ChangeColumnMode::ForceConversion` — which **is** the §7 advanced policy: lossy converts *with a note* (no force flag), incompatible + ADR-0017 static refusals (`↔ blob`, same-type, `date ↔ datetime`, non-`int → serial`) still refuse, while **`int → serial` is allowed** (auto-fills nulls + UNIQUE, ADR-0018 §8 — the §7 "→serial refused" summary is looser than the code); the builder discriminates the fourth branch by the **`type` keyword** (unique — ADD COLUMN's type is an ident), the type slot reuses `SQL_TYPE`; the internal-`__rdbms_*` guard was folded into `do_change_column_type`, closing the simple `change column` exposure too — user-confirmed) and **4g** (`ALTER TABLE … ADD [CONSTRAINT ] (CHECK | UNIQUE | FOREIGN KEY)` + `DROP CONSTRAINT `; ADD = CHECK + composite UNIQUE + FK, with `ADD PRIMARY KEY` and a *named* UNIQUE refused — composite UNIQUE is anonymous in our model; each ADD reuses a low-level path (table-CHECK/UNIQUE rebuild with a dry-run guard; FK → `add_relationship`, bare `REFERENCES

` → parent single-PK), DROP CONSTRAINT resolves the name to a table-CHECK then a child-side FK; **named table-CHECKs round-trip** via a nullable `name` column on `__rdbms_playground_table_checks` (**rebuild-only** arrival — pre-4g projects gain it on `rebuild`, a named add on an un-upgraded project is refused with a friendly "rebuild first" message) *and* a `project.yaml` `check_constraints` extension to an `{expr, name}` mapping (the bare-string form still reads); the internal-`__rdbms_*` guard was folded into `do_add_constraint`/`do_add_relationship`, completing that guard class — all user-confirmed) and **4h** (`ALTER TABLE … RENAME TO` — the one genuinely new low-level op, `do_rename_table`: a native engine rename plus one-transaction reconciliation of every metadata row naming the table (`__rdbms_playground_columns`, **both ends** of `__rdbms_playground_relationships`, `__rdbms_playground_table_checks`), the CSV file (the existing rewrite+delete path — no new persistence method), and **CHECK text that qualifies a column with the old table name** (`T.age`→`U.age`, a planning-`/runda` finding — the engine rewrites the live CHECK but the stored text would drift and break a fresh rebuild; `rewrite_check_table_qualifier` keeps them in step); grammar splits the `rename` verb into one branch with an inner Choice on a distinct second keyword (`column` vs `to`), the new-name slot mirroring the `CREATE TABLE` name slot; refuses same-name / existing-target / `__rdbms_*` / non-existent, with **case-insensitive** collision checks behind an engine-neutral pre-check (a finished-slice `/runda` finding — the engine matches names case-insensitively); auto-named indexes *and* relationships keep their stale names (only table-name columns update — §6 scope); one undo step; advanced-only, closing the rename half of `C1` — all user-confirmed) and **4i** (the verification sweep that completes Phase 4: the shared-entry-word completion merge + the simple-vs-advanced completion colour-when-mixed with Both→Advanced→Simple block ordering; `describe` of table-level composite UNIQUE + table CHECK; the self-ref FK pre-submit indicator fix; and the CREATE-TABLE help/usage skeleton refresh). **All of Phase 4 (4a–4i) is shipped.** Each sub-phase has exit + DA gates; **Amendment 2, 2026-05-27** (design agreed, pending impl): a **standard-first dialect stance** (refines ADR-0030's "standard SQL" posture — ISO spelling is canonical + echoed where one exists; a vendor shorthand may be *accepted* but isn't canonical; where ISO offers none, *one* documented vendor spelling is a deliberate extension) + an `ALTER COLUMN` **constraint gap-fill** surfaced by the ADR-0038 echo design: makes ISO `ALTER COLUMN … SET DATA TYPE` the canonical type-change verb with `TYPE` retained as a synonym (**reverses §4f's "no `SET DATA TYPE`"**), and adds `SET/DROP DEFAULT` (ISO) + `SET/DROP NOT NULL` (the one documented extension — ISO has no in-place NOT-NULL verb; PostgreSQL's chosen for being type-independent), all **rebuild-backed via the existing ADR-0029 `do_add_constraint`/`do_drop_constraint` executors** (dry-run + internal-table guards free, no new worker layer), reaching simple↔advanced constraint-mod **parity for NOT NULL + DEFAULT**; the **rebuild stays hidden** (Category-1 engine detail, ADR-0038). Residual gap left open + flagged: dropping a **column-level (anonymous) UNIQUE/CHECK** (no portable name — same class as Am1's parallel gap), which ADR-0038's catalogue marks "no headline echo" diff --git a/src/completion.rs b/src/completion.rs index 38bf3bd..10b319a 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -753,6 +753,51 @@ pub fn candidates_at_cursor_with_in_mode( ); } + // Source 1.95: in-scope table aliases (issue #31). At a bare + // `sql_expr_ident` slot — one *not* already past a `qualifier .` + // (handled by §10.5 column narrowing) — the partial may be a + // FROM-source the learner is mid-typing as a qualifier + // (`group by o` → `o.`). Offer each binding's *qualifier*: + // its alias if it has one, else the table name (an aliased source + // must be referenced by its alias, not the raw table name). This + // makes aliases Tab-discoverable and — since a non-empty candidate + // set overlapping the partial suppresses the under-cursor error + // (the `typing_over_diag` path) — keeps the alias from flashing as + // a bogus "unknown column" while typing. Mixed into `identifiers` + // so it sorts/dedups/colours uniformly with column candidates. + let alias_candidates: Vec = + if has_sql_expr_slot && prefix_qualifier.is_none() { + // Once the partial *exactly* matches an in-scope qualifier, + // discoverability is served — the learner has a whole alias + // in hand and now needs the "add `.column`" hint + // (`diagnostic.alias_used_as_column`), not sibling aliases + // that merely share the prefix. Offering them would also let + // the `typing_over_diag` path suppress that very hint. So in + // the exact-match case we emit no alias candidates and let + // the targeted diagnostic surface. + let partial_is_exact_alias = resolution_from_scope.iter().any(|b| { + let q = b.alias.as_deref().unwrap_or(b.table.as_str()); + q.eq_ignore_ascii_case(&partial_prefix) + }); + if partial_is_exact_alias { + Vec::new() + } else { + let mut out: Vec = Vec::new(); + for binding in resolution_from_scope { + let qualifier = + binding.alias.as_deref().unwrap_or(binding.table.as_str()); + if matches_prefix(qualifier) + && !out.iter().any(|q| q.eq_ignore_ascii_case(qualifier)) + { + out.push(qualifier.to_string()); + } + } + out + } + } else { + Vec::new() + }; + // Source 2: schema identifiers — accumulated across every // matching schema-listable `Ident { source }` expectation. // `NewName` / `Types` / `Free` sources don't query the @@ -788,6 +833,10 @@ pub fn candidates_at_cursor_with_in_mode( }) .filter(|name| matches_prefix(name)) .collect(); + // Fold in the in-scope alias qualifiers (Source 1.95). They are + // already prefix-filtered; dedup against any column of the same + // spelling happens via the shared sort/dedup below. + identifiers.extend(alias_candidates); identifiers.sort(); identifiers.dedup(); // If an identifier shares its name with a keyword candidate @@ -1930,6 +1979,67 @@ mod tests { cache } + fn two_table_alias_cache() -> SchemaCache { + use crate::dsl::types::Type; + let mut cache = schema_with_table("a", &[("id", Type::Int), ("name", Type::Text)]); + cache.tables.push("b".to_string()); + cache.columns.push("total".to_string()); + cache.table_columns.insert( + "b".to_string(), + vec![ + TableColumn::new("id", Type::Int), + TableColumn::new("total", Type::Real), + ], + ); + cache + } + + #[test] + fn bare_expr_slot_offers_in_scope_aliases() { + // Issue #31: at a bare SQL-expression slot (here GROUP BY) the + // in-scope FROM aliases are Tab-discoverable, so a learner can + // reach `o.` without guessing the alias. + let cache = two_table_alias_cache(); + let input = "select a.id from a o join b z on o.id = z.id group by "; + let cs = cands_with(input, input.len(), &cache); + assert!(cs.contains(&"o".to_string()), "alias `o` must be offered; got {cs:?}"); + assert!(cs.contains(&"z".to_string()), "alias `z` must be offered; got {cs:?}"); + } + + #[test] + fn bare_expr_slot_narrows_aliases_by_partial_prefix() { + // A partial that prefix-matches several aliases offers each; + // an exact match (`o`) is the learner's whole alias — no + // sibling-alias noise, so the `alias_used_as_column` hint can + // surface instead (issue #31). + let cache = two_table_alias_cache(); + let input = "select a.id from a aa join b ab on aa.id = ab.id group by a"; + let cs = cands_with(input, input.len(), &cache); + assert!(cs.contains(&"aa".to_string()), "alias `aa` must be offered; got {cs:?}"); + assert!(cs.contains(&"ab".to_string()), "alias `ab` must be offered; got {cs:?}"); + + // Exact-alias partial: the alias source steps aside. + let exact = "select aa.id from a aa join b ab on aa.id = ab.id group by aa"; + let cs2 = cands_with(exact, exact.len(), &cache); + assert!( + !cs2.iter().any(|c| c == "ab"), + "an exact-alias partial must not surface sibling aliases; got {cs2:?}", + ); + } + + #[test] + fn alias_not_offered_after_a_qualifier_dot() { + // Past `o.` the §10.5 column-narrowing owns the slot; aliases + // are not candidates there. + let cache = two_table_alias_cache(); + let input = "select a.id from a o join b z on o.id = z.id group by o."; + let cs = cands_with(input, input.len(), &cache); + assert!( + !cs.iter().any(|c| c == "o" || c == "z"), + "aliases must not be offered after a qualifier dot; got {cs:?}", + ); + } + #[test] fn update_set_offers_only_current_table_columns() { use crate::dsl::types::Type; diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index f32ffa1..3723f20 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -1163,6 +1163,60 @@ fn schema_existence_diagnostics( // Allowed-clause alias ref — silent. continue; } + // Issue #31: the bare ident is itself an + // in-scope FROM source — a table alias + // (`o` from `FROM Orders o`) or, when the + // source is un-aliased, the table name. The + // learner means a *column* of that source + // (`o.`); calling it an "unknown + // column" misleads. Point at the qualified + // form. + // + // Two guards keep the advice correct: + // - SQL only (`role == "sql_expr_ident"`): + // the DSL `Expr` (role `expr_column`) + // has no `table.column` syntax, so the + // qualified-form advice would be wrong; + // it keeps the generic unknown_column. + // - Match the *effective qualifier* + // (alias if present, else table name), + // not the table name independently. An + // aliased source must be referenced by + // its alias — `FROM Orders o … Orders` + // is invalid SQL, so it must NOT be + // advised as `Orders.`. Mirrors + // the completion side's qualifier rule. + let qualifier_binding = (role + == "sql_expr_ident") + .then(|| { + bindings.iter().find(|b| { + let q = b + .alias + .as_deref() + .unwrap_or(b.table.as_str()); + q.eq_ignore_ascii_case(&item.text) + }) + }) + .flatten(); + if let Some(binding) = qualifier_binding { + let key = if binding.alias.is_some() { + "diagnostic.alias_used_as_column" + } else { + "diagnostic.table_used_as_column" + }; + diagnostics.push(Diagnostic { + severity: Severity::Error, + span: item.span, + message: crate::friendly::translate( + key, + &[( + "name", + &item.text as &dyn std::fmt::Display, + )], + ), + }); + continue; + } let table_arg = if bindings.len() == 1 { bindings[0].table.clone() } else { @@ -6330,6 +6384,121 @@ mod tests { ); } + // ---- Issue #31 — bare table alias / table used as a column ---- + + #[test] + fn bare_table_alias_in_group_by_is_alias_hint_not_unknown_column() { + // Issue #31: `… GROUP BY o` where `o` aliases a FROM source. + // The learner means `o.`; the diagnostic must point + // at the qualified form, NOT call `o` an unknown column. + let schema = two_table_schema(); + let diags = diag_keys( + "select a.id from a o join b on a.id = b.id group by o", + &schema, + ); + assert!( + diags + .iter() + .any(|d| d.contains("`o` is a table alias") && d.contains("o.")), + "expected alias_used_as_column hint; got {diags:?}", + ); + assert!( + !diags.iter().any(|d| d.contains("no such column")), + "unknown_column must not fire for an in-scope alias; got {diags:?}", + ); + } + + #[test] + fn bare_table_alias_in_projection_is_alias_hint() { + // The same applies outside GROUP BY — a bare alias in the + // projection (`SELECT o …`) is equally not a column. + let schema = two_table_schema(); + let diags = + diag_keys("select o from a o join b on a.id = b.id", &schema); + assert!( + diags.iter().any(|d| d.contains("`o` is a table alias")), + "expected alias_used_as_column hint in projection; got {diags:?}", + ); + } + + #[test] + fn bare_unaliased_table_used_as_column_is_table_hint() { + // An un-aliased FROM source referenced bare gets the + // table-form hint (qualify with the table name). + let schema = two_table_schema(); + let diags = diag_keys("select id from a group by a", &schema); + assert!( + diags + .iter() + .any(|d| d.contains("`a` is a table") && d.contains("a.")), + "expected table_used_as_column hint; got {diags:?}", + ); + } + + #[test] + fn genuine_unknown_column_still_reports_no_such_column() { + // Regression guard: the alias branch must not swallow a + // genuine typo. `nope` matches no alias, no table, no column. + let schema = two_table_schema(); + let diags = diag_keys( + "select a.id from a o join b on a.id = b.id group by nope", + &schema, + ); + assert!( + diags.iter().any(|d| d.contains("no such column") && d.contains("nope")), + "a genuine unknown column must still report no such column; got {diags:?}", + ); + } + + #[test] + fn aliased_table_referenced_by_real_name_is_not_table_hint() { + // DA guard (issue #31): a source aliased as `x` must be + // referenced by the alias — `FROM a x … GROUP BY a` is invalid + // SQL, so we must NOT advise `a.`. The branch matches + // the *effective qualifier* (the alias when present), so `a` + // (the now-shadowed table name) falls through to the generic + // unknown_column rather than wrong qualified-form advice. + let schema = two_table_schema(); + let diags = diag_keys( + "select x.id from a x join b on x.id = b.id group by a", + &schema, + ); + assert!( + diags.iter().any(|d| d.contains("no such column") && d.contains("`a`")), + "an aliased table referenced by its real name must fall through to \ + unknown_column; got {diags:?}", + ); + assert!( + !diags.iter().any(|d| d.contains("is a table")), + "must not advise `a.` when `a` is aliased as `x`; got {diags:?}", + ); + } + + #[test] + fn dsl_bare_table_name_in_where_keeps_unknown_column() { + // DA guard (issue #31): the alias/table hint is SQL-only + // (role `sql_expr_ident`). The DSL `Expr` (role `expr_column`) + // has no `table.column` syntax, so advising the qualified form + // would be wrong. A DSL bare table-name ref stays the generic + // unknown_column it was before issue #31. + let schema = + schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]); + for input in [ + "show data Customers where Customers = 5", + "update Customers set Name = 'x' where Customers = 5", + ] { + let diags = diag_keys_simple(input, &schema); + assert!( + diags.iter().any(|d| d.contains("no such column")), + "DSL bare table ref must stay unknown_column for {input:?}; got {diags:?}", + ); + assert!( + !diags.iter().any(|d| d.contains("is a table")), + "DSL must not get SQL qualified-form advice for {input:?}; got {diags:?}", + ); + } + } + // ---- ADR-0032 §11.2 — compound_arity_mismatch ---- #[test] diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index b26d01e..57e2ed3 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -38,6 +38,7 @@ /// `(key, expected_placeholders)`. Sorted by key for grep-ability. pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ // ---- Pre-submit diagnostics (ADR-0027) ---- + ("diagnostic.alias_used_as_column", &["name"]), ("diagnostic.ambiguous_column", &["column", "qualifiers"]), ("diagnostic.auto_column_overridden", &["column", "type"]), ("diagnostic.compound_arity_mismatch", &["op", "left_n", "right_n"]), @@ -63,6 +64,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("diagnostic.not_null_missing", &["column"]), ("diagnostic.like_numeric", &["column", "type"]), ("diagnostic.projection_alias_misplaced", &["alias", "clause"]), + ("diagnostic.table_used_as_column", &["name"]), ("diagnostic.type_mismatch", &["column", "type"]), ("diagnostic.unknown_column", &["name", "table"]), ("diagnostic.unknown_qualifier", &["qualifier"]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 8bfb9c5..5999ff8 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -640,6 +640,12 @@ diagnostic: unknown_qualifier: "no such table or alias in scope: `{qualifier}`" ambiguous_column: "`{column}` is ambiguous — appears in {qualifiers}" projection_alias_misplaced: "alias `{alias}` cannot be used in {clause} — aliases are not bound until after `SELECT`'s projection list" + # Issue #31: a bare table alias / table name used where the grammar + # expects a column (e.g. `GROUP BY o`). The name *is* in scope — it + # is the alias of a FROM source — so calling it an "unknown column" + # misleads. Point the learner at the qualified `alias.column` form. + alias_used_as_column: "`{name}` is a table alias — write `{name}.` to reference one of its columns" + table_used_as_column: "`{name}` is a table — write `{name}.` to reference one of its columns" cte_arity_mismatch: "CTE `{cte}` declares {declared} columns but its body has {actual}" compound_arity_mismatch: "`{op}` requires both sides to have the same number of columns — left has {left_n}, right has {right_n}" duplicate_cte: "duplicate `WITH` table name: `{name}`" diff --git a/src/input_render.rs b/src/input_render.rs index 47efe00..cf11299 100644 --- a/src/input_render.rs +++ b/src/input_render.rs @@ -1765,6 +1765,65 @@ mod tests { cache } + fn issue31_join_cache() -> crate::completion::SchemaCache { + use crate::completion::{SchemaCache, TableColumn}; + use crate::dsl::types::Type; + let mut cache = SchemaCache::default(); + let tables: &[(&str, &[(&str, Type)])] = &[ + ("Customers", &[("id", Type::Serial), ("name", Type::Text)]), + ( + "Products", + &[("id", Type::Serial), ("name", Type::Text), ("price", Type::Decimal)], + ), + ( + "OrderLines", + &[ + ("id", Type::Serial), + ("order_id", Type::Int), + ("product_id", Type::Int), + ("count", Type::Int), + ], + ), + ( + "Orders", + &[("id", Type::Serial), ("customer_id", Type::Int), ("date", Type::Date)], + ), + ]; + for (t, cols) in tables { + cache.tables.push((*t).to_string()); + let tc: Vec = + cols.iter().map(|(n, ty)| TableColumn::new(*n, *ty)).collect(); + for c in &tc { + cache.columns.push(c.name.clone()); + } + cache.table_columns.insert((*t).to_string(), tc); + } + cache + } + + #[test] + fn issue31_group_by_partial_alias_shows_alias_hint() { + // Issue #31 end-to-end: the manual-testing query ended in + // `… group by o`, where `o` aliases `Orders`. The ambient + // hint must guide the learner to `o.`, not claim + // `o` is an unknown column. + let cache = issue31_join_cache(); + let input = "select c.name as customer_name, o.id as order_id, o.date, sum(ol.count*p.price) as total from Orders o join OrderLines ol on o.id=ol.order_id join Products p on p.id=ol.product_id join Customers c on c.id=o.customer_id group by o"; + match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) { + Some(AmbientHint::Prose(p)) => { + assert!( + p.contains("`o` is a table alias") && p.contains("o."), + "expected the alias hint; got: {p:?}", + ); + assert!( + !p.contains("no such column"), + "must not show the misleading unknown-column message; got: {p:?}", + ); + } + other => panic!("expected a Prose alias hint; got: {other:?}"), + } + } + #[test] fn ambient_hint_at_insert_first_value_shows_int_prose() { use crate::dsl::types::Type; From 3d4a0fd45e7a29d91b7ba6a9b3a5ba1370640e80 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 14:42:22 +0000 Subject: [PATCH 02/50] fix(render): trim IEEE-754 noise from displayed decimal arithmetic (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `decimal` is stored as exact TEXT, but SQLite has no native decimal type, so arithmetic/aggregation implicitly coerces it to an IEEE-754 double. The computed result carries no playground type, so `sum(price * qty)` rendered the double's full noise — `298.59999999999997` for `298.60` — a confusing, off-topic float lesson for a teaching tool. Add `format_real_display`: round REAL values to 15 significant figures (a double's reliable precision) then take the shortest round-tripping form, collapsing `298.59999999999997` to `298.6`. Wired into `format_cell` (result-set / `show data` cells) only — the sole surface where the noise appears, since it arises from arithmetic. Every other f64->string path keeps full precision for semantic, not cosmetic, reasons: CSV persistence stays byte-exact for round-trip; `render_value` is a canonical identity key for the uniqueness dry-runs (dry_run_unique, check_uniqueness_collisions), where rounding would report collisions the exact-valued engine wouldn't; FK-key matching and EXPLAIN-SQL literals likewise stay exact. ADR-0005 Amendment 1; +7 tests. --- docs/adr/0005-column-type-vocabulary.md | 62 ++++++++++++ docs/adr/README.md | 2 +- src/db.rs | 121 +++++++++++++++++++++++- tests/it/sql_select.rs | 61 ++++++++++++ 4 files changed, 244 insertions(+), 2 deletions(-) diff --git a/docs/adr/0005-column-type-vocabulary.md b/docs/adr/0005-column-type-vocabulary.md index 3714ec1..91a67bf 100644 --- a/docs/adr/0005-column-type-vocabulary.md +++ b/docs/adr/0005-column-type-vocabulary.md @@ -70,3 +70,65 @@ True UUIDs are intentionally **not** in the type set. - Learners who later need a true UUID column will find that the app does not provide one; this is a deliberate trade-off in favour of TUI legibility. + +## Amendment 1 — display rounding of coerced doubles (2026-06-12) + +Issue #32. The Decision keeps `decimal` exact by storing it as +TEXT, noting that "numeric ops require casts" — the engine has no +native decimal/BCD type (SQLite's storage classes are only NULL / +INTEGER / REAL / TEXT / BLOB; `NUMERIC` is an affinity, not a +type). What the original wording did not anticipate is that the +engine performs that cast **implicitly**: `sum(price * qty)` over +TEXT decimals coerces to an IEEE-754 double with no explicit cast, +and the computed result carries no playground type (ADR-0030 §6), +so it rendered with the double's full noise — +`298.59999999999997` for `298.60`. For a teaching tool that is a +confusing, off-topic lesson about float representation. + +### Decision + +**Round floating-point values to 15 significant figures for +display only.** A double carries ~15–17 significant decimal digits +and the noise lives in the last one or two; rounding to 15 then +taking the shortest round-tripping form of the rounded value +collapses `298.59999999999997` → `298.6` and +`0.30000000000000004` → `0.3`. A clean value rounds to itself, so +the result is never longer than before; non-finite values pass +through. Implemented as `format_real_display` in `db.rs`. + +The rounding is wired into **exactly one place — `format_cell`, +the result-set / `show data` cell formatter** — because that is +the only surface where the IEEE-754 noise actually appears: noise +arises from *arithmetic/aggregation*, whose results flow through +`format_cell`. Every other `f64`-to-string path deliberately keeps +full precision, and the distinction is **semantic, not cosmetic**: + +- **Persistence stays exact.** The CSV encoder + (`persistence::csv_io::format_real`) keeps the shortest + round-tripping form so a stored `real` survives save/load + byte-for-byte — rounding there would corrupt data. +- **Uniqueness dry-runs key on exact values.** `render_value` + (the diagnostic/echo formatter) is reused as a *canonical + identity key* by `dry_run_unique` (ADR-0029 §5) and + `check_uniqueness_collisions` (ADR-0017 §4.3): they group rows + by this string to predict the duplicates the engine would + reject. Rounding there would merge two distinct doubles into one + key and report a collision the engine — which compares exact + values — would not. So `render_value` keeps `format!("{r}")`. + (It also never displays a *computed* value, so it has no noise + to trim.) +- **FK-key matching and EXPLAIN-SQL literals keep full + precision** — neither is a data-cell display. + +Within `format_cell` the rounding applies to **all** REAL cells +(stored `real` columns and computed results alike), for one +consistent rule; the lost digits are at the double's precision +limit, not real information, and a stored `real` typed by the user +is itself noise-free so its display is unchanged in practice. Raw +`decimal` columns are unaffected — they are TEXT and render +verbatim, trailing zeros and all (`100.10`). Exact decimal +*arithmetic* (a SQLite extension exposing +`decimal_mul`/`decimal_sum`) was considered and rejected: it would +require rewriting the user's standard-SQL operators into function +calls, defeating both the "validated SQL runs verbatim" model and +the goal of teaching ordinary SQL. diff --git a/docs/adr/README.md b/docs/adr/README.md index 0ded724..2e15522 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -10,7 +10,7 @@ This directory contains the project's ADRs, recorded per - [ADR-0002 — Database engine](0002-database-engine.md) - [ADR-0003 — Input modes and command dispatch](0003-input-modes-and-command-dispatch.md) — the persistent `Simple`/`Advanced` mode and the `:` one-shot escape. The **startup mode is no longer always `simple`**: it is restored from the project's stored mode and overridable with `--mode` (see **ADR-0015 Amendment 1**, issue #14). The app-command registry gains **`copy`** (ADR-0041, issue #11) - [ADR-0004 — Project file format](0004-project-file-format.md) -- [ADR-0005 — Column type vocabulary](0005-column-type-vocabulary.md) +- [ADR-0005 — Column type vocabulary](0005-column-type-vocabulary.md) — the ten-type set (`text`/`int`/`real`/`decimal`/`bool`/`date`/`datetime`/`blob`/`serial`/`shortid`), compound PKs, no true UUIDs; `decimal` stored as exact TEXT. **Amendment 1, 2026-06-12** (issue #32): SQLite has no native decimal/BCD type, so arithmetic/aggregation over a TEXT `decimal` is implicitly coerced to an IEEE-754 double and the computed (typeless) result leaked float noise (`298.59999999999997` for `298.60`); floating-point values are now rounded to **15 significant figures for display only** (`format_real_display` in `db.rs`, wired into `format_cell` — the result-set/`show data` cell formatter, the only surface where arithmetic noise surfaces) while every other f64→string path keeps full precision because the distinction is *semantic*: persistence (`csv_io::format_real`) stays byte-exact for round-trip; `render_value` is a *canonical identity key* for the uniqueness dry-runs (`dry_run_unique` ADR-0029 §5, `check_uniqueness_collisions` ADR-0017 §4.3) so rounding it would report collisions the exact-valued engine wouldn't; FK-key matching and EXPLAIN-SQL literals likewise stay exact — so stored `real`/`decimal` round-trips stay byte-exact and raw `decimal` columns render verbatim - [ADR-0006 — Undo snapshots and replay log](0006-undo-snapshots-and-replay-log.md) — **Accepted**. The **replay/journal half** (U3/U4) shipped via ADR-0034; the **undo/snapshot half** (U1/U2) is settled by **Amendment 1 (2026-05-24)** and **implemented 2026-05-24** (plan: `docs/plans/20260524-adr-0006-undo-snapshots.md`; ring in `src/undo.rs`, worker hook in `src/db.rs`). Amendment 1 **supersedes the original "snapshots only before destructive operations" model**: a snapshot is taken before **every** data/schema mutation (DSL + SQL) for familiar single-step (Ctrl-Z) undo — so the confirmation collapses to *naming the one command being undone* (no db-diff). Snapshot is a **hybrid whole-project copy** — database via the online backup API **plus** `project.yaml`/`data/*.csv` as files — reconciling this ADR with ADR-0015's "text is authoritative, db is derived"; undo restores all three directly. Staged before the mutation's transaction, finalised after the db commit (preserves ADR-0015 §6 commit-db-last); rolled-back ops leave no snapshot. **Persisted** ring under `.snapshots/`, **N = 50** (raised from 10), git-ignored + export-excluded + temp-cleanup-aware. `redo` supported, **redo stack discarded on new work**. **Batch ops record one undo step** (`replay` + future batch via a Begin/EndBatch worker primitive); **`import` is outside undo** (it switches projects per ADR-0015 §11, leaving the current project untouched). A **`--no-undo` CLI flag** disables snapshotting (hardware escape hatch). Adds the `backup` feature to `rusqlite` - [ADR-0007 — Sharing and export](0007-sharing-and-export.md) - [ADR-0008 — Testing approach](0008-testing-approach.md) diff --git a/src/db.rs b/src/db.rs index 19e4d07..a057ebe 100644 --- a/src/db.rs +++ b/src/db.rs @@ -6041,6 +6041,16 @@ fn render_value(v: &rusqlite::types::Value) -> String { match v { RV::Null => "(null)".to_string(), RV::Integer(i) => i.to_string(), + // Full-precision, shortest round-trip — NOT the issue-#32 + // display rounding. `render_value` is reused as a *canonical + // identity key* by the uniqueness dry-runs (`dry_run_unique`, + // `check_uniqueness_collisions`, ADR-0017 §4.3 / ADR-0029 §5): + // they group rows by this string to detect duplicates the + // engine would reject. Rounding here would merge two distinct + // doubles into one key and report a collision the engine — + // which compares exact values — would not. Display rounding + // lives in `format_cell` (query / `show data` cells) only, + // where no value is ever used as a key. RV::Real(r) => format!("{r}"), RV::Text(s) => s.clone(), RV::Blob(_) => "".to_string(), @@ -10809,12 +10819,49 @@ fn format_cell(value: rusqlite::types::Value, ty: Option) -> Option Some(format!("{r}")), + V::Real(r) => Some(format_real_display(r)), V::Text(s) => Some(s), V::Blob(b) => Some(format!("", b.len())), } } +/// Render a floating-point value for **display**, trimming the +/// IEEE-754 noise that surfaces when the engine coerces a +/// `decimal` (stored as exact TEXT, ADR-0005) to a double for +/// arithmetic or aggregation (issue #32): `sum(price * qty)` +/// would otherwise show `298.59999999999997` for `298.60`. +/// +/// A double carries ~15–17 significant decimal digits and the +/// noise lives in the last one or two, so we round to 15 +/// significant figures and then take the *shortest* round-tripping +/// form of the rounded value. That collapses +/// `298.59999999999997` → `298.6` and `0.30000000000000004` → +/// `0.3`. A clean value rounds to itself, so the result is never +/// longer than the previous `format!("{r}")` — the magnitude and +/// whatever digit expansion `Display` already chose are preserved +/// (Rust's `Display` for `f64` never uses scientific notation, so +/// a very large value still expands to full digits, exactly as +/// before). Non-finite values pass through unchanged. +/// +/// DISPLAY ONLY, and only for genuine display cells. The CSV +/// encoder (`persistence::csv_io::format_real`) keeps the exact +/// shortest round-trip so a stored `real` survives save/load +/// byte-for-byte; the uniqueness dry-runs key on the full-precision +/// `render_value`; FK-key matching and EXPLAIN-SQL literals keep +/// full precision too. Rounding any of those would change behaviour, +/// not just looks. This is wired only into `format_cell`. +fn format_real_display(r: f64) -> String { + if !r.is_finite() { + return format!("{r}"); + } + // `{:.14e}` is a 15-significant-figure scientific form (one + // mantissa digit + 14 after the point); parsing it back and + // letting `Display` pick the shortest exact form drops the + // trailing IEEE-754 noise. + let rounded: f64 = format!("{r:.14e}").parse().unwrap_or(r); + format!("{rounded}") +} + /// Capture the query plan for an explainable command /// (ADR-0028 §2). Matches the inner command, builds the exact /// SQL it would otherwise run via the shared `build_*_sql` @@ -11442,6 +11489,78 @@ mod tests { Database::open(":memory:").expect("open in-memory") } + // ---- Issue #32 — display rounding of coerced doubles ---- + + #[test] + fn format_real_display_trims_ieee754_noise() { + // The reported case and the classic float artifacts. + assert_eq!(format_real_display(298.599_999_999_999_97), "298.6"); + assert_eq!(format_real_display(0.1 + 0.2), "0.3"); + assert_eq!(format_real_display(59.97), "59.97"); + } + + #[test] + fn format_real_display_keeps_whole_numbers_and_sign() { + assert_eq!(format_real_display(5.0), "5"); + assert_eq!(format_real_display(0.0), "0"); + assert_eq!(format_real_display(-298.599_999_999_999_97), "-298.6"); + assert_eq!(format_real_display(1.5e-12), "0.0000000000015"); + } + + #[test] + fn format_real_display_preserves_clean_values_no_regression() { + // A value with no IEEE-754 noise rounds to itself, so the + // helper is never *worse* (or longer) than the previous + // `format!("{r}")` — including a very large magnitude, which + // `Display` expands to full digits both before and after. + for v in [1e300_f64, 1.25_f64, 1.5e-12_f64, 42.0_f64, -0.5_f64] { + assert_eq!( + format_real_display(v), + format!("{v}"), + "clean value {v} must format exactly as before", + ); + } + } + + #[test] + fn render_value_keeps_full_precision_for_identity_grouping() { + // DA guard (issue #32): `render_value` must NOT adopt the + // display rounding — the uniqueness dry-runs key on its output + // to detect duplicates the engine (which compares exact + // values) would reject. Two adjacent doubles that agree to 15 + // significant figures must still render to *distinct* strings, + // or `dry_run_unique` / `check_uniqueness_collisions` would + // report a false collision. + use rusqlite::types::Value as V; + let a = 1.0_f64; + let b = 1.000_000_000_000_000_2_f64; // 1.0.next_up() + assert_ne!(a, b, "the two doubles are genuinely distinct"); + assert_eq!(render_value(&V::Real(a)), "1"); + assert_eq!(render_value(&V::Real(b)), "1.0000000000000002"); + assert_ne!( + render_value(&V::Real(a)), + render_value(&V::Real(b)), + "render_value must keep distinct doubles distinct (identity key)", + ); + // And the display formatter *does* round both to the same + // string — which is exactly why it must not be used as a key. + assert_eq!(format_real_display(a), format_real_display(b)); + } + + #[test] + fn format_real_display_passes_through_non_finite() { + assert_eq!(format_real_display(f64::INFINITY), "inf"); + assert_eq!(format_real_display(f64::NEG_INFINITY), "-inf"); + assert_eq!(format_real_display(f64::NAN), "NaN"); + } + + #[test] + fn format_real_display_rounds_to_fifteen_significant_figures() { + // 1/3 collapses to 15 sig figs (the noise past that is the + // double's representation limit, not real precision). + assert_eq!(format_real_display(1.0 / 3.0), "0.333333333333333"); + } + fn col(name: &str, ty: Type) -> ColumnSpec { ColumnSpec::new(name, ty) } diff --git a/tests/it/sql_select.rs b/tests/it/sql_select.rs index 791b5d8..eb79c9f 100644 --- a/tests/it/sql_select.rs +++ b/tests/it/sql_select.rs @@ -175,6 +175,67 @@ fn open_project_db() -> (project::Project, Database, tempfile::TempDir) { (project, db, dir) } +#[test] +fn decimal_aggregation_display_trims_ieee754_noise() { + // Issue #32: `decimal` is stored as exact TEXT, but SQLite + // coerces it to an IEEE-754 double for arithmetic/aggregation, + // so `sum(price * qty)` would render `298.59999999999997` for + // `298.60`. The display layer rounds computed REAL cells to ~15 + // significant figures, trimming that noise — while raw decimal + // columns stay byte-exact (TEXT, untouched). + let (_p, db, _dir) = open_project_db(); + let rt = rt(); + rt.block_on(async { + db.create_table( + "Products".to_string(), + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("price", Type::Decimal), + ColumnSpec::new("qty", Type::Int), + ], + vec!["id".to_string()], + None, + ) + .await + .expect("create"); + for (price, qty) in [("19.99", 3), ("5.49", 7), ("100.10", 2)] { + db.insert( + "Products".to_string(), + Some(vec!["price".to_string(), "qty".to_string()]), + vec![ + Value::Number(price.to_string()), + Value::Number(qty.to_string()), + ], + None, + ) + .await + .expect("insert"); + } + }); + + // The reported case: the aggregate no longer leaks float noise. + let agg = rt + .block_on(db.run_select("select sum(price * qty) from Products".to_string(), None)) + .expect("aggregate select"); + assert_eq!( + agg.rows[0][0].as_deref(), + Some("298.6"), + "sum(price*qty) must trim IEEE-754 noise (298.60), not show 298.59999999999997", + ); + + // Raw decimal column is still exact — TEXT storage preserves + // the input string verbatim, including the trailing zero. + let raw = rt + .block_on(db.run_select("select price from Products".to_string(), None)) + .expect("raw decimal select"); + let prices: Vec<&str> = raw.rows.iter().map(|r| r[0].as_deref().unwrap()).collect(); + assert_eq!( + prices, + vec!["19.99", "5.49", "100.10"], + "raw decimal column must stay byte-exact (TEXT storage untouched)", + ); +} + #[test] fn database_run_select_constant_returns_a_single_row() { let (_p, db, _dir) = open_project_db(); From fde50ce3bf91d638cd97999ee49e179cb6d916d7 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 15:01:26 +0000 Subject: [PATCH 03/50] fix(ui): mark sidebar focus with an accent colour, not bold (#25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The focused sidebar panel border (ADR-0046 DC3) was bright `fg` plus `Modifier::BOLD`. Bold box-drawing glyphs render as broken/gapped line-art in the asciinema cast player and are fragile in some terminals. `panel_border_style` now marks focus with a non-bold accent colour (`theme.mode_simple`, blue); the unfocused border stays muted. Bold is untouched on text spans (titles, key hints) — the constraint is specifically that box-drawing borders carry no bold attribute. Pure style change: the Tier-2 snapshots are text-only so none needed re-accepting; the Tier-1 assertion was updated and a render-level test now checks the rendered border cells carry the accent and no bold. ADR-0046 Amendment 1. --- ...ar-navigation-and-responsive-input-hint.md | 24 ++++++ docs/adr/README.md | 2 +- src/ui.rs | 78 +++++++++++++++++-- 3 files changed, 95 insertions(+), 9 deletions(-) diff --git a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md index 3305a11..fe10f80 100644 --- a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md +++ b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md @@ -554,3 +554,27 @@ All tiers green, zero skips; clippy clean (nursery). and is accepted: 90 is the screencast width, real terminals sit well to one side of it, and `Ctrl-O` peek covers the in-between case. The `90` threshold is a tunable constant. + +## Amendment 1 — focus accent is a colour, not bold (2026-06-12) + +Issue #25. DC3's "accent border" on the focused sidebar panel was +first implemented as bright `theme.fg` **plus `Modifier::BOLD`** on +the box-drawing border. Bold box-drawing glyphs render as broken / +gapped line-art in the asciinema player used for the website casts +(vertical strokes don't connect to the corner glyphs) and are +fragile in some terminals. + +**`panel_border_style` now marks focus with a non-bold accent +colour — `theme.mode_simple` (blue) — and never `Modifier::BOLD` on +a border.** The unfocused border stays muted `theme.border`. This +makes the ADR's "accent border (lazygit convention)" wording +literal — it is now a true accent hue rather than bold bright-fg — +and is what renders cleanly in casts. Bold remains fine on *text* +spans (titles, key hints); the constraint is specifically that +box-drawing borders carry no bold attribute. + +Note: this is a pure style change. The Tier-2 snapshots are +text-only (`render_to_string` captures cell symbols, not styles), +so none needed re-accepting; the Tier-1 `panel_border_style` +assertion was updated and a render-level test now checks the actual +border cells carry the accent colour and no bold. diff --git a/docs/adr/README.md b/docs/adr/README.md index 2e15522..3f101b1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -51,6 +51,6 @@ This directory contains the project's ADRs, recorded per - [ADR-0043 — Compound-primary-key foreign-key references (T3)](0043-compound-pk-foreign-key-references.md) — **Accepted + implemented 2026-06-09** (all four forks confirmed at the recommended option: full-PK matching, house-style uniform lists, parenthesized DSL syntax, bare-SQL-FK auto-expansion). Closes `requirements.md` **T3** `[x]` — the relationship model went list-based across six layers (single-column preserved, no migration), DSL `from P.(a,b) to C.(x,y)` + SQL `FOREIGN KEY (a,b) REFERENCES P(x,y)` parse/execute/enforce, 12 tests in `tests/it/compound_fk.rs`. Closes the open leg of `requirements.md` **T3**: a foreign key that *references* a parent's compound primary key. A 2026-06-09 audit found single-column FK woven through ~15–20 sites (metadata table, `RelationshipSchema`, `project.yaml` `RawEndpoint`, both grammar surfaces, executor FK-DDL emission, per-column type-compat, display) — earns an ADR, not an inline build. **Decision:** reference the parent's **full** compound PK, matched **positionally** to an equal-length child column list, per-pair `fk_target_type` compat (ADR-0011, element-wise); DSL `from

.(a, b) to .(x, y)` (single form unchanged), SQL `FOREIGN KEY (x, y) REFERENCES P(a, b)` (extend the existing one-cap lists; bare table-level FK auto-expands to the parent PK when arities match). **Storage — no migration (back-compat not required, user-confirmed 2026-06-09; no installed base):** the relationship endpoint joins the list convention `project.yaml` *already* uses — `columns: [a, b]` like `primary_key: [id]` and index `columns: [...]` (the endpoint was the lone scalar `column:` holdout); the metadata `TEXT` columns are unchanged and store the list **comma-joined** (`a,b`; the bare name for single — safe because identifiers are `[A-Za-z0-9_]+`). No F3 migrator, no version bump; accepted trade-off is that a pre-change `project.yaml` with relationships won't load (clean cutover). In-memory model goes list-based (`Vec`) through all six layers; the enforced FK is the rebuilt child-table DDL (`FOREIGN KEY (a,b) REFERENCES P(x,y)`), one relationship = one undo step (ADR-0013). Genuine forks escalated: matching policy (full-PK vs subset), storage (house-style uniform lists vs normalized table), DSL syntax (parenthesized vs repeated-dotted), bare-SQL-FK auto-expansion. OOS: subset/non-PK (UNIQUE-targeted) FK references; any single-column behaviour change - [ADR-0044 — Relationship visualization (two-table connector diagrams)](0044-relationship-visualization.md) — **Accepted 2026-06-09; implemented 2026-06-10** (closes `requirements.md` V1; second `/runda` pass over the implementation; §3 last-resort helper line considered and rejected). Resolves **ADR-0016 OOS-1** and closes the open half of `requirements.md` **V1** ("a selected relationship as two tables joined by a line"). Renders a relationship as **Style A** (two structure boxes + connector). **Reach = "relationship-relevant"** (user-chosen over global / show-only): diagrams on the surfaces where the relationship is the *subject* — `show relationship ` (one full diagram), `show table ` (T's structure box then a **Relationships** section of **stacked compact** per-relationship diagrams — chosen over a focal-centred subgraph: no crossing lines, scales via scroll, two-boxes-wide fits any terminal), and relationship DDL echoes (`add`/`drop`/`modify relationship`); incidental DDL echoes (`add column`, `drop index`, `change column`, plain `create table`) keep the terse prose, via a `Diagram`|`Prose` render mode on `render_structure`. Reading convention **child(FK)-left / parent-right, arrow →, `n`…`1` cardinality**, applied uniformly; every box gets a **bold title row + rule** so the name can't read as a column. **Compound FKs** (ADR-0043) route one connector per positional pair + an explicit pairing line. **Width-aware** (first in the codebase) but **App-side**: `render_structure`/diagram rendering runs in `app.rs` (the worker only returns `TableDescription`s), a new `App::last_output_width` (set from `ui.rs`) drives side-by-side vs a **vertical-stack** fallback + last-resort "run `show relationship`" pointer; rendered once at command time, **no live reflow** (V4). `show relationship`'s worker path (`do_show_one`, prose-only) is restructured to return both endpoint `TableDescription`s. Styling reuses **ADR-0028** App-side styled runs (new classes: table-name/key/connector/cardinality/action) — no worker→UI contract change. **Partially supersedes ADR-0016 §5** (prose block replaced on relationship-subject surfaces, retained on incidental ones); extends §4 (layout width-awareness, still no cell truncation) and §6 (per-span theming). Tests: insta snapshots (single, compound, vertical fallback, helper line, self-referential, multi-rel `show table`) + width-threshold/routing unit tests + Tier-3 wiring; enumerated prose-fallout updates (`output_render.rs:121/135/793`, the relationships snapshot, `walking_skeleton.rs:477/530`). A `/runda` DA pass corrected three inverted-architecture claims (App-side rendering, untracked width, prose-in-worker show-relationship) before acceptance. OOS: user-configurable display setting (OOS-7), live reflow (V4), whole-DB ER export (V3), m:n (C4), ASCII fallback (ADR-0016 OOS-5) - [ADR-0045 — `create m:n relationship` convenience command (C4)](0045-mn-convenience.md) — **Accepted + implemented 2026-06-10** (closes `requirements.md` **C4**; all forks user-confirmed + a `/runda` DA pass that verified the `do_create_table` reuse against code and corrected the "no PK-less tables" assumption — advanced SQL `create table t (a int)` has none, so a parent-PK guard is retained). Implementation corrected a second ADR premise: "the walker already dispatches multiple nodes per entry word" held only in *advanced* mode — two simple-mode spots (dispatcher `decide`, completion continuation-merge) assumed ≤1 DSL form per entry word and were generalized **behaviour-preservingly** (dispatch reduces to the old single-candidate commit; completion merge gated on `simple_count > 1`). Junction echo wired (`render_create_m2n`, round-trips as SQL). `create m:n relationship from to [as ]` generates a junction table with one FK column per parent PK column, a **compound PK over all the FK columns** (the textbook junction — the pair is unique, no duplicate links), and **two 1:n relationships**, all in **one transaction = one undo step** (built by reusing `do_create_table`, which already takes `foreign_keys` + writes relationship metadata — no batch bracketing). Forks all user-chosen: junction PK = compound-over-FKs (vs surrogate serial / no PK); referential actions = **`CASCADE`** on delete+update (vs NO ACTION / RESTRICT); naming = auto `{T1}_{T2}` + optional `as` (vs auto-only); available in **both modes** (Simple-category DSL, like the sibling relationship commands). FK columns named `{parent_table}_{pk_column}` (disambiguates shared `id`; generalises to compound parents via ADR-0043), typed via `fk_target_type` (ADR-0011). A distinct `Command::CreateM2nRelationship` (not lowered to `CreateTable`) preserves command identity (X5) and lets the teaching echo speak in m:n terms. Cross-cutting wiring enumerated: separate `CREATE_M2N` `CommandNode` (own `help_id`/`usage_ids`), `("m","m:n")` completion composite, `HintMode`s, grammar-driven highlighting, `help`/`help create`, `parse_error_pedagogy` near-miss matrix, teaching echo. OOS: **self-referential m:n** (`from T to T`) refused outright (user-confirmed "full stop" — directional column-naming is more than this beginner convenience warrants); per-relationship action overrides; extra junction payload columns; m:n diagram echo; renaming the auto-generated relationships -- [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted + implemented 2026-06-10, phased A→B→C** (8 commits `9f5f76b`…`22bec61`; closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Two decisions landed differently from the draft (recorded inline): relationship data on **`App`** not `SchemaCache` (DB2); the nav overlay clears **only the sidebar strip + a one-column gutter**, panels staying visible behind (DC2). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). the full records live on a new **`App.relationships`** field (revised from the ADR's original `SchemaCache.relationship_details` at implementation — `SchemaCache` is walker-facing and needs only the names, kept in `relationships: Vec`; details are UI-only, so `App` mirrors `app.tables` and avoids ~23 fixture edits), delivered by `Database::read_all_relationships` + an `AppEvent::RelationshipsRefreshed`; the two left panels split vertically with the relationships panel floored at 5 rows ("(none)" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears) +- [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted + implemented 2026-06-10, phased A→B→C** (8 commits `9f5f76b`…`22bec61`; closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Two decisions landed differently from the draft (recorded inline): relationship data on **`App`** not `SchemaCache` (DB2); the nav overlay clears **only the sidebar strip + a one-column gutter**, panels staying visible behind (DC2). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). the full records live on a new **`App.relationships`** field (revised from the ADR's original `SchemaCache.relationship_details` at implementation — `SchemaCache` is walker-facing and needs only the names, kept in `relationships: Vec`; details are UI-only, so `App` mirrors `app.tables` and avoids ~23 fixture edits), delivered by `Database::read_all_relationships` + an `AppEvent::RelationshipsRefreshed`; the two left panels split vertically with the relationships panel floored at 5 rows ("(none)" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears); **Amendment 1, 2026-06-12** (issue #25): DC3's focus accent is now a **non-bold accent colour** (`theme.mode_simple`, blue) rather than bold bright-`fg` — bold box-drawing glyphs render as broken/gapped line-art in the asciinema cast player (and are fragile in some terminals), so `panel_border_style` carries no `Modifier::BOLD` on a border (bold stays fine on text spans); pure style change — the text-only Tier-2 snapshots were unaffected, the Tier-1 assertion was updated, and a render-level test now checks the focused border cells carry the accent and no bold - [ADR-0047 — Demonstration overlay layer (keystroke badges + step captions)](0047-demonstration-overlay-layer.md) — **Accepted 2026-06-10; implemented 2026-06-11, phased A→B→C (closes Gitea #22)** (commits `f879d54`→`2d0f4b2`; no `requirements.md` item — tracked by issue + ADR per convention; all forks user-confirmed + a pre-build `/runda` pass that produced 10 tightening findings and a whole-implementation `/runda` pass that returned PASS, no blockers). An in-app **demonstration mode** (`--demo` flag / `RDBMS_PLAYGROUND_DEMO` env, **off by default, zero footprint when off**) that renders two transient overlays so `autocast` screencasts — and live teaching, and a future guided-lesson system — can show otherwise-invisible interactions. **Keystroke badges** (`[TAB]`, `[ENTER]`, `[UP]`, …): **automatic, app-detected** over a fixed set of glyph-less keys (the app already sees every key, so it re-records for free), label via a pure `demo_badge_label(&KeyEvent)`; the badge **auto-expires on a ~1.5 s timer** that extends the runtime's existing time-boxed-`recv` arm condition (`debounce.is_armed() || badge_pending`; expiry `Instant` in the runtime, `App.demo_badge` the render mirror — mirroring the `input` vs `input_indicator` split). **Step captions**: a **stealth, control-code-delimited input buffer** toggled by **`Ctrl+]`** (byte `0x1D` → arrives as `Char('5')+CONTROL`, verified against crossterm 0.29 `parse.rs:110-113`; chosen over `Ctrl+!`, which is **not a single ASCII byte so autocast cannot send it** — the same wall as arrow keys, R4) — typed characters accumulate **invisibly** (prompt untouched, no echo/history), `Backspace` edits, other keys inert, a second `Ctrl+]` **commits** to the caption box (empty commit dismisses); lives in pure-sync `App::update()`, **intercepted before the modal gate** so captions/badges work **over the load picker** (the `#24` projects cast). Both render as **floating flat black-on-yellow rectangles** (solid fill, **no border glyphs** — a one-cell text margin, deliberately unlike the app's bordered panels; user decision post-build, `2d0f4b2`) **at the output panel's inner bottom-right**, drawn **last over modals**, badge **stacked above** the caption, **no layout reflow**; caption **word-wraps to ≤ 3 lines** (3–5 rows), badge fixed 3 rows; clamp/skip guard for tiny terminals; a new **`App.last_output_area: Rect`** (set in `render_output_panel`) gives the top-level draw the anchor. Caption persists **until the next keystroke**; badge suppressed while capturing. Forks all user-chosen: `--demo` activation (vs hidden command / chord); automatic badges (vs scripted); stealth buffer (vs typed-command / preloaded-file); floating bottom-right boxes (vs HUD / banner / subtitle); `Ctrl+]` trigger; wrap-to-3-line captions; ~1.5 s badge / next-keystroke caption timing. Tested test-first across Tier 1 (label fn, capture state machine incl. over-modal + demo-off gate, nearest-deadline helper), Tier 2 (insta snapshots: badge/caption/both-stacked at 90×26 light+dark, short-terminal clamp), Tier 3 (`--demo` plumbing, badge set/suppressed, caption-without-input wiring), CLI (`--demo` parse + env fallback) — with an **honest limit** noted: the `tokio` timer wiring inside `run_loop` is exercised via the pure pieces + Tier-3 plumbing, not a standalone integration test of the timeout (same posture as the existing `IndicatorDebounce`). One intentional, user-acknowledged behaviour: `Ctrl-C` is inert while capturing (every non-`Ctrl+]` key is, by spec). Final tally **2290 passing / 0 failing / 0 skipped** (1 long-standing ignored doctest), clippy clean. OOS: scripted/manual badge push; badges for glyph keys; configurable styling/placement; the guided-lesson system itself (own ADR); cross-session/-switch persistence; localised caption content; arrow-only cast interactions (output-pane scroll); wiring the overlays into the website `casts.mjs` scripts (website-branch follow-up). Implementation phased **A** (`--demo` plumbing) → **B** (badges) → **C** (captions) + a flat-rectangle restyle - [ADR-0048 — `seed` fake-data generation command](0048-seed-fake-data-generation.md) — **Accepted 2026-06-11; Phase 1 + Phase 2 implemented 2026-06-11** (Phase 1 commits `202e25a`→`fbd219b`; design settled with the user across an extended fork dialogue, hardened by a pre-build `/runda` pass (six blockers folded in), a post-implementation `/runda` pass (eight gaps closed — FK/shortid determinism so **D4 holds with no exceptions**, plus six untested ADR decisions), and a Phase-2 pre-build `/runda` pass (which caught the no-date-literal-token reality → the D2 quoted-dates amendment), and a post-implementation `/runda` pass (which added a friendly error for a bounded override on a UNIQUE column — see D2); **2400 tests pass, clippy clean**). Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1**. **Phase 1 shipped:** whole-row `seed [count] [--seed ]` with realistic name-aware generation (the `fake` crate + a type-gated heuristic catalogue, table-context name disambiguation, hand-rolled `product` generator, bounded dates), identifier + constraint uniqueness incl. junction distinct-combos, FK sampling from existing parent rows (empty-parent error), `IN`-CHECK derivation + complex-CHECK advisory, a required-column block guard, `--seed` reproducibility (serial/FK/shortid all deterministic), undo as one batch step, replay as a data write, a capped auto-show preview, the enum/CHECK advisory, and an O(N) single-transaction insert path. **Phase 2 shipped (2026-06-11):** the `set` override clause (D2 — fixed value / pick-list / `as ` / `between` range, **quoted** dates per the D2 amendment, type-aware, override drops the column from the advisory) and the `
.` column-fill form (D1 form 2 — an UPDATE over existing rows, refusing PK/autogen targets, empty-table no-op, FK/unique-respecting, one undo step), with the new `KNOWN_GENERATORS` vocabulary (D9), a range `Generator`, full completion/highlight (`HighlightClass::Function`)/validity (`IdentSource::Generators`)/help/pedagogy wiring, and the D13 advisory's Phase-2/3 wording. Further SD2 increments (custom generators, NULL injection, multi-locale, recursive auto-seed) out of scope. Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1** (the other being `hint`/**H2**). A dedicated `seed` command (own AST variant + `do_seed` executor, **both modes**) generating **realistic, name-aware** fake data. Two forms: **`seed
[count]`** (new rows, default **20**, capped) and **`seed
.`** (fill a column on existing rows, an UPDATE). Generation adds the **`fake` crate** (v5, English) driven by a **type-gated, token-matched name-heuristic catalogue** (~30 patterns, documented false-positive guards), with **table-context** disambiguating the `name`/`title` family (`products.name`→product, `users.name`→person, `vendors.name`→company), a **hand-rolled `product` generator** (`fake` has no commerce module), **bounded dates** (`date`/`timestamp`/`dob`/`*_at` recognised, recent windows — never "all of history"), the **identifier family** (`id`/`code`/`ref`/`number`, non-FK/non-PK) → **unique sequential**, and **enum-ish names** (`role`/`status`/`type`/…) left generic + a **post-seed Hint advisory** pointing at `set … in (…)`. A **`set` override clause** — `= value` / `in (a,b,c)` / `as ` / `between a and b` (numeric **and** date), reusing ADR-0026 operators — answers the heuristic-miss case. **`--seed `** makes runs reproducible (and enables exact-value tests). **FK** columns sampled uniformly from existing parent rows (**empty parent → friendly error**, no recursion v1); **junction/compound-PK** tables seeded with **distinct combinations**, capped + noted (SD1). A **required-column block guard** refuses rather than NULL-violate a `NOT NULL` column it can't fill (e.g. `NOT NULL blob`). Full ambient wiring (completion incl. a new generator-name vocabulary highlighted as `tok_function`, hints, `help seed`, ADR-0042 near-miss matrix, ADR-0027 validity); **no DSL→SQL teaching echo** (seed is a utility command, not a SQL twin). Honours **X5** — `do_seed` reuses insert/update *mechanics as helpers*, not by emitting `Command::Insert`. Implementation phased: (1) core whole-row seed → (2) `set` overrides → (3) column-fill. Deferred (future SD2): recursive auto-seed, NULL injection, multi-locale, user-defined custom generators, full per-column report diff --git a/src/ui.rs b/src/ui.rs index 409cf9e..16ac859 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -275,13 +275,15 @@ fn render_nav_sidebar_overlay(app: &mut App, theme: &Theme, frame: &mut Frame<'_ render_relationships_panel(app, theme, frame, parts[1]); } -/// Border style for a sidebar panel: an accented, bold border when it -/// holds navigation focus (ADR-0046 DC3), the muted border otherwise. +/// Border style for a sidebar panel: a non-bold **accent colour** +/// border when it holds navigation focus (ADR-0046 DC3, refined by +/// Amendment 1 / issue #25), the muted border otherwise. The focus +/// cue is the accent hue, NOT `Modifier::BOLD` — bold box-drawing +/// glyphs render as broken/gapped line-art in the asciinema player +/// and are fragile in some terminals. fn panel_border_style(theme: &Theme, focused: bool) -> Style { if focused { - Style::default() - .fg(theme.fg) - .add_modifier(Modifier::BOLD) + Style::default().fg(theme.mode_simple) } else { Style::default().fg(theme.border) } @@ -3027,16 +3029,76 @@ mod tests { #[test] fn focused_panel_gets_an_accent_border() { - // ADR-0046 DC3: the focused sidebar panel is accent-bordered. + // ADR-0046 DC3 (Amendment 1, issue #25): the focused sidebar + // panel is marked by a non-bold accent COLOUR, not bold. Bold + // box-drawing glyphs render as broken/gapped line-art in the + // asciinema player (and are fragile in some terminals), so the + // focus cue is the accent hue against the muted unfocused + // border — never a `Modifier::BOLD` on the border. let theme = Theme::dark(); let focused = panel_border_style(&theme, true); let normal = panel_border_style(&theme, false); - assert_eq!(focused.fg, Some(theme.fg)); - assert!(focused.add_modifier.contains(Modifier::BOLD)); + assert_eq!(focused.fg, Some(theme.mode_simple)); + assert!( + !focused.add_modifier.contains(Modifier::BOLD), + "the focused border must NOT be bold (issue #25)", + ); assert_eq!(normal.fg, Some(theme.border)); assert!(!normal.add_modifier.contains(Modifier::BOLD)); } + #[test] + fn focused_panel_border_cells_are_accent_colour_not_bold() { + // Full-stack guard for issue #25: the accent colour (and the + // absence of bold) must reach the actual rendered border cells, + // not just `panel_border_style` in isolation. With the Tables + // panel focused, its box-drawing border cells carry + // `theme.mode_simple` and never `Modifier::BOLD`; with no panel + // focused, no border cell wears the accent colour. + const BOX_DRAWING: &[char] = &['╭', '╮', '╰', '╯', '─', '│']; + let is_border = |sym: &str| sym.chars().all(|c| BOX_DRAWING.contains(&c)); + let theme = Theme::dark(); + + let mut app = App::new(); + app.tables = vec!["Customers".to_string(), "Orders".to_string()]; + app.nav_focus = NavFocus::SidebarTables; + let buf = render_to_buffer(&mut app, &theme, 110, 24); + let mut accent_border_cells = 0; + for y in 0..buf.area.height { + for x in 0..buf.area.width { + let cell = &buf[(x, y)]; + if is_border(cell.symbol()) && cell.fg == theme.mode_simple { + accent_border_cells += 1; + assert!( + !cell.modifier.contains(Modifier::BOLD), + "focused border cell at ({x},{y}) must not be bold (issue #25)", + ); + } + } + } + assert!( + accent_border_cells > 0, + "the focused Tables panel must render accent-coloured border cells", + ); + + // With nothing focused (Input), no border cell wears the accent. + let mut app2 = App::new(); + app2.tables = vec!["Customers".to_string()]; + app2.nav_focus = NavFocus::Input; + let buf2 = render_to_buffer(&mut app2, &theme, 110, 24); + for y in 0..buf2.area.height { + for x in 0..buf2.area.width { + let cell = &buf2[(x, y)]; + if is_border(cell.symbol()) { + assert_ne!( + cell.fg, theme.mode_simple, + "no border cell may wear the focus accent when nothing is focused (at {x},{y})", + ); + } + } + } + } + #[test] fn focused_tables_panel_scrolls_and_clamps() { // ADR-0046 DC3: more tables than fit → a large offset reveals the From 9189740028cd344d15987b53dce87480d23c4376 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 20:35:39 +0000 Subject: [PATCH 04/50] build(nix): reproducible dev + build env via a flake (ADR-0049) Root flake with two outputs: devShells.default (pinned 1.95.0 toolchain via rust-toolchain.toml + rust-overlay, plus cargo-sweep) and packages.default (rustPlatform.buildRustPackage from the committed Cargo.lock; doCheck=false). flake.lock pins nixpkgs nixos-26.05 / rust-overlay / flake-utils. .envrc (use flake) for direnv parity. Single source of toolchain for dev and the upcoming CI, so they can't drift. Verified through the flake: nix build yields a working binary, clippy clean, 2424 tests pass / 0 fail / 1 intentional ignored doctest. First step toward requirements.md TT5 + D1/D2/D3. --- .envrc | 1 + .gitignore | 6 + docs/adr/0049-nix-flake-dev-and-build-env.md | 127 +++++++++++++++++++ docs/adr/README.md | 1 + flake.lock | 82 ++++++++++++ flake.nix | 80 ++++++++++++ rust-toolchain.toml | 10 ++ 7 files changed, 307 insertions(+) create mode 100644 .envrc create mode 100644 docs/adr/0049-nix-flake-dev-and-build-env.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 rust-toolchain.toml diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index 6b15ae8..5b370c4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,12 @@ /target **/*.rs.bk +# Nix +# `nix build` output symlinks (`result`, `result-`), direnv's cached env +/result +/result-* +.direnv/ + # Snapshot test review files *.snap.new *.pending-snap diff --git a/docs/adr/0049-nix-flake-dev-and-build-env.md b/docs/adr/0049-nix-flake-dev-and-build-env.md new file mode 100644 index 0000000..2c2d9aa --- /dev/null +++ b/docs/adr/0049-nix-flake-dev-and-build-env.md @@ -0,0 +1,127 @@ +# ADR-0049: Nix flake for a reproducible dev + build environment + +## Status + +**Accepted (2026-06-12).** Implemented the same day on the `ci` branch: +`flake.nix`, `flake.lock`, `rust-toolchain.toml`, `.envrc`. Verified +end-to-end before acceptance — `nix develop` provides the pinned +toolchain; `nix build .#default` produces a working binary; `cargo +clippy --all-targets -- -D warnings` is clean and `cargo test` is +**2424 passed / 0 failed / 1 ignored** (the ignored item is the +intentional ```` ```ignore ```` doctest at `src/friendly/mod.rs:21`), +all run *through the flake*. This ADR is the dev/build-environment +half of the CI work; the CI **pipeline** itself (runner wiring, +release matrix) is decided separately as it settles. + +## Context + +The project is near feature-complete and CI is finally being set up +(`requirements.md` **TT5**, **CI** in the deferred list). CI must not +depend on whatever Rust/toolchain happens to be installed on the build +machine — that is neither reproducible nor honest about what the build +needs. + +The sibling project **datamage** already solved this with a Nix flake +(its ADR 0046): the flake is the single, version-pinned declaration of +the toolchain, and both the dev shell and CI go through it so they +cannot drift. We adopt the same pattern here. Ours is dramatically +simpler than datamage's — this is a pure-Rust TUI with no Tauri / +WebKitGTK / Node / WASM surface — so the flake carries almost no system +dependencies. + +Two build facts drove the (tiny) dependency set, confirmed from +`Cargo.lock`: + +- **`libsqlite3-sys` is built with `bundled`** → SQLite is compiled + from vendored C, which needs a C compiler. `nixpkgs`' `stdenv` + provides one automatically; nothing is declared for it. +- **`arboard`'s clipboard backend is `x11rb`** — a pure-Rust socket + XCB client that links *no* C X11 libraries. So no X11/`pkg-config` + system inputs are needed to build or test. A live X server is only + required at *runtime* to actually copy; headless sessions fall back + to OSC 52. + +## Decision + +Adopt a **Nix flake** at the repository root as the canonical +declaration of the dev *and* build environment. + +- **`flake.nix`** exposes two outputs (user-chosen 2026-06-12 over a + dev-shell-only variant): + - **`devShells.default`** — the pinned Rust toolchain (from + `rust-toolchain.toml` via `rust-overlay`) plus `cargo-sweep` for + the `target/` build-hygiene discipline (CLAUDE.md / the datamage + ADR 0050 equivalent). + - **`packages.default`** (= `packages.rdbms-playground`) — a + `rustPlatform.buildRustPackage` that produces the binary + reproducibly from the pinned toolchain and the committed + `Cargo.lock` (`cargoLock.lockFile` → `importCargoLock`, which + fetches each dependency by its lockfile checksum: offline, + deterministic, no `cargoHash` to churn). `nix build` yields the + artifact CI's gate/release can consume. +- **`rust-toolchain.toml`** pins an **exact stable release** + (`1.95.0`), not the floating `stable` channel, so `nix flake update` + cannot surprise-bump Rust into new clippy lints that would fail the + `-D warnings` gate (same reasoning as datamage ADR 0046). Components: + `rustfmt` + `clippy`. No coverage/WASM tooling and no + cross-compilation targets yet — those are added when the release + matrix needs them, not before. +- **`flake.lock`** pins every input (`nixpkgs` `nixos-26.05`, + `rust-overlay`, `flake-utils`) to a commit, making the env + bit-reproducible. +- **`.envrc`** contains `use flake` for direnv auto-activation, kept + for parity with datamage even though direnv is not installed on the + current dev VM (entry is via `nix develop`). +- **`packages.default` sets `doCheck = false`.** The test suite is + *not* run during `nix build` — the Nix build sandbox has no `HOME` + and no X server, which fights the project-directory / clipboard + paths the tests touch. Tests run as their own CI stage via + `nix develop -c cargo test`, keeping "build the artifact" and "run + the suite" cleanly separate. +- **The package version is read from `Cargo.toml`** via + `builtins.fromTOML`, so it never drifts from the crate metadata. + +## Consequences + +- **One toolchain definition.** Dev and CI share the exact pinned + toolchain; they cannot drift. New contributors run `nix develop` + (or get auto-activation via direnv) and have the same Rust as CI. +- **D2 (static binary) is unaffected and still pending.** The + `nix build` artifact links the Nix-store glibc *dynamically* — it is + a reproducible build/test artifact, **not** the single static + release binary D2 calls for. Release binaries will target a static + toolchain (e.g. `x86_64-unknown-linux-musl`) in the forthcoming CI + release work; that is a release-step concern, not a dev-shell one. +- **`fmt` is deliberately *not* gated yet.** The tree is not clean + under stock `rustfmt` (~100 files would change; no `rustfmt.toml` is + committed and the code was shaped by something other than default + `rustfmt`). Reformatting churns blame across every file and would + conflict with the in-flight website branch and ongoing `main` work, + so — user decision 2026-06-12 — the `fmt` gate is left out for now + and revisited on `main`. The CI gate is `clippy` + `test`. +- **Engine-name posture (CLAUDE.md) is respected.** The flake's + comments may name SQLite/`rusqlite` where technically necessary + (build-input rationale); no user-facing string is affected. + +## Alternatives considered + +- **Dev-shell only (no build package).** Matches datamage exactly; CI + would `cargo build` inside `nix develop -c`. Rejected (user choice): + a `nix build` package gives a reproducible release artifact straight + from the pinned toolchain, which the release job wants. +- **A standard `rust:1.95` image in CI, flake for dev only.** Simpler + in CI (no nix-in-CI caching to solve), but it is a *second* place + that defines the toolchain — exactly the drift this ADR exists to + prevent. Rejected for the unified-env goal; the nix-in-CI caching + cost is solved in the CI pipeline work instead. +- **`rustup` on the build machine.** The status quo CI would replace — + non-reproducible, machine-dependent, the thing we are eliminating. + +## Relationship to other decisions + +- Mirrors **datamage ADR 0046** (nix flake dev env) and its build + hygiene companion. This is the rdbms-playground analogue, scoped to + a pure-Rust project. +- Feeds the CI pipeline work for `requirements.md` **TT5** (CI runs + the tiers) and the **D1/D2/D3** distribution items (the release + matrix consumes `nix build` / a static target). diff --git a/docs/adr/README.md b/docs/adr/README.md index 2e15522..7d3d36f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -54,3 +54,4 @@ This directory contains the project's ADRs, recorded per - [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 - [ADR-0048 — `seed` fake-data generation command](0048-seed-fake-data-generation.md) — **Accepted 2026-06-11; Phase 1 + Phase 2 implemented 2026-06-11** (Phase 1 commits `202e25a`→`fbd219b`; design settled with the user across an extended fork dialogue, hardened by a pre-build `/runda` pass (six blockers folded in), a post-implementation `/runda` pass (eight gaps closed — FK/shortid determinism so **D4 holds with no exceptions**, plus six untested ADR decisions), and a Phase-2 pre-build `/runda` pass (which caught the no-date-literal-token reality → the D2 quoted-dates amendment), and a post-implementation `/runda` pass (which added a friendly error for a bounded override on a UNIQUE column — see D2); **2400 tests pass, clippy clean**). Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1**. **Phase 1 shipped:** whole-row `seed
[count] [--seed ]` with realistic name-aware generation (the `fake` crate + a type-gated heuristic catalogue, table-context name disambiguation, hand-rolled `product` generator, bounded dates), identifier + constraint uniqueness incl. junction distinct-combos, FK sampling from existing parent rows (empty-parent error), `IN`-CHECK derivation + complex-CHECK advisory, a required-column block guard, `--seed` reproducibility (serial/FK/shortid all deterministic), undo as one batch step, replay as a data write, a capped auto-show preview, the enum/CHECK advisory, and an O(N) single-transaction insert path. **Phase 2 shipped (2026-06-11):** the `set` override clause (D2 — fixed value / pick-list / `as ` / `between` range, **quoted** dates per the D2 amendment, type-aware, override drops the column from the advisory) and the `
.` column-fill form (D1 form 2 — an UPDATE over existing rows, refusing PK/autogen targets, empty-table no-op, FK/unique-respecting, one undo step), with the new `KNOWN_GENERATORS` vocabulary (D9), a range `Generator`, full completion/highlight (`HighlightClass::Function`)/validity (`IdentSource::Generators`)/help/pedagogy wiring, and the D13 advisory's Phase-2/3 wording. Further SD2 increments (custom generators, NULL injection, multi-locale, recursive auto-seed) out of scope. Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1** (the other being `hint`/**H2**). A dedicated `seed` command (own AST variant + `do_seed` executor, **both modes**) generating **realistic, name-aware** fake data. Two forms: **`seed
[count]`** (new rows, default **20**, capped) and **`seed
.`** (fill a column on existing rows, an UPDATE). Generation adds the **`fake` crate** (v5, English) driven by a **type-gated, token-matched name-heuristic catalogue** (~30 patterns, documented false-positive guards), with **table-context** disambiguating the `name`/`title` family (`products.name`→product, `users.name`→person, `vendors.name`→company), a **hand-rolled `product` generator** (`fake` has no commerce module), **bounded dates** (`date`/`timestamp`/`dob`/`*_at` recognised, recent windows — never "all of history"), the **identifier family** (`id`/`code`/`ref`/`number`, non-FK/non-PK) → **unique sequential**, and **enum-ish names** (`role`/`status`/`type`/…) left generic + a **post-seed Hint advisory** pointing at `set … in (…)`. A **`set` override clause** — `= value` / `in (a,b,c)` / `as ` / `between a and b` (numeric **and** date), reusing ADR-0026 operators — answers the heuristic-miss case. **`--seed `** makes runs reproducible (and enables exact-value tests). **FK** columns sampled uniformly from existing parent rows (**empty parent → friendly error**, no recursion v1); **junction/compound-PK** tables seeded with **distinct combinations**, capped + noted (SD1). A **required-column block guard** refuses rather than NULL-violate a `NOT NULL` column it can't fill (e.g. `NOT NULL blob`). Full ambient wiring (completion incl. a new generator-name vocabulary highlighted as `tok_function`, hints, `help seed`, ADR-0042 near-miss matrix, ADR-0027 validity); **no DSL→SQL teaching echo** (seed is a utility command, not a SQL twin). Honours **X5** — `do_seed` reuses insert/update *mechanics as helpers*, not by emitting `Command::Insert`. Implementation phased: (1) core whole-row seed → (2) `set` overrides → (3) column-fill. Deferred (future SD2): recursive auto-seed, NULL injection, multi-locale, user-defined custom generators, full per-column report +- [ADR-0049 — Nix flake for a reproducible dev + build environment](0049-nix-flake-dev-and-build-env.md) — **Accepted + implemented 2026-06-12** (`ci` branch; first step of the CI work toward `requirements.md` **TT5** + **D1/D2/D3**). Adopts a root **Nix flake** as the single, version-pinned declaration of the dev *and* build toolchain so CI never relies on whatever Rust is installed on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI: no Tauri/WebKit/Node/WASM). Two outputs (user-chosen over dev-shell-only): **`devShells.default`** (pinned toolchain via `rust-toolchain.toml` + `rust-overlay`, plus `cargo-sweep`) and **`packages.default`** (a `rustPlatform.buildRustPackage` building the binary reproducibly from the committed `Cargo.lock` via `importCargoLock`; `doCheck = false` — the suite runs as its own `nix develop -c cargo test` stage, not in the HOME/X-less build sandbox; version read from `Cargo.toml` via `fromTOML`). Toolchain pinned to **exact `1.95.0`** (not floating `stable`) so `nix flake update` can't surprise-bump clippy lints past the `-D warnings` gate; components `rustfmt` + `clippy`; **no** cross/WASM targets yet (added when the release matrix needs them). System inputs are nearly empty by design — `libsqlite3-sys` `bundled` needs only the stdenv C compiler; `arboard`→`x11rb` is pure-Rust (no C X11 libs, X server only needed at *runtime*, OSC 52 otherwise). `.envrc` (`use flake`) kept for direnv parity though direnv isn't on the current VM. **Verified before acceptance:** `nix develop` toolchain pinned, `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest** — all through the flake. Consequences: the `nix build` artifact is glibc-**dynamic** (a reproducible build/test artifact, **not** the D2 static release binary — release uses a static target like `x86_64-unknown-linux-musl`, deferred to the CI release work); the **`fmt` gate is deliberately left out for now** (user decision — the tree isn't clean under stock `rustfmt`, ~100 files would churn and conflict with the website/`main` work; revisit on `main`), so the gate is **`clippy` + `test`**. Alternatives rejected: dev-shell-only (no reproducible artifact); a standard `rust:1.95` CI image (a second toolchain definition = drift, the very thing this prevents); `rustup` on the build host (non-reproducible — the status quo being eliminated). The CI **pipeline** itself (runner wiring, release matrix) is decided separately as it settles. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..69958c3 --- /dev/null +++ b/flake.lock @@ -0,0 +1,82 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1780902259, + "narHash": "sha256-q8yYEC5f1mFlQO9RGna4LTc9QrcvWunX6FYp83munkQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "bd0ff2d3eac24699c3664d5966b9ef36f388e2ca", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-26.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1781234414, + "narHash": "sha256-HdA+P4fKRGOomkewnI/Tww5Wz4xK1O7+hDO90YAsPB4=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "1d18bfe3de6244c641ca4e8011186d0981b81d76", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..e5f8fd8 --- /dev/null +++ b/flake.nix @@ -0,0 +1,80 @@ +{ + description = "RDBMS Playground — Rust TUI dev environment + reproducible build"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, rust-overlay, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ (import rust-overlay) ]; + }; + + # Single source of the Rust toolchain: the rustup toolchain file. + # rust-overlay provisions the exact channel + components declared there, + # so the dev shell and the build package share one pinned toolchain. + rust = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; + + # Read the package version straight from Cargo.toml so it never drifts + # from the crate metadata (no hand-maintained duplicate here). + cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); + + # System build inputs are deliberately tiny — this is a pure-Rust TUI: + # * libsqlite3-sys is built with the `bundled` feature, so SQLite is + # compiled from vendored C. That needs a C compiler, which the + # stdenv provides automatically (no entry required here). + # * arboard's clipboard backend is `x11rb` — a pure-Rust socket XCB + # client. It links no C X11 libraries, so none appear below. A live + # X server is only needed at *runtime* to copy; headless sessions + # fall back to OSC 52. + # If a future dependency introduces a pkg-config / native-lib link, add + # it here (and document why) rather than leaking it into the host env. + nativeBuildInputs = [ ]; + buildInputs = [ ]; + + # `nix build` → the release binary, built reproducibly from the pinned + # toolchain and the committed Cargo.lock (importCargoLock fetches each + # dependency by its lockfile checksum — offline, no cargoHash to churn). + # CI's release job consumes this artifact; the gate's tests run + # separately via `nix develop -c cargo test` (see below), so the package + # build skips the suite — the nix sandbox has no HOME/X server and would + # fight the project-dirs / clipboard paths the tests touch. + rdbms-playground = pkgs.rustPlatform.buildRustPackage { + pname = cargoToml.package.name; + version = cargoToml.package.version; + src = ./.; + cargoLock.lockFile = ./Cargo.lock; + inherit nativeBuildInputs buildInputs; + doCheck = false; + }; + in { + packages.default = rdbms-playground; + packages.rdbms-playground = rdbms-playground; + + devShells.default = pkgs.mkShell { + inherit buildInputs; + nativeBuildInputs = nativeBuildInputs ++ [ + rust + # Dev-disk maintenance: cargo never garbage-collects stale per-hash + # build artifacts, so target/ creeps into the tens of GB (see + # CLAUDE.md "Build hygiene"). cargo-sweep prunes them; run it + # periodically between milestones. + pkgs.cargo-sweep + ]; + + shellHook = '' + echo "RDBMS Playground dev shell ($(uname -s))" + echo " rust: $(rustc --version | cut -d' ' -f1-2)" + echo " cargo: $(cargo --version | cut -d' ' -f1-2)" + ''; + }; + }); +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..cabb063 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,10 @@ +[toolchain] +# Pinned to an exact stable release (not the floating "stable" channel) so +# `nix flake update` cannot surprise-bump Rust into new clippy lints that would +# fail the `-D warnings` CI gate. Matches the host toolchain and the datamage +# flake's convention (its ADR 0046). Bump deliberately, in its own commit. +channel = "1.95.0" +# rustfmt + clippy back the `fmt`/`clippy` CI stages; no coverage or WASM +# tooling is needed here (pure-Rust TUI). Cross-compilation targets for the +# eventual D1 release matrix are added when that CI lands, not before. +components = ["rustfmt", "clippy"] From c7ac0c98775a7c1a6a883206939339eaa5c8d6bf Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 20:35:39 +0000 Subject: [PATCH 05/50] ci: add throwaway runner-probe workflow Diagnostic to determine how the ci-public runner executes jobs and where the nix toolchain is reachable (host vs default container vs a custom container:), so the real gate is built on facts. Delete once the gate lands. --- .gitea/workflows/ci-probe.yaml | 73 ++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 .gitea/workflows/ci-probe.yaml diff --git a/.gitea/workflows/ci-probe.yaml b/.gitea/workflows/ci-probe.yaml new file mode 100644 index 0000000..19fd23a --- /dev/null +++ b/.gitea/workflows/ci-probe.yaml @@ -0,0 +1,73 @@ +# THROWAWAY DIAGNOSTIC — delete once the real gate is wired. +# +# This answers the questions that decide the CI architecture, on facts rather +# than guesses: +# * How does this runner execute a plain job — directly on the host, or inside +# a default container? (-> is "the ci server has nix" reachable from steps?) +# * Is `nix` on PATH where steps run, and does a /nix store persist? +# * Is a docker client/daemon reachable from a plain job (no DinD service)? +# * Does a custom job `container:` work on this rootless runner, and can it pull +# an image (nixos/nix) — i.e. is the "reusable nix image" model viable? +# +# Trigger: push to this branch, or run manually from the Actions UI. +name: ci-probe +on: [push, workflow_dispatch] + +jobs: + # --- Job 1: DEFAULT execution ------------------------------------------- + # No `container:` override — this is whatever environment the runner gives a + # plain job. Tells us where steps actually run and what's already there. + host: + runs-on: ci-public + steps: + - name: identity & environment + run: | + echo "=== uname ==="; uname -a + echo "=== os-release ==="; head -3 /etc/os-release 2>/dev/null || echo "(none)" + echo "=== whoami / id ==="; whoami; id + echo "=== containerized? ===" + if [ -f /.dockerenv ]; then + echo "/.dockerenv PRESENT -> steps run INSIDE a container" + else + echo "/.dockerenv absent" + fi + echo "--- /proc/1/cgroup (first lines) ---"; head -5 /proc/1/cgroup 2>/dev/null || echo "(none)" + + - name: nix availability (the decisive check) + run: | + echo "=== which nix ==="; command -v nix || echo "nix NOT on PATH" + echo "=== nix --version ==="; nix --version 2>/dev/null || echo "(no nix here)" + echo "=== /nix store ==="; ls -ld /nix /nix/store 2>/dev/null || echo "(no /nix)" + echo "=== store path count (persistence hint; high => warm/shared) ===" + ls /nix/store 2>/dev/null | wc -l + + - name: docker availability (without a DinD service) + run: | + echo "=== which docker ==="; command -v docker || echo "docker NOT on PATH" + docker version 2>/dev/null || echo "(no docker client/daemon reachable from a plain job)" + + - name: checkout — does the flake land here? + uses: actions/checkout@v4 + + - name: flake present in this checkout? + run: ls -la flake.nix flake.lock rust-toolchain.toml 2>/dev/null || echo "(flake not on this branch's checkout)" + + # --- Job 2: CUSTOM CONTAINER -------------------------------------------- + # Tests the "reusable nix image" model: run steps inside a pinned nix image. + # Deliberately minimal — no checkout (the checkout action needs node, which a + # bare nixos/nix image lacks; that's a separate concern). If this job's steps + # run at all, custom job containers are viable on this runner. + nix-container: + runs-on: ci-public + container: + image: nixos/nix:latest + steps: + - name: nix inside a pinned container + run: | + echo "=== inside nixos/nix container ===" + nix --version + echo "--- identity ---"; whoami; id; uname -a + echo "--- flakes enabled? ---" + nix --extra-experimental-features 'nix-command flakes' flake --help >/dev/null 2>&1 \ + && echo "flakes usable (with --extra-experimental-features)" \ + || echo "flake subcommand not usable as invoked" From deb0948d6cdc2ce00a6a83837777205c5aa5ffcf Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 20:36:20 +0000 Subject: [PATCH 06/50] feat(seed): year-as-int + conventional choice-set heuristics (#33, #34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two additive D7 catalogue rules, surfaced while writing the website seed docs. No change to the type fallback, executor, or grammar. #33 — year-like int columns. `published`/`birth_year` were just `int`, so they fell to the unbounded int path and produced nonsense (`9419`). Add an int-gated year rule (after the quantity rule, so `year_count` stays a count): `year`/`*_year`/`published`/`founded` -> a bounded 1950-2025 year (new `YearRecent`), or the dob-style birth window 1945-2007 for `birth`/`born`/`dob` (new `YearBirth`). Plain int; not added to the D9 named-generator vocabulary. #34 — conventional choice sets. A few enum-ish names have a near-canonical small set that reads far better than lorem text. Add a type-gated PickFrom lookup (reusing the existing generator): priority/prio, severity, rating/stars. `status` is deliberately excluded (values too domain-specific) and keeps the D12 advisory; a user IN-CHECK still wins. `priority` leaves ENUM_TOKENS. ADR-0048 Amendment 1; +8 tests (incl. a column-fill integration test that also closes a pre-existing gap on that path). --- docs/adr/0048-seed-fake-data-generation.md | 65 +++++++++++ docs/adr/README.md | 2 +- docs/requirements.md | 5 +- src/seed/generators.rs | 52 +++++++++ src/seed/heuristics.rs | 130 ++++++++++++++++++++- src/seed/mod.rs | 7 ++ tests/it/seed.rs | 117 +++++++++++++++++++ 7 files changed, 374 insertions(+), 4 deletions(-) diff --git a/docs/adr/0048-seed-fake-data-generation.md b/docs/adr/0048-seed-fake-data-generation.md index 9dfd1ed..b3cbe83 100644 --- a/docs/adr/0048-seed-fake-data-generation.md +++ b/docs/adr/0048-seed-fake-data-generation.md @@ -317,6 +317,8 @@ with the implementation): | `url`/`website`/`homepage` · `color`/`colour` | URL / hex colour | text | | `price`/`amount`/`cost`/`salary`/`balance`/`total` | currency-range number | numeric | | `age` · `quantity`/`qty`/`stock`/`count` | 18–80 · small int | numeric | +| `year`/`*_year`/`published`/`founded` (Amendment 1) | bounded year (birth window for `birth`/`born`/`dob`, else 1950–2025) | int | +| `priority`/`prio` · `severity` · `rating`/`stars` (Amendment 1) | built-in `PickFrom` value set | text/int | | `date`/`*_date` | date, recent ~3 yr window | date | | `dob`/`birthday` | date, adult window (18–80 yr ago) | date | | `timestamp`/`datetime` · `created_at`/`updated_at`/`*_at` | datetime, recent window (`updated_at` ≥ `created_at`) | datetime | @@ -675,3 +677,66 @@ the regression floor. derive-`IN`-else-friendly-fail tier. - **`set`-driven NULL / per-column report / recursive parent seed:** deferred — see Out of scope. + +## Amendment 1 — year-as-int + conventional choice sets (2026-06-12) + +Two SD2-style refinements to the D7 catalogue, surfaced while writing +the website `seed` docs. Both are additive name rules; no change to D8 +(type fallback), the executor, or the grammar. + +### Issue #33 — year-like `int` columns + +A column such as `published` or `birth_year` was just an `int`, so it +fell through to the unbounded type-based `int` path (D8) and produced +nonsense like `9419` or `1426` — implausible as years, undercutting the +"realistic data" pedagogy. Added an **`int`-gated** year rule, placed +*after* the quantity rule (so `year_count` stays a count): + +- `year` / `*_year` / `published` / `founded` → **`YearRecent`**, a + bounded window of **1950–2025** (75 years relative to the fixed + `REF_YEAR`, wide enough for published books / founding years / + release years; matches the issue's own `between 1950 and 2020` + workaround). +- the same with a `birth` / `born` / `dob` token (e.g. `birth_year`) → + **`YearBirth`**, mirroring the existing `dob → DateAdult` adult birth + window as years (**1945–2007**). + +Both emit a plain `int`. `published` / `founded` are included +(user-confirmed): an `int` so named is almost always a year (a flag +would be `is_published`). The generators are **not** added to the D9 +named-generator vocabulary — explicit control stays with `set +between and `. + +### Issue #34 — built-in value sets for conventional choice names + +D12 deliberately does not guess values for enum-ish names. For a few, +though, there is a near-canonical small set that reads far better than +lorem text. Added a **type-gated `PickFrom`** lookup (reusing the +existing generator — no new machinery), placed ahead of the enum-ish +fallthrough: + +| Name (tokens) | text | int | +|---|---|---| +| `priority` / `prio` | `low`/`medium`/`high` | `1`/`2`/`3` | +| `severity` | `low`/`medium`/`high`/`critical` | `1`/`2`/`3`/`4` | +| `rating` / `stars` | — | `1`–`5` | + +A user-declared `IN`-CHECK (D17) still wins — it is resolved before the +heuristics. Any name that gains a set is **removed from the enum-ish +advisory trigger** (`priority` left `ENUM_TOKENS`); since the advisory +(D13) only fires on `Generator::Generic`, a `PickFrom` name is excluded +either way, but the removal keeps `is_enum_ish` semantically "names seed +still can't guess". + +**`status` is deliberately excluded** (user-confirmed on the issue): its +real values are too domain-specific (`active/inactive`, +`open/closed/pending`, `draft/published`, …), so it keeps the D12 +"don't guess" stance — generic text + the advisory pointing at `set +status in (…)`. `state` stays its US-state-name generator (D7); +`type`/`kind`/`category`/`stage`/`gender` and `size`/`tier`/`plan` were +considered and left to the advisory. + +**Website follow-up** (tracked on the `website` branch, not here): the +`seed` cast exercises a `tickets` table with `priority`; it should be +re-recorded so the table tightens once `priority` collapses to a short +value — likely subsumed by the pre-publication cast sweep. diff --git a/docs/adr/README.md b/docs/adr/README.md index 3f101b1..13a0fb0 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -53,4 +53,4 @@ This directory contains the project's ADRs, recorded per - [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); **Amendment 1, 2026-06-12** (issue #25): DC3's focus accent is now a **non-bold accent colour** (`theme.mode_simple`, blue) rather than bold bright-`fg` — bold box-drawing glyphs render as broken/gapped line-art in the asciinema cast player (and are fragile in some terminals), so `panel_border_style` carries no `Modifier::BOLD` on a border (bold stays fine on text spans); pure style change — the text-only Tier-2 snapshots were unaffected, the Tier-1 assertion was updated, and a render-level test now checks the focused border cells carry the accent and no bold - [ADR-0047 — Demonstration overlay layer (keystroke badges + step captions)](0047-demonstration-overlay-layer.md) — **Accepted 2026-06-10; implemented 2026-06-11, phased A→B→C (closes Gitea #22)** (commits `f879d54`→`2d0f4b2`; no `requirements.md` item — tracked by issue + ADR per convention; all forks user-confirmed + a pre-build `/runda` pass that produced 10 tightening findings and a whole-implementation `/runda` pass that returned PASS, no blockers). An in-app **demonstration mode** (`--demo` flag / `RDBMS_PLAYGROUND_DEMO` env, **off by default, zero footprint when off**) that renders two transient overlays so `autocast` screencasts — and live teaching, and a future guided-lesson system — can show otherwise-invisible interactions. **Keystroke badges** (`[TAB]`, `[ENTER]`, `[UP]`, …): **automatic, app-detected** over a fixed set of glyph-less keys (the app already sees every key, so it re-records for free), label via a pure `demo_badge_label(&KeyEvent)`; the badge **auto-expires on a ~1.5 s timer** that extends the runtime's existing time-boxed-`recv` arm condition (`debounce.is_armed() || badge_pending`; expiry `Instant` in the runtime, `App.demo_badge` the render mirror — mirroring the `input` vs `input_indicator` split). **Step captions**: a **stealth, control-code-delimited input buffer** toggled by **`Ctrl+]`** (byte `0x1D` → arrives as `Char('5')+CONTROL`, verified against crossterm 0.29 `parse.rs:110-113`; chosen over `Ctrl+!`, which is **not a single ASCII byte so autocast cannot send it** — the same wall as arrow keys, R4) — typed characters accumulate **invisibly** (prompt untouched, no echo/history), `Backspace` edits, other keys inert, a second `Ctrl+]` **commits** to the caption box (empty commit dismisses); lives in pure-sync `App::update()`, **intercepted before the modal gate** so captions/badges work **over the load picker** (the `#24` projects cast). Both render as **floating flat black-on-yellow rectangles** (solid fill, **no border glyphs** — a one-cell text margin, deliberately unlike the app's bordered panels; user decision post-build, `2d0f4b2`) **at the output panel's inner bottom-right**, drawn **last over modals**, badge **stacked above** the caption, **no layout reflow**; caption **word-wraps to ≤ 3 lines** (3–5 rows), badge fixed 3 rows; clamp/skip guard for tiny terminals; a new **`App.last_output_area: Rect`** (set in `render_output_panel`) gives the top-level draw the anchor. Caption persists **until the next keystroke**; badge suppressed while capturing. Forks all user-chosen: `--demo` activation (vs hidden command / chord); automatic badges (vs scripted); stealth buffer (vs typed-command / preloaded-file); floating bottom-right boxes (vs HUD / banner / subtitle); `Ctrl+]` trigger; wrap-to-3-line captions; ~1.5 s badge / next-keystroke caption timing. Tested test-first across Tier 1 (label fn, capture state machine incl. over-modal + demo-off gate, nearest-deadline helper), Tier 2 (insta snapshots: badge/caption/both-stacked at 90×26 light+dark, short-terminal clamp), Tier 3 (`--demo` plumbing, badge set/suppressed, caption-without-input wiring), CLI (`--demo` parse + env fallback) — with an **honest limit** noted: the `tokio` timer wiring inside `run_loop` is exercised via the pure pieces + Tier-3 plumbing, not a standalone integration test of the timeout (same posture as the existing `IndicatorDebounce`). One intentional, user-acknowledged behaviour: `Ctrl-C` is inert while capturing (every non-`Ctrl+]` key is, by spec). Final tally **2290 passing / 0 failing / 0 skipped** (1 long-standing ignored doctest), clippy clean. OOS: scripted/manual badge push; badges for glyph keys; configurable styling/placement; the guided-lesson system itself (own ADR); cross-session/-switch persistence; localised caption content; arrow-only cast interactions (output-pane scroll); wiring the overlays into the website `casts.mjs` scripts (website-branch follow-up). Implementation phased **A** (`--demo` plumbing) → **B** (badges) → **C** (captions) + a flat-rectangle restyle -- [ADR-0048 — `seed` fake-data generation command](0048-seed-fake-data-generation.md) — **Accepted 2026-06-11; Phase 1 + Phase 2 implemented 2026-06-11** (Phase 1 commits `202e25a`→`fbd219b`; design settled with the user across an extended fork dialogue, hardened by a pre-build `/runda` pass (six blockers folded in), a post-implementation `/runda` pass (eight gaps closed — FK/shortid determinism so **D4 holds with no exceptions**, plus six untested ADR decisions), and a Phase-2 pre-build `/runda` pass (which caught the no-date-literal-token reality → the D2 quoted-dates amendment), and a post-implementation `/runda` pass (which added a friendly error for a bounded override on a UNIQUE column — see D2); **2400 tests pass, clippy clean**). Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1**. **Phase 1 shipped:** whole-row `seed
[count] [--seed ]` with realistic name-aware generation (the `fake` crate + a type-gated heuristic catalogue, table-context name disambiguation, hand-rolled `product` generator, bounded dates), identifier + constraint uniqueness incl. junction distinct-combos, FK sampling from existing parent rows (empty-parent error), `IN`-CHECK derivation + complex-CHECK advisory, a required-column block guard, `--seed` reproducibility (serial/FK/shortid all deterministic), undo as one batch step, replay as a data write, a capped auto-show preview, the enum/CHECK advisory, and an O(N) single-transaction insert path. **Phase 2 shipped (2026-06-11):** the `set` override clause (D2 — fixed value / pick-list / `as ` / `between` range, **quoted** dates per the D2 amendment, type-aware, override drops the column from the advisory) and the `
.` column-fill form (D1 form 2 — an UPDATE over existing rows, refusing PK/autogen targets, empty-table no-op, FK/unique-respecting, one undo step), with the new `KNOWN_GENERATORS` vocabulary (D9), a range `Generator`, full completion/highlight (`HighlightClass::Function`)/validity (`IdentSource::Generators`)/help/pedagogy wiring, and the D13 advisory's Phase-2/3 wording. Further SD2 increments (custom generators, NULL injection, multi-locale, recursive auto-seed) out of scope. Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1** (the other being `hint`/**H2**). A dedicated `seed` command (own AST variant + `do_seed` executor, **both modes**) generating **realistic, name-aware** fake data. Two forms: **`seed
[count]`** (new rows, default **20**, capped) and **`seed
.`** (fill a column on existing rows, an UPDATE). Generation adds the **`fake` crate** (v5, English) driven by a **type-gated, token-matched name-heuristic catalogue** (~30 patterns, documented false-positive guards), with **table-context** disambiguating the `name`/`title` family (`products.name`→product, `users.name`→person, `vendors.name`→company), a **hand-rolled `product` generator** (`fake` has no commerce module), **bounded dates** (`date`/`timestamp`/`dob`/`*_at` recognised, recent windows — never "all of history"), the **identifier family** (`id`/`code`/`ref`/`number`, non-FK/non-PK) → **unique sequential**, and **enum-ish names** (`role`/`status`/`type`/…) left generic + a **post-seed Hint advisory** pointing at `set … in (…)`. A **`set` override clause** — `= value` / `in (a,b,c)` / `as ` / `between a and b` (numeric **and** date), reusing ADR-0026 operators — answers the heuristic-miss case. **`--seed `** makes runs reproducible (and enables exact-value tests). **FK** columns sampled uniformly from existing parent rows (**empty parent → friendly error**, no recursion v1); **junction/compound-PK** tables seeded with **distinct combinations**, capped + noted (SD1). A **required-column block guard** refuses rather than NULL-violate a `NOT NULL` column it can't fill (e.g. `NOT NULL blob`). Full ambient wiring (completion incl. a new generator-name vocabulary highlighted as `tok_function`, hints, `help seed`, ADR-0042 near-miss matrix, ADR-0027 validity); **no DSL→SQL teaching echo** (seed is a utility command, not a SQL twin). Honours **X5** — `do_seed` reuses insert/update *mechanics as helpers*, not by emitting `Command::Insert`. Implementation phased: (1) core whole-row seed → (2) `set` overrides → (3) column-fill. Deferred (future SD2): recursive auto-seed, NULL injection, multi-locale, user-defined custom generators, full per-column report +- [ADR-0048 — `seed` fake-data generation command](0048-seed-fake-data-generation.md) — **Accepted 2026-06-11; Phase 1 + Phase 2 implemented 2026-06-11** (Phase 1 commits `202e25a`→`fbd219b`; design settled with the user across an extended fork dialogue, hardened by a pre-build `/runda` pass (six blockers folded in), a post-implementation `/runda` pass (eight gaps closed — FK/shortid determinism so **D4 holds with no exceptions**, plus six untested ADR decisions), and a Phase-2 pre-build `/runda` pass (which caught the no-date-literal-token reality → the D2 quoted-dates amendment), and a post-implementation `/runda` pass (which added a friendly error for a bounded override on a UNIQUE column — see D2); **2400 tests pass, clippy clean**). Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1**. **Phase 1 shipped:** whole-row `seed
[count] [--seed ]` with realistic name-aware generation (the `fake` crate + a type-gated heuristic catalogue, table-context name disambiguation, hand-rolled `product` generator, bounded dates), identifier + constraint uniqueness incl. junction distinct-combos, FK sampling from existing parent rows (empty-parent error), `IN`-CHECK derivation + complex-CHECK advisory, a required-column block guard, `--seed` reproducibility (serial/FK/shortid all deterministic), undo as one batch step, replay as a data write, a capped auto-show preview, the enum/CHECK advisory, and an O(N) single-transaction insert path. **Phase 2 shipped (2026-06-11):** the `set` override clause (D2 — fixed value / pick-list / `as ` / `between` range, **quoted** dates per the D2 amendment, type-aware, override drops the column from the advisory) and the `
.` column-fill form (D1 form 2 — an UPDATE over existing rows, refusing PK/autogen targets, empty-table no-op, FK/unique-respecting, one undo step), with the new `KNOWN_GENERATORS` vocabulary (D9), a range `Generator`, full completion/highlight (`HighlightClass::Function`)/validity (`IdentSource::Generators`)/help/pedagogy wiring, and the D13 advisory's Phase-2/3 wording. Further SD2 increments (custom generators, NULL injection, multi-locale, recursive auto-seed) out of scope. Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1** (the other being `hint`/**H2**). A dedicated `seed` command (own AST variant + `do_seed` executor, **both modes**) generating **realistic, name-aware** fake data. Two forms: **`seed
[count]`** (new rows, default **20**, capped) and **`seed
.`** (fill a column on existing rows, an UPDATE). Generation adds the **`fake` crate** (v5, English) driven by a **type-gated, token-matched name-heuristic catalogue** (~30 patterns, documented false-positive guards), with **table-context** disambiguating the `name`/`title` family (`products.name`→product, `users.name`→person, `vendors.name`→company), a **hand-rolled `product` generator** (`fake` has no commerce module), **bounded dates** (`date`/`timestamp`/`dob`/`*_at` recognised, recent windows — never "all of history"), the **identifier family** (`id`/`code`/`ref`/`number`, non-FK/non-PK) → **unique sequential**, and **enum-ish names** (`role`/`status`/`type`/…) left generic + a **post-seed Hint advisory** pointing at `set … in (…)`. A **`set` override clause** — `= value` / `in (a,b,c)` / `as ` / `between a and b` (numeric **and** date), reusing ADR-0026 operators — answers the heuristic-miss case. **`--seed `** makes runs reproducible (and enables exact-value tests). **FK** columns sampled uniformly from existing parent rows (**empty parent → friendly error**, no recursion v1); **junction/compound-PK** tables seeded with **distinct combinations**, capped + noted (SD1). A **required-column block guard** refuses rather than NULL-violate a `NOT NULL` column it can't fill (e.g. `NOT NULL blob`). Full ambient wiring (completion incl. a new generator-name vocabulary highlighted as `tok_function`, hints, `help seed`, ADR-0042 near-miss matrix, ADR-0027 validity); **no DSL→SQL teaching echo** (seed is a utility command, not a SQL twin). Honours **X5** — `do_seed` reuses insert/update *mechanics as helpers*, not by emitting `Command::Insert`. Implementation phased: (1) core whole-row seed → (2) `set` overrides → (3) column-fill. Deferred (future SD2): recursive auto-seed, NULL injection, multi-locale, user-defined custom generators, full per-column report. **Amendment 1, 2026-06-12** (issues #33/#34): two additive D7 catalogue rules — **year-as-int** (`year`/`*_year`/`published`/`founded` → a bounded `int` year, 1950–2025, or the `dob`-style birth window 1945–2007 for `birth`/`born`/`dob`; fixes nonsense like `9419`; `int`-gated, after the quantity rule so `year_count` stays a count; two new `YearRecent`/`YearBirth` generators, *not* added to the D9 vocabulary) and **conventional choice sets** (`priority`/`prio`, `severity`, `rating`/`stars` → type-gated built-in `PickFrom` value sets reusing the existing generator; `priority` leaves `ENUM_TOKENS`). `status` is **deliberately excluded** (user-confirmed — values too domain-specific; keeps the D12 "don't guess" + advisory); a user `IN`-CHECK still wins. Website `seed` cast re-record tracked on the `website` branch diff --git a/docs/requirements.md b/docs/requirements.md index 2222f11..7b2984e 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -696,7 +696,10 @@ since ADR-0027.) `Generator`, and full completion / highlight / validity / help / parse-error-pedagogy wiring. Deferred SD2 increments: user-defined custom generators, NULL injection, multi-locale, - recursive parent auto-seed.)* + recursive parent auto-seed. Later catalogue refinements: + **#33** year-as-int (`year`/`*_year`/`published`/`founded`) and + **#34** conventional choice sets (`priority`/`severity`/`rating`, + `status` excluded) — ADR-0048 Amendment 1.)* ## Query analysis diff --git a/src/seed/generators.rs b/src/seed/generators.rs index a5d6a99..7e81f86 100644 --- a/src/seed/generators.rs +++ b/src/seed/generators.rs @@ -31,6 +31,16 @@ const RECENT_WINDOW_DAYS: i64 = 3 * 365; const ADULT_MIN_DAYS: i64 = 18 * 365; const ADULT_MAX_DAYS: i64 = 80 * 365; +/// Year windows for the `int`-typed year heuristics (issue #33), +/// expressed relative to [`REF_YEAR`] so they advance with releases — +/// the year siblings of the `DateRecent` / `DateAdult` windows above. +/// `YearRecent` spans ~75 years (1950–2025 at REF_YEAR=2025), wide +/// enough for `published` / `founded` / `release_year`; `YearBirth` +/// mirrors the adult birth window (1945–2007). +const YEAR_RECENT_SPAN: i32 = 75; +const YEAR_BIRTH_MIN_AGE: i32 = 18; +const YEAR_BIRTH_MAX_AGE: i32 = 80; + /// Produce one value for `generator` against destination type `ty`. #[must_use] pub fn generate_value(generator: &Generator, ty: Type, rng: &mut SeedRng) -> Value { @@ -71,6 +81,13 @@ pub fn generate_value(generator: &Generator, ty: Type, rng: &mut SeedRng) -> Val Generator::CurrencyAmount => currency_amount(ty, rng), Generator::Age => Value::Number(rng.random_range(18..=80).to_string()), Generator::SmallInt => Value::Number(rng.random_range(1..=100).to_string()), + Generator::YearRecent => { + Value::Number(rng.random_range((REF_YEAR - YEAR_RECENT_SPAN)..=REF_YEAR).to_string()) + } + Generator::YearBirth => Value::Number( + rng.random_range((REF_YEAR - YEAR_BIRTH_MAX_AGE)..=(REF_YEAR - YEAR_BIRTH_MIN_AGE)) + .to_string(), + ), Generator::DateRecent => Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS))), Generator::DateAdult => { Value::Text(format_date(random_past_date(rng, ADULT_MIN_DAYS, ADULT_MAX_DAYS))) @@ -489,6 +506,41 @@ mod tests { assert!(matches!(v, Value::Number(_)), "numeric pick should be a Number: {v:?}"); } + #[test] + fn year_generators_stay_within_their_bounded_windows() { + // Issue #33: both year generators emit a plain `int` inside a + // bounded, plausible window — never the unbounded-int nonsense. + let mut rng = make_rng(Some(7)); + for _ in 0..300 { + let Value::Number(s) = generate_value(&Generator::YearRecent, Type::Int, &mut rng) + else { + panic!("YearRecent must be a Number") + }; + let n: i32 = s.parse().unwrap(); + assert!((1950..=2025).contains(&n), "YearRecent {n} out of [1950,2025]"); + } + for _ in 0..300 { + let Value::Number(s) = generate_value(&Generator::YearBirth, Type::Int, &mut rng) + else { + panic!("YearBirth must be a Number") + }; + let n: i32 = s.parse().unwrap(); + assert!((1945..=2007).contains(&n), "YearBirth {n} out of [1945,2007]"); + } + } + + #[test] + fn year_generators_are_deterministic_for_a_fixed_seed() { + assert_eq!( + gen_once(&Generator::YearRecent, Type::Int, 42), + gen_once(&Generator::YearRecent, Type::Int, 42), + ); + assert_eq!( + gen_once(&Generator::YearBirth, Type::Int, 42), + gen_once(&Generator::YearBirth, Type::Int, 42), + ); + } + #[test] fn int_range_stays_within_inclusive_bounds() { let g = Generator::Range { low: "10".into(), high: "20".into() }; diff --git a/src/seed/heuristics.rs b/src/seed/heuristics.rs index d62f78a..3162dd2 100644 --- a/src/seed/heuristics.rs +++ b/src/seed/heuristics.rs @@ -57,9 +57,14 @@ fn choose_generator_inner(table: &str, col: &ColumnSpec) -> Generator { /// the post-seed advisory; such columns still receive generic text. #[must_use] pub fn is_enum_ish(name: &str) -> bool { + // `priority` is intentionally absent: issue #34 gave it a built-in + // value set (low/medium/high · 1/2/3), so it is no longer "filled + // generically" and must not trigger the D13 advisory. `severity` / + // `rating` / `stars` were never here. `status` stays — it is + // deliberately left to the advisory (no built-in set). const ENUM_TOKENS: &[&str] = &[ "role", "status", "state", "type", "kind", "category", "level", - "tier", "stage", "priority", "gender", + "tier", "stage", "gender", ]; let toks = tokens(name); toks.iter().any(|t| ENUM_TOKENS.contains(&t.as_str())) @@ -150,6 +155,49 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option Vec { out } +/// A `PickFrom` generator from string-literal values (issue #34's +/// conventional choice sets). `literal_to_value` interprets each entry +/// by the destination type at generation time (an `int` column turns +/// `"1"` into a number). +fn pick_from(values: &[&str]) -> Generator { + Generator::PickFrom(values.iter().map(|s| (*s).to_string()).collect()) +} + fn has_token(toks: &[String], t: &str) -> bool { toks.iter().any(|x| x == t) } @@ -412,11 +468,81 @@ mod tests { assert!(is_enum_ish("status")); assert!(is_enum_ish("role")); assert!(is_enum_ish("order_state")); - assert!(is_enum_ish("priority")); + // Issue #34: `priority` gained a built-in value set, so it is no + // longer advised (it is no longer "filled generically"). + assert!(!is_enum_ish("priority")); + assert!(!is_enum_ish("severity")); + assert!(!is_enum_ish("rating")); assert!(!is_enum_ish("email")); assert!(!is_enum_ish("first_name")); } + #[test] + fn year_like_int_columns_map_to_bounded_years() { + // Issue #33: `int`-gated year heuristics. `birth`/`born`/`dob` + // years pick the birth window; the rest a recent window. + assert_eq!(choose("authors", "birth_year", Type::Int), Generator::YearBirth); + assert_eq!(choose("authors", "birthYear", Type::Int), Generator::YearBirth); + assert_eq!(choose("u", "year_born", Type::Int), Generator::YearBirth); + assert_eq!(choose("books", "year", Type::Int), Generator::YearRecent); + assert_eq!(choose("films", "release_year", Type::Int), Generator::YearRecent); + assert_eq!(choose("books", "published", Type::Int), Generator::YearRecent); + assert_eq!(choose("companies", "founded", Type::Int), Generator::YearRecent); + // Type-gated: a text `year` is not a bounded-year int. + assert_eq!(choose("books", "year", Type::Text), Generator::Generic); + // `year_count` is a count, not a year — the quantity rule wins. + assert_eq!(choose("t", "year_count", Type::Int), Generator::SmallInt); + } + + #[test] + fn conventional_choice_sets_map_to_pick_from() { + // Issue #34: type-gated built-in value sets. + assert_eq!( + choose("tickets", "priority", Type::Text), + Generator::PickFrom(vec!["low".into(), "medium".into(), "high".into()]), + ); + assert_eq!( + choose("tickets", "prio", Type::Int), + Generator::PickFrom(vec!["1".into(), "2".into(), "3".into()]), + ); + assert_eq!( + choose("bugs", "severity", Type::Text), + Generator::PickFrom(vec!["low".into(), "medium".into(), "high".into(), "critical".into()]), + ); + assert_eq!( + choose("bugs", "severity", Type::Int), + Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into()]), + ); + assert_eq!( + choose("reviews", "rating", Type::Int), + Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into(), "5".into()]), + ); + assert_eq!( + choose("reviews", "stars", Type::Int), + Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into(), "5".into()]), + ); + } + + #[test] + fn status_is_left_to_the_advisory_not_given_a_set() { + // User-confirmed (issue #34): `status` keeps the D12 "don't + // guess" stance — generic text + the advisory, no built-in set. + assert_eq!(choose("orders", "status", Type::Text), Generator::Generic); + assert!(is_enum_ish("status")); + } + + #[test] + fn a_declared_in_check_still_wins_over_a_built_in_set() { + // The CHECK is the user's explicit intent; it precedes the + // issue-#34 default set for the same name. + let mut spec = ColumnSpec::plain("priority", Type::Text); + spec.check_in_values = Some(vec!["p1".into(), "p2".into()]); + assert_eq!( + choose_generator("tickets", &spec), + Generator::PickFrom(vec!["p1".into(), "p2".into()]), + ); + } + #[test] fn enum_ish_columns_fall_through_to_generic() { // No special generator — generic text + the advisory flags them. diff --git a/src/seed/mod.rs b/src/seed/mod.rs index 1a4d424..452097b 100644 --- a/src/seed/mod.rs +++ b/src/seed/mod.rs @@ -149,6 +149,13 @@ pub enum Generator { Age, /// A small positive integer (quantities, counts). SmallInt, + /// A plausible recent year as a plain `int` — `year` / `*_year` / + /// `published` / `founded` columns (issue #33). Bounded window so the + /// type-based `int` fallback can't emit nonsense like `9419`. + YearRecent, + /// A plausible birth year as a plain `int` — `birth_year` and kin + /// (issue #33), the year-typed sibling of [`Self::DateAdult`]. + YearBirth, // — Temporal (bounded windows, D8) — /// A date within the last few years. DateRecent, diff --git a/tests/it/seed.rs b/tests/it/seed.rs index a4a1bc2..eba3e3c 100644 --- a/tests/it/seed.rs +++ b/tests/it/seed.rs @@ -281,6 +281,123 @@ fn seed_populates_a_table_and_persists_rows() { assert!(csv.contains('@'), "seeded emails should appear in the CSV:\n{csv}"); } +/// Parse a seeded table's CSV into per-column value lists (simple +/// comma-split — the values under test carry no commas/quotes). +fn csv_columns(csv: &str) -> (Vec, Vec>) { + let mut lines = csv.lines().filter(|l| !l.trim().is_empty()); + let header: Vec = lines.next().unwrap().split(',').map(str::to_string).collect(); + let rows: Vec> = + lines.map(|l| l.split(',').map(str::to_string).collect()).collect(); + (header, rows) +} + +fn column_values(csv: &str, col: &str) -> Vec { + let (header, rows) = csv_columns(csv); + let idx = header.iter().position(|h| h == col).expect("column present"); + rows.iter().map(|r| r[idx].clone()).collect() +} + +#[test] +fn seed_year_and_choice_set_heuristics() { + // Issues #33 (year-like int columns) + #34 (conventional choice + // sets). A fixed `--seed` makes the values deterministic; we assert + // membership in the bounded windows / value sets rather than exact + // strings (robust to RNG-internals changes, still proves the + // heuristic fired — the type fallback would produce 9419 / lorem). + let (project, db, _dir) = open_project_db(); + let rt = rt(); + rt.block_on(db.create_table( + "Records".to_string(), + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("birth_year", Type::Int), + ColumnSpec::new("published", Type::Int), + ColumnSpec::new("priority", Type::Text), + ColumnSpec::new("severity", Type::Text), + ColumnSpec::new("rating", Type::Int), + ], + vec!["id".to_string()], + None, + )) + .expect("create Records"); + + rt.block_on(db.seed("Records".into(), None, Some(30), Vec::new(), Some(99), Some("seed Records 30".into()))) + .expect("seed succeeds"); + let csv = read_csv(&project, "Records").expect("Records CSV exists"); + + for y in column_values(&csv, "birth_year") { + let n: i32 = y.parse().expect("birth_year is an int"); + assert!((1945..=2007).contains(&n), "birth_year {n} must be a plausible birth year"); + } + for y in column_values(&csv, "published") { + let n: i32 = y.parse().expect("published is an int"); + assert!((1950..=2025).contains(&n), "published {n} must be a plausible recent year"); + } + for p in column_values(&csv, "priority") { + assert!(["low", "medium", "high"].contains(&p.as_str()), "priority `{p}` must be low/medium/high"); + } + for s in column_values(&csv, "severity") { + assert!( + ["low", "medium", "high", "critical"].contains(&s.as_str()), + "severity `{s}` must be low/medium/high/critical", + ); + } + for r in column_values(&csv, "rating") { + let n: i32 = r.parse().expect("rating is an int"); + assert!((1..=5).contains(&n), "rating {n} must be 1–5"); + } +} + +#[test] +fn seed_column_fill_uses_choice_set_heuristic() { + // The `seed
.` column-fill path (an UPDATE over + // existing rows) shares `choose_generator`, so issue #34's value + // sets apply there too. Insert rows with `priority` left NULL, then + // fill just that column and confirm it collapses to the set. + let (project, db, _dir) = open_project_db(); + let rt = rt(); + rt.block_on(db.create_table( + "Tasks".to_string(), + vec![ + ColumnSpec::new("id", Type::Serial), + ColumnSpec::new("title", Type::Text), + ColumnSpec::new("priority", Type::Text), + ], + vec!["id".to_string()], + None, + )) + .expect("create Tasks"); + for t in ["a", "b", "c", "d"] { + rt.block_on(db.insert( + "Tasks".to_string(), + Some(vec!["title".to_string()]), + vec![Value::Text(t.to_string())], + None, + )) + .expect("insert row"); + } + + rt.block_on(db.seed( + "Tasks".into(), + Some("priority".into()), + None, + Vec::new(), + Some(5), + Some("seed Tasks.priority".into()), + )) + .expect("column-fill priority"); + + let csv = read_csv(&project, "Tasks").expect("Tasks CSV"); + let priorities = column_values(&csv, "priority"); + assert_eq!(priorities.len(), 4, "every existing row is filled:\n{csv}"); + for p in priorities { + assert!( + ["low", "medium", "high"].contains(&p.as_str()), + "column-fill priority `{p}` must be low/medium/high", + ); + } +} + #[test] fn seed_count_defaults_to_twenty() { let (project, db, _dir) = open_project_db(); From dc63ed66f1a651260ccc7df3fd497c1ab93438b5 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 21:08:04 +0000 Subject: [PATCH 07/50] ci: nix CI toolchain image (node-slim base + warmed flake) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Job-container image the gate runs in. node:22-bookworm-slim satisfies the act_runner contract (sleep/bash/node) far more cheaply than the catthehacker images; single-user nix installed on top (pre-create /nix + empty build-users-group so it installs as root in a container) with the flake's devShell pre-warmed — CI enters a ready 1.95.0 toolchain in ~1.4s. Verified by local build. ~5.5GB (rust toolchain closure); dep/ target caching is a noted follow-up. --- .gitea/ci-image/Dockerfile | 65 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .gitea/ci-image/Dockerfile diff --git a/.gitea/ci-image/Dockerfile b/.gitea/ci-image/Dockerfile new file mode 100644 index 0000000..161a02a --- /dev/null +++ b/.gitea/ci-image/Dockerfile @@ -0,0 +1,65 @@ +# CI toolchain image for rdbms-playground. +# +# Purpose: a SMALL job-container image that +# (a) satisfies the Gitea act_runner job-container contract — /bin/sleep (the +# keep-alive entrypoint), bash (run: steps), node (JS actions such as +# actions/checkout); a bare nixos/nix image has none of these and won't +# even start (verified by the ci-probe run: "/bin/sleep: no such file"); and +# (b) carries the project's pinned nix toolchain with the flake's devShell +# pre-warmed, so CI runs `nix develop -c cargo ...` against a warm store. +# +# Base: node:22-bookworm-slim. Debian slim already provides bash + coreutils +# (sleep); the node tag adds the actions runtime. Far smaller than the +# catthehacker runner images (which bundle a whole GitHub-runner emulation we +# don't need). +FROM node:22-bookworm-slim + +# nix install + flake eval needs these. git because flakes prefer a VCS context +# and tools shell out to it. Drop apt lists to keep the layer small. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + curl xz-utils ca-certificates git \ + && rm -rf /var/lib/apt/lists/* + +# Single-user nix (--no-daemon): store at /nix owned by root, no daemon/systemd +# needed — the correct mode for a container. The official installer refuses root +# and shells out to `sudo` purely to create /nix; pre-creating it ourselves (we +# ARE root) sidesteps both. Enable flakes globally so every nix invocation (and +# the runner's steps) get nix-command + flakes without flags. +# nix.conf is written FIRST so the installer's own `nix-env` profile step reads +# it: `build-users-group =` (empty) makes single-user nix build as the calling +# user (root) instead of demanding the nixbld group/users a daemon install would +# create; flakes are enabled globally in the same file. +RUN mkdir -m 0755 /nix && chown root:root /nix \ + && mkdir -p /etc/nix \ + && printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf \ + && curl --proto '=https' --tlsv1.2 -sSf -L https://nixos.org/nix/install -o /tmp/nix-install.sh \ + && sh /tmp/nix-install.sh --no-daemon \ + && rm /tmp/nix-install.sh +ENV PATH=/root/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH +# We set PATH directly instead of sourcing the profile, so also point nix at the +# Debian CA bundle (already installed) for substituter HTTPS — otherwise the +# profile-provided NIX_SSL_CERT_FILE is missing and store downloads fail. +ENV NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt + +# Warm the flake's devShell into the store: realizes nixpkgs + the pinned Rust +# toolchain (rustc/cargo/clippy/rustfmt) + cargo-sweep. Only the inputs that +# determine the shell are copied, so this expensive layer is cached and only +# re-runs when the flake or the toolchain pin changes — not on every source edit. +# (devShell eval is lazy: packages.default — and thus Cargo.toml/Cargo.lock — is +# never forced here, so it needn't be present.) +WORKDIR /warm +COPY flake.nix flake.lock rust-toolchain.toml ./ +RUN nix develop -c rustc --version \ + && nix develop -c cargo --version \ + && nix develop -c cargo clippy --version \ + && nix develop -c cargo fmt --version \ + && nix develop -c cargo sweep --version +WORKDIR / +RUN rm -rf /warm + +# FOLLOW-UP optimisation (intentionally NOT done here, see CI notes): cargo +# dependency + target caching. Each CI run still compiles the ~296-crate graph +# from scratch and pulls crate sources from crates.io. A later pass can bake +# `cargo fetch` (offline crate sources) and/or a warmed target dir, or wire +# sccache, to cut run time. Correctness/first-green first; speed next. From 9d8161218ad5bc44d03c4661432d6261f428ed76 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 21:08:04 +0000 Subject: [PATCH 08/50] ci: gate workflow + CI-image build/push, drop probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build-ci-image.yaml: builds .gitea/ci-image/Dockerfile via DinD and pushes git.lazyeval.net/oli/rdbms-playground-ci:latest (REGISTRY_* secrets); triggers on image-input changes + manual dispatch. - ci.yaml: the gate — runs inside that image, clippy -D warnings + cargo test, on push/PR. fmt intentionally not gated (ADR-0049). Removes ci-probe.yaml; it answered the runner questions (jobs run in containers, host nix unreachable, custom container: works). --- .gitea/workflows/build-ci-image.yaml | 47 ++++++++++++++++++ .gitea/workflows/ci-probe.yaml | 73 ---------------------------- .gitea/workflows/ci.yaml | 24 +++++++++ 3 files changed, 71 insertions(+), 73 deletions(-) create mode 100644 .gitea/workflows/build-ci-image.yaml delete mode 100644 .gitea/workflows/ci-probe.yaml create mode 100644 .gitea/workflows/ci.yaml diff --git a/.gitea/workflows/build-ci-image.yaml b/.gitea/workflows/build-ci-image.yaml new file mode 100644 index 0000000..9cea8bb --- /dev/null +++ b/.gitea/workflows/build-ci-image.yaml @@ -0,0 +1,47 @@ +# Builds the nix CI toolchain image (.gitea/ci-image/Dockerfile) and pushes it +# to the Gitea registry. The gate (ci.yaml) runs *inside* this image, so this +# workflow is the gate's prerequisite. It only needs to run when the image's +# inputs change — the Dockerfile, the flake, or the toolchain pin — plus on +# manual dispatch. +# +# DinD pattern: plain docker:27-dind (one of the tested ci-test samples). No +# registry proxy here — the runner's containers have direct internet egress +# (the ci-probe run cloned github.com and pulled docker.io with no proxy), and +# this image's RUN steps fetch from apt + nixos.org, which the proxy isn't +# guaranteed to forward. The dind-cached:local + REGISTRY_PROXY_HOST variant is +# a later speed optimisation for base-image pull caching, not needed for green. +name: build-ci-image +on: + push: + paths: + - '.gitea/ci-image/Dockerfile' + - 'flake.nix' + - 'flake.lock' + - 'rust-toolchain.toml' + - '.gitea/workflows/build-ci-image.yaml' + workflow_dispatch: + +jobs: + build: + runs-on: ci-public + services: + docker: + image: docker:27-dind + options: --privileged + env: + DOCKER_TLS_CERTDIR: "" + env: + DOCKER_HOST: tcp://docker:2375 + IMAGE: git.lazyeval.net/oli/rdbms-playground-ci + steps: + - uses: actions/checkout@v4 + - name: wait for docker + run: until docker version >/dev/null 2>&1; do sleep 1; done + - name: registry login + run: | + echo "${{ secrets.REGISTRY_TOKEN }}" \ + | docker login git.lazyeval.net -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin + - name: build + run: docker build -f .gitea/ci-image/Dockerfile -t "$IMAGE:latest" . + - name: push + run: docker push "$IMAGE:latest" diff --git a/.gitea/workflows/ci-probe.yaml b/.gitea/workflows/ci-probe.yaml deleted file mode 100644 index 19fd23a..0000000 --- a/.gitea/workflows/ci-probe.yaml +++ /dev/null @@ -1,73 +0,0 @@ -# THROWAWAY DIAGNOSTIC — delete once the real gate is wired. -# -# This answers the questions that decide the CI architecture, on facts rather -# than guesses: -# * How does this runner execute a plain job — directly on the host, or inside -# a default container? (-> is "the ci server has nix" reachable from steps?) -# * Is `nix` on PATH where steps run, and does a /nix store persist? -# * Is a docker client/daemon reachable from a plain job (no DinD service)? -# * Does a custom job `container:` work on this rootless runner, and can it pull -# an image (nixos/nix) — i.e. is the "reusable nix image" model viable? -# -# Trigger: push to this branch, or run manually from the Actions UI. -name: ci-probe -on: [push, workflow_dispatch] - -jobs: - # --- Job 1: DEFAULT execution ------------------------------------------- - # No `container:` override — this is whatever environment the runner gives a - # plain job. Tells us where steps actually run and what's already there. - host: - runs-on: ci-public - steps: - - name: identity & environment - run: | - echo "=== uname ==="; uname -a - echo "=== os-release ==="; head -3 /etc/os-release 2>/dev/null || echo "(none)" - echo "=== whoami / id ==="; whoami; id - echo "=== containerized? ===" - if [ -f /.dockerenv ]; then - echo "/.dockerenv PRESENT -> steps run INSIDE a container" - else - echo "/.dockerenv absent" - fi - echo "--- /proc/1/cgroup (first lines) ---"; head -5 /proc/1/cgroup 2>/dev/null || echo "(none)" - - - name: nix availability (the decisive check) - run: | - echo "=== which nix ==="; command -v nix || echo "nix NOT on PATH" - echo "=== nix --version ==="; nix --version 2>/dev/null || echo "(no nix here)" - echo "=== /nix store ==="; ls -ld /nix /nix/store 2>/dev/null || echo "(no /nix)" - echo "=== store path count (persistence hint; high => warm/shared) ===" - ls /nix/store 2>/dev/null | wc -l - - - name: docker availability (without a DinD service) - run: | - echo "=== which docker ==="; command -v docker || echo "docker NOT on PATH" - docker version 2>/dev/null || echo "(no docker client/daemon reachable from a plain job)" - - - name: checkout — does the flake land here? - uses: actions/checkout@v4 - - - name: flake present in this checkout? - run: ls -la flake.nix flake.lock rust-toolchain.toml 2>/dev/null || echo "(flake not on this branch's checkout)" - - # --- Job 2: CUSTOM CONTAINER -------------------------------------------- - # Tests the "reusable nix image" model: run steps inside a pinned nix image. - # Deliberately minimal — no checkout (the checkout action needs node, which a - # bare nixos/nix image lacks; that's a separate concern). If this job's steps - # run at all, custom job containers are viable on this runner. - nix-container: - runs-on: ci-public - container: - image: nixos/nix:latest - steps: - - name: nix inside a pinned container - run: | - echo "=== inside nixos/nix container ===" - nix --version - echo "--- identity ---"; whoami; id; uname -a - echo "--- flakes enabled? ---" - nix --extra-experimental-features 'nix-command flakes' flake --help >/dev/null 2>&1 \ - && echo "flakes usable (with --extra-experimental-features)" \ - || echo "flake subcommand not usable as invoked" diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..cdf32f9 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,24 @@ +# The CI gate. Runs inside the prebuilt nix toolchain image (built + pushed by +# build-ci-image.yaml), so the pinned 1.95.0 toolchain is already warm — steps +# just enter the flake devShell and run cargo. +# +# Gate = clippy + test. fmt is deliberately NOT gated yet (ADR-0049: the tree +# isn't clean under stock rustfmt; revisit on main). The release job (static +# binary for D2) and the platform matrix layer on later, step by step. +name: ci +on: + push: + pull_request: + +jobs: + gate: + runs-on: ci-public + # Public package → anonymous pull, no credentials needed. + container: + image: git.lazyeval.net/oli/rdbms-playground-ci:latest + steps: + - uses: actions/checkout@v4 + - name: clippy (warnings denied) + run: nix develop -c cargo clippy --all-targets -- -D warnings + - name: test + run: nix develop -c cargo test --no-fail-fast From ee3ccd8d771c0e598d27093ae6bd2bc2bb4db8bf Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 21:34:48 +0000 Subject: [PATCH 09/50] feat(hint): advertise the optional seed count in the hint panel (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At `seed
▮` the hint showed only the `set`/`--seed` chips and never mentioned the optional row count — a bare positional number with no candidate, on an already-complete command, so neither the candidate ladder nor the resolver surfaced it. (A prior IntroProse attempt was reverted: pending_hint_mode is cleared by the trailing optionals.) Carry a skipped Optional's IntroProse hint: walk_optional stashes the inner's key into a new WalkContext.surviving_intro_hint (key + position) before the empty match clears pending_hint_mode; the snapshot keeps it only when the skip position is the cursor (so it never leaks past a later-consumed `set …` clause, nor once the count is given); the resolver returns it ahead of the empty-expected short-circuit. The seed count is wrapped Hinted{IntroProse("hint.seed_count")}; the prose names the count (default 20), the `.column` column-fill form, and `set` / `--seed`. Tab still cycles the keywords. Only IntroProse is carried; ProseOnly/ForceProse and the CREATE-TABLE element (a required Repeated) are untouched. No AmbientHint/renderer change. Fires in both modes. ADR-0022 Amendment 7; +3 tests. --- docs/adr/0022-ambient-typing-assistance.md | 52 +++++++++++++ docs/adr/README.md | 2 +- src/dsl/grammar/data.rs | 13 +++- src/dsl/walker/context.rs | 13 ++++ src/dsl/walker/driver.rs | 17 +++++ src/dsl/walker/mod.rs | 27 +++++++ src/friendly/keys.rs | 1 + src/friendly/strings/en-US.yaml | 6 ++ src/input_render.rs | 87 ++++++++++++++++++++++ 9 files changed, 216 insertions(+), 2 deletions(-) diff --git a/docs/adr/0022-ambient-typing-assistance.md b/docs/adr/0022-ambient-typing-assistance.md index 254b45e..1150d34 100644 --- a/docs/adr/0022-ambient-typing-assistance.md +++ b/docs/adr/0022-ambient-typing-assistance.md @@ -772,6 +772,58 @@ invalid_ident_does_not_fire_for_column_prefix_at_sql_expr_slot}`; `theme::function_colour_is_distinct_from_keyword_identifier_and_type`. See ADR-0031's status note for the grammar-side anchor. +## Amendment 7 — optional positional args reach the hint panel (2026-06-12) + +Issue #26. At `seed
▮` the hint panel showed only the +`set` / `--seed` continuation chips and never mentioned the +**optional row count** — even though a count (`seed users 50`) is +the most common next move. The count is a bare positional +`NumberLit` with no keyword/candidate text, so the candidate ladder +can't surface it; and `seed
` is already a *complete* +command, so the hint resolver short-circuits (empty expected set). + +The existing `IntroProse` `HintMode` (ADR-0024 §HintMode-per-node; +issue #4's CREATE-TABLE element hint) is the right tool — it shows +prose that *introduces* a position whose first-class move has no +candidate, with the keyword alternatives folded into the prose and +Tab still cycling them. But it did not reach this position: a +`Node::Hinted`'s mode lives in `pending_hint_mode`, which the very +next match clears — including the **empty** match of a skipped +`Optional`. The CREATE-TABLE element survives only because it sits +in a *required* `Repeated(min:1)`; an optional positional followed +by more optionals (the seed count) is cleared before the resolver +reads it. + +### Mechanism + +A small, general carry: when `walk_optional` skips its inner (the +inner didn't engage), it stashes any `IntroProse` key the inner +left in `pending_hint_mode` into a new `WalkContext` field, +`surviving_intro_hint: Option<(key, position)>`, **before** the +empty match clears `pending_hint_mode`. The trailing optionals, +which are not `IntroProse`, don't overwrite it. The hint snapshot +keeps the key **only when `position == cursor`** (the slice end), +so it shows while the cursor sits at the count slot but not once a +later clause (`set …`) consumes input past it, nor once the count +itself is supplied. The resolver returns that `IntroProse` even for +an otherwise-complete command (ahead of the empty-expected +short-circuit). + +The seed grammar wraps the count in +`Hinted { IntroProse("hint.seed_count"), NumberLit }`; the prose +names the count (with its default 20) plus the `.column` +column-fill form and the `set` / `--seed` keywords (user-chosen +scope: mention every option). Only `IntroProse` is carried — +`ProseOnly` / `ForceProse` mark *active* slots and reach the +resolver through the normal path, unchanged. The CREATE-TABLE +element (in a `Repeated`, not an `Optional`) is untouched. + +This is a refinement of ADR-0024 §HintMode-per-node and a sibling +of issue #4; no `AmbientHint` / renderer change. Covered by +`input_render::{seed_count_is_advertised_at_the_optional_position, +seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given, +seed_count_hint_also_fires_after_a_column_fill_target}`. + ## Out of scope Deliberately deferred to keep this ADR shippable as a single diff --git a/docs/adr/README.md b/docs/adr/README.md index 13a0fb0..9389da8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -27,7 +27,7 @@ This directory contains the project's ADRs, recorded per - [ADR-0019 — Friendly error layer (H1) and i18n message catalog](0019-friendly-error-layer-and-i18n.md) - [ADR-0020 — Tokenization layer for the DSL parser](0020-tokenization-layer-for-the-dsl-parser.md) — **Superseded by ADR-0024 (never implemented).** Specified a `chumsky`-over-tokens architecture (separate lexer, `define_keywords!`, `&[Token]` grammar). ADR-0024 adopted a scannerless hand-rolled walker and removed `chumsky` entirely; the lexer/keyword/token model here does not exist. Kept as institutional memory of the path not taken. - [ADR-0021 — Parser-as-source-of-truth for H1a (per-command usage in parse errors)](0021-parser-as-source-of-truth-for-h1a.md) — **Mechanism superseded by ADR-0024; H1a scope continued in ADR-0042.** The *intent* (show the command's grammar at the point of error) shipped — `usage_ids` on each `CommandNode`, the `parse.usage.*` templates, and the `available_commands` fallback all exist — but via grammar nodes, not the `chumsky` `UsageEntry` registry / `parse.token.*` keys described here (which were never built). -- [ADR-0022 — Ambient typing assistance: colour, hint panel, completion (I3 + I4)](0022-ambient-typing-assistance.md) — **Amendment 1 supersedes §12's simple-mode-only carve-out**: the unified mode-aware walker (ADR-0030/0031/0032) now speaks SQL, so advanced-mode ambient assistance is re-enabled. `ambient_hint_in_mode` + `hint_resolution_at_input_in_mode` + `expected_for_hint_snapshot` thread `Mode`; `render_hint_panel` calls ambient for all modes (no more advanced-mode `None`); the one-shot `:` sigil is stripped before the ambient walk. Fixes a live bug where advanced-mode SQL hinting/completion-preview were dead despite Phase 2 marking them green (validated at the engine layer, not the UI). Simple-mode gating, highlighting, and the §13 performance posture are unchanged; covered by an app-level render test plus ambient-layer regression locks; **Amendment 2 reverses the handoff-14 keywords-first candidate ordering** — schema identifiers (table/column/relationship names) now sort *before* keywords so a name the user would have to look up stays visible in the single-row, window-scrolled candidate line (keywords are learned over time; the `tok_identifier`/`tok_keyword` colour split marks the boundary); shipped with a `walk_repeated` fix that surfaces a list item's trailing optionals at a clean boundary (`order by Name ` → `asc`/`desc`, `select Name ` → `as`, `create table … Code(text) ` → `not`/`unique`/`default`/`check`; the `,` separator deliberately not surfaced); records a deferred two-line hint box for growing lists; **Amendment 3 makes the ambient-hint fallback rung schema-aware** — Amendment 1's bottom-rung `parse_command_in_mode` was schemaless while every earlier rung was not, so between-values insert hints pointed at `)` (type-blind close) instead of `,` and wrong-arity closed tuples read "submit with Enter" for an input the schema-aware parse rejects (issue #2); now uses `parse_command_with_schema_in_mode`, no extra walk, with the friendly arity diagnostic still winning at its higher rung; **Amendment 4 gives column types a dedicated highlight class** — both `Node::Ident.highlight_override` *and* the `Word.highlight_override` field were dead (driver destructured the former to `_`, `walk_word` hardcoded `Keyword`); now both wired through, with a new `HighlightClass::Type` + eighth `Theme` field `tok_type` (a pink/deep-magenta distinct from both keyword purple and identifier teal) so types no longer render identically to identifiers (issue #8); the three `IdentSource::Types` slots opt in via `Some(Type)` (advanced-mode single-word SQL aliases — `float`, `varchar`, … per ADR-0035 §3 — ride along for free), and the two-word `double precision` alias opts in via the new `Word::type_keyword` constructor so it matches its synonyms; **Amendment 5 lets the hint panel grow for long prose hints** — a fixed one-row panel clipped long field-value/usage hints past the first line (issue #12); `resolve_hint_lines` now pre-wraps prose and `render_right_column` sizes the panel to the line count (1 row default, up to `MAX_HINT_ROWS`=3, reclaimed when short) with a `clamp_wrapped` ellipsis backstop; the candidate list still scrolls horizontally on one row (Amendment 2's deferred two-line candidate box stays deferred); also shortens the 299-char `parse.usage.sql_create_table` synopsis to a terse one-liner (full grammar remains in `help.ddl.sql_create_table`); **Amendment 6 adds a curated SQL function-name list** (`src/dsl/sql_functions.rs`, `KNOWN_SQL_FUNCTIONS` — aggregates + common + broader scalars; `cast` deliberately excluded as its `CAST(x AS type)` syntax isn't a plain-call shape) as the single source of truth shared by two consumers at the `sql_expr_ident` slot (ADR-0031 §1): **issue #15** offers the functions as Tab candidates under a new `CandidateKind::Function` + ninth `Theme` colour `tok_function` (a blue distinct from keyword/identifier/type, parallel to Amendment 4's `tok_type`) so a learner discovers `sum`/`upper`/…; **issue #16** restores the typing-time column-typo flag the issue-#6 fix had dropped wholesale at this slot — `invalid_ident_at_cursor` now bails only when the partial prefix-matches a known function, else falls through to the schema-column check, so `select Agx` warns again at typing time while `select sum` does not (the issue-#6 lockdown tests + the submit-time `unknown_column` diagnostic path are untouched, and the no-validation-allowlist posture stands); see ADR-0031's status note for the grammar-side anchor +- [ADR-0022 — Ambient typing assistance: colour, hint panel, completion (I3 + I4)](0022-ambient-typing-assistance.md) — **Amendment 1 supersedes §12's simple-mode-only carve-out**: the unified mode-aware walker (ADR-0030/0031/0032) now speaks SQL, so advanced-mode ambient assistance is re-enabled. `ambient_hint_in_mode` + `hint_resolution_at_input_in_mode` + `expected_for_hint_snapshot` thread `Mode`; `render_hint_panel` calls ambient for all modes (no more advanced-mode `None`); the one-shot `:` sigil is stripped before the ambient walk. Fixes a live bug where advanced-mode SQL hinting/completion-preview were dead despite Phase 2 marking them green (validated at the engine layer, not the UI). Simple-mode gating, highlighting, and the §13 performance posture are unchanged; covered by an app-level render test plus ambient-layer regression locks; **Amendment 2 reverses the handoff-14 keywords-first candidate ordering** — schema identifiers (table/column/relationship names) now sort *before* keywords so a name the user would have to look up stays visible in the single-row, window-scrolled candidate line (keywords are learned over time; the `tok_identifier`/`tok_keyword` colour split marks the boundary); shipped with a `walk_repeated` fix that surfaces a list item's trailing optionals at a clean boundary (`order by Name ` → `asc`/`desc`, `select Name ` → `as`, `create table … Code(text) ` → `not`/`unique`/`default`/`check`; the `,` separator deliberately not surfaced); records a deferred two-line hint box for growing lists; **Amendment 3 makes the ambient-hint fallback rung schema-aware** — Amendment 1's bottom-rung `parse_command_in_mode` was schemaless while every earlier rung was not, so between-values insert hints pointed at `)` (type-blind close) instead of `,` and wrong-arity closed tuples read "submit with Enter" for an input the schema-aware parse rejects (issue #2); now uses `parse_command_with_schema_in_mode`, no extra walk, with the friendly arity diagnostic still winning at its higher rung; **Amendment 4 gives column types a dedicated highlight class** — both `Node::Ident.highlight_override` *and* the `Word.highlight_override` field were dead (driver destructured the former to `_`, `walk_word` hardcoded `Keyword`); now both wired through, with a new `HighlightClass::Type` + eighth `Theme` field `tok_type` (a pink/deep-magenta distinct from both keyword purple and identifier teal) so types no longer render identically to identifiers (issue #8); the three `IdentSource::Types` slots opt in via `Some(Type)` (advanced-mode single-word SQL aliases — `float`, `varchar`, … per ADR-0035 §3 — ride along for free), and the two-word `double precision` alias opts in via the new `Word::type_keyword` constructor so it matches its synonyms; **Amendment 5 lets the hint panel grow for long prose hints** — a fixed one-row panel clipped long field-value/usage hints past the first line (issue #12); `resolve_hint_lines` now pre-wraps prose and `render_right_column` sizes the panel to the line count (1 row default, up to `MAX_HINT_ROWS`=3, reclaimed when short) with a `clamp_wrapped` ellipsis backstop; the candidate list still scrolls horizontally on one row (Amendment 2's deferred two-line candidate box stays deferred); also shortens the 299-char `parse.usage.sql_create_table` synopsis to a terse one-liner (full grammar remains in `help.ddl.sql_create_table`); **Amendment 6 adds a curated SQL function-name list** (`src/dsl/sql_functions.rs`, `KNOWN_SQL_FUNCTIONS` — aggregates + common + broader scalars; `cast` deliberately excluded as its `CAST(x AS type)` syntax isn't a plain-call shape) as the single source of truth shared by two consumers at the `sql_expr_ident` slot (ADR-0031 §1): **issue #15** offers the functions as Tab candidates under a new `CandidateKind::Function` + ninth `Theme` colour `tok_function` (a blue distinct from keyword/identifier/type, parallel to Amendment 4's `tok_type`) so a learner discovers `sum`/`upper`/…; **issue #16** restores the typing-time column-typo flag the issue-#6 fix had dropped wholesale at this slot — `invalid_ident_at_cursor` now bails only when the partial prefix-matches a known function, else falls through to the schema-column check, so `select Agx` warns again at typing time while `select sum` does not (the issue-#6 lockdown tests + the submit-time `unknown_column` diagnostic path are untouched, and the no-validation-allowlist posture stands); see ADR-0031's status note for the grammar-side anchor; **Amendment 7 surfaces optional positional args in the hint panel** (issue #26): at `seed
▮` the optional row count (a bare `NumberLit` with no candidate) was invisible next to the `set`/`--seed` chips, and the resolver short-circuits on the already-complete command. Extends the issue-#4 `IntroProse` `HintMode` (ADR-0024 §HintMode-per-node) to survive trailing optionals: `walk_optional` stashes a skipped inner's `IntroProse` key into a new `WalkContext.surviving_intro_hint` (key + position) before the empty match clears `pending_hint_mode`, and the snapshot keeps it only when the skip position is the cursor (so it never leaks past a later-consumed `set …` clause or once the count is given); the resolver returns it ahead of the empty-expected short-circuit. The seed count is wrapped `Hinted{IntroProse("hint.seed_count")}`; prose names the count (default 20), the `.column` column-fill form, and `set`/`--seed` (user-chosen scope). Only `IntroProse` is carried; `ProseOnly`/`ForceProse` and the CREATE-TABLE element (a required `Repeated`) are untouched; no `AmbientHint`/renderer change - [ADR-0023 — Unified declarative grammar tree](0023-unified-grammar-tree.md) — direction (superseded for execution detail by ADR-0024) - [ADR-0024 — Unified grammar tree: execution plan](0024-unified-grammar-tree-execution-plan.md) — **Accepted**, the executable spec — implemented (Phases A–F; Phase F shipped "minimal", `parser.rs` retained as the router — see the ADR's Phase F implementation note) - [ADR-0025 — Indexes](0025-indexes.md) — **Accepted** (**Amendment 1, 2026-05-25**: UNIQUE indexes admitted on the **advanced-mode** surface via `CREATE UNIQUE INDEX` — ADR-0035 §4d; the `IndexSchema.unique` flag round-trips through `project.yaml` with no new metadata table since the engine reports uniqueness natively; simple-mode `add unique index` stays deferred), `add index` / `drop index`, persistence, rebuild-table preservation, and items-list display (`C3` index portion + `S2`) diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index b111075..3f14b93 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -438,6 +438,17 @@ const LIMIT_CLAUSE: Node = Node::Seq(LIMIT_CLAUSE_NODES); const SEED_COUNT: Node = Node::NumberLit { validator: Some(LIMIT_VALIDATOR), }; +/// Issue #26: the row count is a bare positional number, so it produces +/// no Tab candidate and was invisible in the hint panel at +/// `seed
▮` (only `set` / `--seed` showed). Wrapping it in +/// `IntroProse` advertises it (and the other options) in prose; the +/// skipped-optional carry (`surviving_intro_hint`) makes the hint reach +/// the resolver despite the trailing optionals. Tab still cycles the +/// keyword candidates. +const SEED_COUNT_HINTED: Node = Node::Hinted { + mode: crate::dsl::grammar::HintMode::IntroProse("hint.seed_count"), + inner: &SEED_COUNT, +}; /// `--seed ` — a reproducible-generation flag carrying a numeric /// seed (ADR-0048 D4). The only flag in the DSL that takes a value; /// `build_seed` reads the number immediately after the flag. @@ -567,7 +578,7 @@ const SEED_NODES: &[Node] = &[ // against this table. TABLE_NAME_WRITES, SEED_DOT_COLUMN, - Node::Optional(&SEED_COUNT), + Node::Optional(&SEED_COUNT_HINTED), Node::Optional(&SEED_SET_CLAUSE), Node::Optional(&SEED_FLAG), ]; diff --git a/src/dsl/walker/context.rs b/src/dsl/walker/context.rs index 807f41e..6306119 100644 --- a/src/dsl/walker/context.rs +++ b/src/dsl/walker/context.rs @@ -134,6 +134,17 @@ pub struct WalkContext<'a> { /// resolver reads this directly instead of inferring the /// slot kind from the shape of the expected set. pub pending_hint_mode: Option, + /// An `IntroProse` hint captured from an *optional* slot that + /// the walk skipped (issue #26). Unlike `pending_hint_mode` + /// (cleared on the very next match — including the empty match + /// of a skipped `Optional`), this survives the trailing + /// optional siblings so the hint reaches the resolver for a + /// position like `seed
▮`, where the optional row + /// count is otherwise invisible. Carries the catalog key and + /// the byte position the optional was skipped at; the resolver + /// uses it only when that position is the cursor (so it doesn't + /// leak past a later-consumed clause). + pub surviving_intro_hint: Option<(&'static str, usize)>, /// The columns the user explicitly listed in /// `insert into (col1, col2, …) values (…)` (Form A), /// in declaration order. @@ -232,6 +243,7 @@ impl<'a> WalkContext<'a> { pending_value_type: None, pending_value_column: None, pending_hint_mode: None, + surviving_intro_hint: None, user_listed_columns: None, subgrammar_depth: 0, from_scope_stack: vec![ScopeFrame::default()], @@ -254,6 +266,7 @@ impl<'a> WalkContext<'a> { pending_value_type: None, pending_value_column: None, pending_hint_mode: None, + surviving_intro_hint: None, user_listed_columns: None, subgrammar_depth: 0, from_scope_stack: vec![ScopeFrame::default()], diff --git a/src/dsl/walker/driver.rs b/src/dsl/walker/driver.rs index e14f04f..231bc36 100644 --- a/src/dsl/walker/driver.rs +++ b/src/dsl/walker/driver.rs @@ -990,6 +990,21 @@ fn walk_seq( } } +/// Issue #26: when an `Optional` is skipped (its inner didn't engage), +/// stash any `IntroProse` hint the inner left in `pending_hint_mode` +/// into the surviving slot before it is cleared by this empty match. +/// `position` is where the optional was skipped — the resolver compares +/// it to the cursor so the hint only shows while the cursor sits at that +/// optional, not after a later clause consumes input past it. Only +/// `IntroProse` is carried (it is the "introduce an optional position" +/// mode); `ProseOnly` / `ForceProse` mark active slots and reach the +/// resolver through the normal `pending_hint_mode` path. +const fn capture_skipped_intro_hint(ctx: &mut WalkContext, position: usize) { + if let Some(crate::dsl::grammar::HintMode::IntroProse(key)) = ctx.pending_hint_mode { + ctx.surviving_intro_hint = Some((key, position)); + } +} + fn walk_optional( source: &str, position: usize, @@ -1008,6 +1023,7 @@ fn walk_optional( // Inner didn't engage at all — skip the Optional // but carry the inner's expectations so the caller's // expected-set sees them. + capture_skipped_intro_hint(ctx, position); path.items.truncate(saved_path_len); per_byte.truncate(saved_byte_len); NodeWalkResult::Matched { @@ -1019,6 +1035,7 @@ fn walk_optional( // Inner reported Incomplete without consuming // anything — same as NoMatch from the user's // perspective. Roll back and skip. + capture_skipped_intro_hint(ctx, position); path.items.truncate(saved_path_len); per_byte.truncate(saved_byte_len); let _ = p; diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index 3723f20..575ec48 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -116,6 +116,19 @@ pub fn hint_resolution_at_input_in_mode( use crate::dsl::grammar::HintMode; let snap = expected_for_hint_snapshot(source, schema, mode); + // Issue #26: an optional positional slot with no candidate text + // (the `seed
` row count) left an `IntroProse` hint that + // survived the trailing optionals. It is shown even for an + // otherwise-complete command (empty expected set) — that is exactly + // the `seed users ▮` case where the count is invisible. Checked + // first, before the complete-command short-circuit below. + if let Some(key) = snap.surviving_intro_hint { + return Some(HintResolution { + mode: HintMode::IntroProse(key), + column: None, + form_b_autogen_skipped: Vec::new(), + }); + } // Empty expected set means the command is already complete // (`WalkOutcome::Match`) — no slot to hint at. if snap.expected.is_empty() { @@ -2599,6 +2612,11 @@ struct HintWalkSnapshot { /// The grammar-declared `HintMode` at the cursor's slot /// (`Node::Hinted` annotation, ADR-0024 §HintMode-per-node). pending_hint_mode: Option, + /// An `IntroProse` catalog key for an *optional* positional slot at + /// the cursor that produced no candidate (issue #26 — `seed
` + /// row count). Survives the trailing optional siblings that clear + /// `pending_hint_mode`; already filtered to the cursor position. + surviving_intro_hint: Option<&'static str>, current_table_columns: Option>, /// `Some` when the input used Form A's explicit column list. /// `None` for Form B (`insert into T values …`) and for @@ -2625,6 +2643,7 @@ fn expected_for_hint_snapshot( pending_value_type: None, pending_value_column: None, pending_hint_mode: None, + surviving_intro_hint: None, current_table_columns: None, user_listed_columns: None, }; @@ -2652,6 +2671,14 @@ fn expected_for_hint_snapshot( pending_value_type: ctx.pending_value_type, pending_value_column: ctx.pending_value_column, pending_hint_mode: ctx.pending_hint_mode, + // Issue #26: only surface the skipped-optional hint when the + // optional was skipped *at the cursor* (the end of the walked + // slice). Captured earlier (before a later clause consumed past + // it) → stale, so drop it. + surviving_intro_hint: ctx + .surviving_intro_hint + .filter(|(_, pos)| *pos == source.len()) + .map(|(key, _)| key), current_table_columns: ctx.current_table_columns, user_listed_columns: ctx.user_listed_columns, } diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index 57e2ed3..f5244b6 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -231,6 +231,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ // slot (`create table T (`) so the otherwise-invisible // column-name role reads as the dominant first move. ("hint.create_table_element", &[]), + ("hint.seed_count", &[]), ("hint.value_literal_slot", &[]), ( "hint.ambient_typing_name_then", diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 5999ff8..40930df 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -400,6 +400,12 @@ hint: # at `create table T (` so the column-name role is visible # alongside the table-level constraint keywords. create_table_element: "Type a column name, or a table-level constraint: `primary`, `unique`, `check`, `constraint`, `foreign`" + # Issue #26: the `seed
▮` position. The optional row count is + # a bare number with no Tab candidate, so it (and the `.column` + # column-fill form) would be invisible next to the `set` / `--seed` + # chips. Names every option so the most common next move (a count) is + # discoverable. + seed_count: "Optionally a row count, e.g. `50` (default 20); `.column` to fill one column on existing rows; `set` to pin a column; `--seed` to fix the RNG" # Value-literal slot — `insert ... values (`, `update ... set # col=`, `where col=`. Replaces the misleading "null true # false" keyword candidate list with format guidance for all diff --git a/src/input_render.rs b/src/input_render.rs index cf11299..01af66b 100644 --- a/src/input_render.rs +++ b/src/input_render.rs @@ -1356,6 +1356,93 @@ mod tests { } } + fn seed_cache() -> crate::completion::SchemaCache { + use crate::completion::TableColumn; + use crate::dsl::types::Type; + let mut cache = crate::completion::SchemaCache::default(); + cache.tables.push("users".to_string()); + cache.columns.push("email".to_string()); + cache + .table_columns + .insert("users".to_string(), vec![TableColumn::new("email", Type::Text)]); + cache + } + + #[test] + fn seed_count_is_advertised_at_the_optional_position() { + // Issue #26: `seed users ▮` is a complete command, so the hint + // ladder shows only the `set` / `--seed` continuation chips — + // the optional row count (a bare number with no candidate) was + // invisible. An IntroProse hint that survives the trailing + // optionals now advertises it; Tab still cycles the keywords. + let cache = seed_cache(); + let input = "seed users "; + match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple) { + Some(AmbientHint::Prose(p)) => { + assert!( + p.contains("row count") && p.contains("20"), + "prose must mention the row count and the default; got: {p:?}", + ); + assert!( + p.contains("set") && p.contains("--seed") && p.contains(".column"), + "prose should fold in the keyword + column-fill options; got: {p:?}", + ); + } + other => panic!("expected a Prose count hint; got: {other:?}"), + } + // Tab candidates remain available (completion is independent). + let comp = crate::completion::candidates_at_cursor_in_mode( + input, input.len(), &cache, Mode::Simple, + ) + .expect("completion remains available"); + let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect(); + assert!( + texts.contains(&"set") && texts.contains(&"--seed"), + "Tab must still cycle `set` / `--seed`; got {texts:?}", + ); + + // `seed` runs in both modes (ADR-0048), so the hint must fire in + // advanced mode too — not only simple. + match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) { + Some(AmbientHint::Prose(p)) => assert!( + p.contains("row count"), + "count hint must also fire in advanced mode; got: {p:?}", + ), + other => panic!("expected the count hint in advanced mode; got: {other:?}"), + } + } + + #[test] + fn seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given() { + // Position guard: the hint shows only while the cursor sits at + // the count slot. Once the count is supplied — or a later clause + // consumes input past it — it must not reappear. + let cache = seed_cache(); + for input in ["seed users 50 ", "seed users set email = 'x' "] { + let hint = ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple); + let is_count_prose = matches!( + &hint, + Some(AmbientHint::Prose(p)) if p.contains("row count") + ); + assert!(!is_count_prose, "count hint must not show for {input:?}; got {hint:?}"); + } + } + + #[test] + fn seed_count_hint_also_fires_after_a_column_fill_target() { + // The count is valid after `seed users.email` too, so the hint + // fires there — `.email` is a real column (no diagnostic). + let cache = seed_cache(); + let input = "seed users.email "; + match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple) { + Some(AmbientHint::Prose(p)) => assert!( + p.contains("row count"), + "count hint expected after a column-fill target; got: {p:?}", + ), + other => panic!("expected a Prose count hint; got: {other:?}"), + } + } + #[test] fn genuine_column_typo_in_complete_select_still_hints_via_diagnostic() { // Issue #6 trade-off lockdown: dropping the typing-time From 862ab21202b2846d68da7d3d28f70f65d862de6c Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 21:38:57 +0000 Subject: [PATCH 10/50] =?UTF-8?q?docs:=20handoff=2068=20=E2=80=94=20six=20?= =?UTF-8?q?issues=20closed=20(#25/#26/#31/#32/#33/#34)=20+=20open-issue=20?= =?UTF-8?q?map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/handoff/20260612-handoff-68.md | 173 ++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/handoff/20260612-handoff-68.md diff --git a/docs/handoff/20260612-handoff-68.md b/docs/handoff/20260612-handoff-68.md new file mode 100644 index 0000000..ec65611 --- /dev/null +++ b/docs/handoff/20260612-handoff-68.md @@ -0,0 +1,173 @@ +# Session handoff — 2026-06-12 (68) + +Sixty-eighth handover. Continues directly from handoff-67 (which +triaged a manual-testing pass into fixes + filed issues). This was an +**issue-burndown session**: six Gitea issues closed across five +commits, each landed with the full phased workflow + a `/runda` + +Devil's-Advocate pass before commit. Net: **six issues closed, five +commits, +29 tests, zero regressions.** + +## §1. State at handoff + +**Branch:** `main`. Working tree **clean**; all work committed. +**Five unpushed commits** (push is the user's step). + +**Tests: 2436 passing / 0 failing / 0 skipped / 1 ignored** (the +long-standing `friendly` doctest). **Clippy clean** (nursery, all +targets). Breakdown: 1730 lib + 506 integration (`it`) + 200 +typing-surface-matrix. +29 over handoff-67's 2407. + +**Commits since handoff-67:** +``` +ee3ccd8 feat(hint): advertise the optional seed count in the hint panel (#26) +deb0948 feat(seed): year-as-int + conventional choice-set heuristics (#33, #34) +fde50ce fix(ui): mark sidebar focus with an accent colour, not bold (#25) +3d4a0fd fix(render): trim IEEE-754 noise from displayed decimal arithmetic (#32) +7e4bc12 fix(completion): treat a bare in-scope table alias as an alias, not an unknown column (#31) +``` + +## §2. Issues closed this session (all committed, all tested, all `/runda`-reviewed) + +Each closed on `git.lazyeval.net/oli/rdbms-playground` with a summary +comment. The `/runda` pass earned its keep on every one — see the +"DA caught" notes. + +1. **#31 (`7e4bc12`) — bare table alias treated as unknown column.** + A bare in-scope table alias in a SQL expression (`… GROUP BY o`, + `o` aliasing `FROM Orders o`) got `no such column o on table …` and + zero completions. Now: completion offers each FROM source's + *qualifier* (alias-if-present-else-table) at a bare `sql_expr_ident` + slot; the `matched.len()==0` arm emits a targeted + `alias_used_as_column` / `table_used_as_column` hint after the + projection-alias check. **DA caught** two real bugs pre-commit: a + DSL leak (the hint fired for simple-mode `expr_column` refs, which + have no `table.column` syntax) and wrong advice for an + aliased-table-by-real-name — both fixed by gating on + `role == "sql_expr_ident"` + matching the *effective qualifier*. + ADR-0032 Amendment 3. + +2. **#32 (`3d4a0fd`) — decimal aggregation float noise.** `decimal` + is exact TEXT, but SQLite has no decimal type, so arithmetic + coerces to IEEE-754 double; `sum(price*qty)` rendered + `298.59999999999997`. Now `format_real_display` (db.rs) rounds REAL + to 15 sig figs **for display only**, wired into `format_cell`. + **DA caught** a real regression: I'd also wired it into + `render_value`, which is a *canonical identity key* for the + uniqueness dry-runs (`dry_run_unique`, `check_uniqueness_collisions`) + — rounding there would report collisions the exact-valued engine + wouldn't. Reverted `render_value` to exact; locked with a + regression test. CSV/FK-key/EXPLAIN paths stay exact. ADR-0005 + Amendment 1. + +3. **#25 (`fde50ce`) — sidebar focus accent colour, not bold.** Bold + box-drawing glyphs render broken in asciinema casts. + `panel_border_style` now uses a non-bold accent colour + (`theme.mode_simple`); bold stays fine on text spans. **DA caught** + that the issue's "Tier-2 snapshots need re-accepting" was wrong — + `render_to_string` is text-only, so no snapshot changed. Added a + render-level test that inspects the actual border *cells*. + User visually confirmed. ADR-0046 Amendment 1. + +4. **#33 + #34 (`deb0948`) — seed heuristics: year-as-int + choice + sets.** Two additive D7 catalogue rules. **#33:** `year`/`*_year`/ + `published`/`founded` → bounded `int` year (1950–2025, or the + `dob`-style birth window 1945–2007 for `birth`/`born`/`dob`); new + `YearRecent`/`YearBirth` generators. Placed *after* the quantity + rule so `year_count` stays a count. **#34:** type-gated `PickFrom` + sets for `priority`/`prio`, `severity`, `rating`/`stars`; `status` + **deliberately excluded** (user-confirmed on the issue — values too + domain-specific). `priority` left `ENUM_TOKENS`. A user `IN`-CHECK + still wins. **DA/process caught** that I'd skipped reading the issue + *comments* (where the `status` decision + a website cast note lived) + — **lesson: always read issue comments**. Also closed a + pre-existing column-fill integration-test gap. ADR-0048 Amendment 1. + +5. **#26 (`ee3ccd8`) — optional `count` advertised in the hint panel.** + At `seed
▮` only `set`/`--seed` chips showed; the optional + row count (a bare positional number) was invisible, and the prior + `IntroProse` attempt was reverted because `pending_hint_mode` is + cleared by the trailing optionals. Now `walk_optional` stashes a + skipped inner's `IntroProse` key into a new + `WalkContext.surviving_intro_hint` (key + position) before the empty + match clears it; a **position guard** (`pos == cursor`) stops it + leaking past a later `set …` clause or once the count is given. Tab + still cycles the keywords. Prose mentions the count, `.column` + column-fill, `set`, and `--seed` (user-chosen scope). **DA caught** + a coverage gap (advanced-mode path untested — seed runs in both + modes); added the test. ADR-0022 Amendment 7. + +## §3. Open issues — next session's candidates + +Four open, all on `git.lazyeval.net/oli/rdbms-playground`. **All four +are interaction/UX design changes that need a decision or two from the +user up front — none is a pure mechanical fix.** Read each issue body +**and its comments** before starting (the #33/#34 lesson). + +- **#28 — Reconsider relationship prose in `add column` (incidental + DDL) confirmations** *(enhancement)*. **Revisits a decided area** → + needs a **new ADR** superseding the relevant part of ADR-0016 §5 / + ADR-0044 §1. User preference (from the issue): do **not** show the + `References:` / `Referenced by:` block in the add-column + confirmation. Confirm scope with the user (just `add column`, or all + incidental DDL). The highest-ceremony of the four. + +- **#27 — Bottom status line: keybindings-only, context- and + state-aware; add `mode advanced` to empty hint** *(enhancement)*. + Per-nav-focus keybindings (Input vs sidebar), **including transient + states** (Tab-cycle, history) per user preference. May warrant a + small ADR. Touches `src/ui.rs` rendering + the nav-focus model + (ADR-0046). + +- **#29 — Command input keystroke support.** Esc / double-Esc to clear + a partly-typed command; possibly Ctrl-A/Ctrl-E (Home/End). Relates + to the deferred **I1b readline shortcuts** (`requirements.md`). + **Needs a key-set decision** from the user before coding. + +- **#30 — History brings back all commands in both modes.** + Advanced-mode history entries can't replay in simple mode; proposal: + if we can distinguish them, prepend `:` to reuse advanced history + from simple mode. Interaction design; touches the input-history + + mode model (ADR-0003). + +No strong ordering. **#28** is the only one that *must* produce an ADR. +**#29** is closest to "small once the key-set is decided." **#27** and +**#30** are medium UX work. + +## §4. Carried-over follow-up (not a `main`-branch task) + +- **Website `seed` cast re-record** (from #34's comment thread). The + `website` branch ships a `seed` cast exercising a `tickets` table + with `priority`; now that `priority` collapses to `low/medium/high`, + the cast should be re-recorded (`cd website && pnpm casts seed`, + needs a `../target/debug` binary) so the table tightens. The issue + comment notes it is **likely redundant** — casts get a full + re-record sweep before publication. Tracked on the `website` branch, + **not** here. `website/` is not in the `main` tree. + +## §5. Other open roadmap (unchanged from handoff-67 §5) + +`seed` is feature-complete (`requirements.md` SD1/SD2 `[x]`, now with +the #33/#34 catalogue refinements noted inline). User's call: + +- **H2 `hint`** — the last A1 gap (its own ADR). +- **TT5 CI** — test infra exists; no CI workflow yet (the `ci` branch + exists — check its state before starting). +- **TT4 PTY (Tier-4)** — ADR-0008 specifies it; not wired. +- Larger: **V4 journal**, **tutorial/lesson system** (each needs an ADR). + +## §6. How to take over + +1. Read handoffs 66 → 67 → 68, `CLAUDE.md`, `docs/requirements.md`. +2. Confirm green baseline: `cargo test` (expect 2436 pass / 1 ignored) + + `cargo clippy --all-targets` (clean). +3. Pick from §3 (#28/#27/#29/#30). **For each, read the issue body AND + its comments** before designing, and **escalate the design fork to + the user** before coding — all four have genuine UX decisions. #28 + needs a new ADR. +4. Follow the project workflow: phased (requirements → divergent → + eval → execute → verify), test-first (failing test before the fix), + `/runda` + DA pass before every commit, ADR amendment for any + decided-area change + the README index-upkeep rule, and confirm the + commit message with the user before committing. +5. Consider a `cargo sweep` at this milestone (`target/` grows across + sessions; see CLAUDE.md "Build hygiene"). From 8e3208528ec85c085d1d8cf267c2453e32c3ceeb Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 21:43:23 +0000 Subject: [PATCH 11/50] build: static musl release build capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rust-toolchain.toml gains the x86_64-unknown-linux-musl target; the flake devShell gains a musl cc (pkgsCross.musl64) + CC/linker env so a `cargo build --target …-musl` compiles rusqlite's bundled SQLite C and links fully static (D2: single static binary, no runtime deps). Cargo release profile strips symbols (13MB -> 10MB). Verified locally: the musl binary is static-pie, statically linked, stripped, runs standalone. --- Cargo.toml | 6 ++++++ flake.nix | 15 +++++++++++++++ rust-toolchain.toml | 9 +++++++-- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 10c5fd4..32408ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,12 @@ tempfile = "3.27.0" incremental = false debug = "line-tables-only" +# Release builds back the distributed binaries (D2: single static binary). +# strip = "symbols" drops the symbol table at link time so the shipped artifact +# is lean (≈13 MB → 10 MB for the musl build) without a separate strip step. +[profile.release] +strip = "symbols" + [lints.rust] unsafe_code = "forbid" unreachable_pub = "warn" diff --git a/flake.nix b/flake.nix index e5f8fd8..77e74d3 100644 --- a/flake.nix +++ b/flake.nix @@ -27,6 +27,13 @@ # from the crate metadata (no hand-maintained duplicate here). cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); + # musl-targeting C compiler for the static release build: rusqlite's + # `bundled` SQLite is compiled from C, so a `--target …-musl` cargo build + # needs a musl `cc` for that C, plus a musl linker. `targetPrefix` is + # "x86_64-unknown-linux-musl-", so the wrapped binary is + # `${muslCC}/bin/x86_64-unknown-linux-musl-cc`. + muslCC = pkgs.pkgsCross.musl64.stdenv.cc; + # System build inputs are deliberately tiny — this is a pure-Rust TUI: # * libsqlite3-sys is built with the `bundled` feature, so SQLite is # compiled from vendored C. That needs a C compiler, which the @@ -68,9 +75,17 @@ # CLAUDE.md "Build hygiene"). cargo-sweep prunes them; run it # periodically between milestones. pkgs.cargo-sweep + # musl cc/linker for the static release build (see muslCC above). + muslCC ]; + # Point cargo's musl target at the musl cc for both the bundled-C + # compile (CC_, consumed by the `cc` crate) and the final link + # (CARGO_TARGET__LINKER). Harmless for normal glibc builds — + # these only take effect when building `--target x86_64-…-musl`. shellHook = '' + export CC_x86_64_unknown_linux_musl="${muslCC}/bin/${muslCC.targetPrefix}cc" + export CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER="${muslCC}/bin/${muslCC.targetPrefix}cc" echo "RDBMS Playground dev shell ($(uname -s))" echo " rust: $(rustc --version | cut -d' ' -f1-2)" echo " cargo: $(cargo --version | cut -d' ' -f1-2)" diff --git a/rust-toolchain.toml b/rust-toolchain.toml index cabb063..68cd111 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -5,6 +5,11 @@ # flake's convention (its ADR 0046). Bump deliberately, in its own commit. channel = "1.95.0" # rustfmt + clippy back the `fmt`/`clippy` CI stages; no coverage or WASM -# tooling is needed here (pure-Rust TUI). Cross-compilation targets for the -# eventual D1 release matrix are added when that CI lands, not before. +# tooling is needed here (pure-Rust TUI). components = ["rustfmt", "clippy"] +# x86_64 musl for the static release binary (D2: single static binary, no +# runtime deps). The glibc default links the host/nix-store glibc dynamically +# and isn't portable; the musl target with crt-static produces a fully static +# binary. Further D1 matrix targets (aarch64, windows-gnu, …) are added as the +# release matrix expands, step by step. +targets = ["x86_64-unknown-linux-musl"] From 88145225cc49d7343190e776c745de80bada50f5 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 21:43:23 +0000 Subject: [PATCH 12/50] =?UTF-8?q?ci:=20release=20workflow=20=E2=80=94=20st?= =?UTF-8?q?atic=20binary=20to=20Gitea=20releases=20on=20tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a v* tag, builds the x86_64-unknown-linux-musl binary in the CI image and publishes it (+ .sha256) to a Gitea release via the API and the auto GITEA_TOKEN. x86_64 Linux only for now; rest of the D1 matrix and D3 packaging layer on later. Correctness comes from the branch gate. --- .gitea/workflows/release.yaml | 69 +++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .gitea/workflows/release.yaml diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..4db084d --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,69 @@ +# Release: on a version tag, build the static Linux binary (D2) and publish it +# to a Gitea release with a checksum. Runs in the same prebuilt CI image as the +# gate, so the pinned toolchain + musl target/cc are already warm. +# +# Scope: x86_64-unknown-linux-musl only, for now. The rest of the D1 matrix +# (aarch64, macOS, Windows) and the D3 package-manager manifests layer on later, +# step by step. +# +# Correctness comes from the branch gate (clippy + test) that ran when the +# tagged commit was pushed; this job builds + publishes, it does not re-test. +name: release +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ci-public + container: + image: git.lazyeval.net/oli/rdbms-playground-ci:latest + env: + TARGET: x86_64-unknown-linux-musl + steps: + - uses: actions/checkout@v4 + + - name: build static binary + run: nix develop -c cargo build --release --target "$TARGET" + + - name: package artifacts + run: | + set -euo pipefail + BIN="target/$TARGET/release/rdbms-playground" + file "$BIN" + OUT="rdbms-playground-${{ github.ref_name }}-$TARGET" + mkdir -p dist + cp "$BIN" "dist/$OUT" + ( cd dist && sha256sum "$OUT" > "$OUT.sha256" ) + ls -l dist + + - name: publish gitea release + assets + env: + # Auto-provided by Gitea Actions; has repo write (release) scope. + TOKEN: ${{ secrets.GITEA_TOKEN }} + API: ${{ github.server_url }}/api/v1 + REPO: ${{ github.repository }} + TAG: ${{ github.ref_name }} + run: | + set -euo pipefail + # Create the release for this tag; if it already exists, look it up. + created=$(curl -sS -X POST "$API/repos/$REPO/releases" \ + -H "Authorization: token $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Automated release for $TAG.\"}") + id=$(printf '%s' "$created" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const o=JSON.parse(s);process.stdout.write(String(o.id||""))}catch(e){}})') + if [ -z "$id" ]; then + id=$(curl -sS "$API/repos/$REPO/releases/tags/$TAG" \ + -H "Authorization: token $TOKEN" \ + | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{process.stdout.write(String(JSON.parse(s).id))})') + fi + echo "release id: $id" + for f in dist/*; do + name=$(basename "$f") + echo "uploading $name" + curl -sS -X POST "$API/repos/$REPO/releases/$id/assets?name=$name" \ + -H "Authorization: token $TOKEN" \ + -F "attachment=@$f" > /dev/null + done + echo "published $TAG" From bba24120f1bd43a1eea5fdf438bc806a9bf80121 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 22:11:24 +0000 Subject: [PATCH 13/50] ci: scope gate + image-build to branch pushes (skip tags) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tag pushes ignore paths: filters, so a release tag spuriously rebuilt the unchanged CI image and re-ran the gate on a commit the branch push already gated. Add branches: ['**'] to both push triggers — tag pushes no longer fire them (release.yaml owns tags). Pushing commits + a tag together still gates the commits via the branch push. --- .gitea/workflows/build-ci-image.yaml | 4 ++++ .gitea/workflows/ci.yaml | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/.gitea/workflows/build-ci-image.yaml b/.gitea/workflows/build-ci-image.yaml index 9cea8bb..a2ede71 100644 --- a/.gitea/workflows/build-ci-image.yaml +++ b/.gitea/workflows/build-ci-image.yaml @@ -13,6 +13,10 @@ name: build-ci-image on: push: + # Branch pushes only. Tag pushes ignore `paths:` filters and would rebuild + # the (unchanged) image on every release tag — `branches: ['**']` excludes + # tags, so this runs only when a branch push actually changes an image input. + branches: ['**'] paths: - '.gitea/ci-image/Dockerfile' - 'flake.nix' diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index cdf32f9..0505252 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -8,6 +8,11 @@ name: ci on: push: + # Branch pushes only — a tag push hits the same commit the branch push + # already gated, so `branches: ['**']` drops the redundant tag-triggered + # run (the release workflow owns tags). Pushing commits + a tag together + # still gates the commits via the branch push. + branches: ['**'] pull_request: jobs: From 89b9392c2595b0b9f2e07e23c7d45f65a31b25d2 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 22:11:24 +0000 Subject: [PATCH 14/50] =?UTF-8?q?ci:=20release=20job=20=E2=80=94=20test=20?= =?UTF-8?q?before=20publish,=20pin=20bash,=20fix=20diagnostic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Run cargo test before the build so a tag never publishes untested code. - Pin shell: bash on the scripted steps; the runner defaults to dash, which rejected `set -o pipefail` and failed run 22's package step. - Swap `file` (absent in the slim image) for `ls -l`. --- .gitea/workflows/release.yaml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 4db084d..98f7fe4 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -6,8 +6,8 @@ # (aarch64, macOS, Windows) and the D3 package-manager manifests layer on later, # step by step. # -# Correctness comes from the branch gate (clippy + test) that ran when the -# tagged commit was pushed; this job builds + publishes, it does not re-test. +# Tests run here before the build so a tag can never publish untested code, +# even one pointing at a commit that was never gated on a branch. name: release on: push: @@ -24,14 +24,20 @@ jobs: steps: - uses: actions/checkout@v4 + - name: test + run: nix develop -c cargo test --no-fail-fast + - name: build static binary run: nix develop -c cargo build --release --target "$TARGET" - name: package artifacts + # Pin bash: the runner defaults scripted steps to dash, which rejects + # `set -o pipefail`. bash is in the CI image. + shell: bash run: | set -euo pipefail BIN="target/$TARGET/release/rdbms-playground" - file "$BIN" + ls -l "$BIN" OUT="rdbms-playground-${{ github.ref_name }}-$TARGET" mkdir -p dist cp "$BIN" "dist/$OUT" @@ -39,6 +45,7 @@ jobs: ls -l dist - name: publish gitea release + assets + shell: bash env: # Auto-provided by Gitea Actions; has repo write (release) scope. TOKEN: ${{ secrets.GITEA_TOKEN }} From 66c8bdaa6515440b02d7e4ca8fd5b14634d3d06c Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 22:12:08 +0000 Subject: [PATCH 15/50] =?UTF-8?q?feat(input):=20readline=20keymap=20?= =?UTF-8?q?=E2=80=94=20Esc-clear=20+=20Ctrl-A/E/W/K/U=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the deferred I1b readline shortcuts in the command input field (ADR-0049, closing issue #29): Esc clear a partly-typed command (only when no completion memo) Ctrl-A cursor to line start (Home alias) Ctrl-E cursor to line end (End alias) Ctrl-W delete the previous word (readline-style, UTF-8 safe) Ctrl-K kill to end of line Ctrl-U kill to start of line Esc precedence is preserved: a live Tab-completion memo still wins (Esc undoes the completion first, ADR-0022); Esc clears only when no memo is alive. While a sidebar panel is focused (Ctrl-O), Esc exits navigation mode upstream and never clears the input draft. Cursor-only keys leave history navigation intact like Home/End; buffer-mutating keys end it like Backspace. New helpers clear_input / delete_prev_word / kill_to_end / kill_to_start in src/app.rs. 22 new Tier-1 tests (2458 pass / 0 fail / 0 skip, clippy clean). ADR-0049 amends ADR-0046's OOS list; requirements.md I1b marked done. --- ...ar-navigation-and-responsive-input-hint.md | 4 +- docs/adr/0049-input-field-readline-keymap.md | 114 +++++++ docs/adr/README.md | 1 + docs/requirements.md | 12 +- src/app.rs | 319 +++++++++++++++++- 5 files changed, 445 insertions(+), 5 deletions(-) create mode 100644 docs/adr/0049-input-field-readline-keymap.md diff --git a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md index fe10f80..a0573ad 100644 --- a/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md +++ b/docs/adr/0046-sidebar-navigation-and-responsive-input-hint.md @@ -525,7 +525,9 @@ All tiers green, zero skips; clippy clean (nursery). submits over a multi-logical-line buffer. DA3/DA4 keep a single logical line; this remains a separate, deferred feature. - **Readline shortcuts (I1b)** — Ctrl-A/E/W/K/U stay reserved-deferred; - not touched here. + not touched here. *(Superseded 2026-06-12: I1b is now in scope and + decided by **ADR-0049** — Esc-clear + Ctrl-A/E/W/K/U in the input + field, issue #29.)* - **Cross-session sidebar persistence** — visibility is session-only (DB1); persisting it would amend ADR-0015. - **The output panel as a third navigation focus target** — navigation diff --git a/docs/adr/0049-input-field-readline-keymap.md b/docs/adr/0049-input-field-readline-keymap.md new file mode 100644 index 0000000..841df2b --- /dev/null +++ b/docs/adr/0049-input-field-readline-keymap.md @@ -0,0 +1,114 @@ +# ADR-0049: Input-field readline keymap — Esc-clear + Ctrl-A/E/W/K/U (I1b) + +## Status + +**Accepted + implemented 2026-06-12 (issue #29).** Closes Gitea **#29** +("Command input keystroke support") and the deferred **I1b** readline +requirement in `requirements.md`. Every fork below was escalated to the +user and user-chosen before any code was written; implemented test-first +(22 new Tier-1 tests in `src/app.rs`, all green; clippy nursery clean). + +This ADR **amends ADR-0046**, which explicitly listed "readline +shortcuts (I1b)" in its out-of-scope set: that item is now in scope and +decided here. It is orthogonal to ADR-0003's input-*mode* model (simple +vs advanced, the `:` sigil) — these are editing keys within the input +field, not mode or sigil changes — and it extends the single-line cursor +editing already shipped under requirement **I1a** (Left/Right/Home/End/ +Backspace/Delete, `app.rs`). + +## Context + +The input field already supported in-line cursor editing (I1a): Left/ +Right by char (UTF-8 aware), Home/End to the extremes, Backspace/Delete. +Two gaps remained, raised in issue #29: + +1. No way to **clear a partly-typed command** in one keystroke — a user + who started typing the wrong thing had to hold Backspace. +2. No **readline cursor/kill shortcuts** (Ctrl-A/Ctrl-E and friends) for + keyboards without Home/End and for muscle-memory in a command-driven + workflow. This is requirement I1b, deferred by ADR-0046. + +`Esc` was free in the input field except that a *live Tab-completion +memo* consumes it first (to undo the completion in one keystroke, +ADR-0022). Ctrl-A/E/W/K/U were unbound. The existing chords are Ctrl-C +(quit), Ctrl-O (nav focus cycle, ADR-0046), and Ctrl-`]` (demo caption +toggle, ADR-0047) — none collide with a/e/w/k/u. + +## Decision + +Bind the following in the input field (non-modal, non-navigation, +both input modes), in `App::handle_key`: + +| Key | Action | +|-----------|---------------------------------------------------| +| `Esc` | Clear the input (empty buffer, cursor→0, scroll→0)| +| `Ctrl-A` | Cursor to line start (alias of Home) | +| `Ctrl-E` | Cursor to line end (alias of End) | +| `Ctrl-W` | Delete the word before the cursor | +| `Ctrl-K` | Kill from the cursor to end of line | +| `Ctrl-U` | Kill from start of line to the cursor | + +Behavioural rules: + +- **Esc precedence.** A live completion memo still wins: the first Esc + undoes the completion (ADR-0022), and Esc only *clears* when no memo + is alive. This is a natural progression — Esc once to back out the + completion, Esc again to clear. +- **Esc does not clear while navigating the sidebar.** When a sidebar + panel is focused (Ctrl-O, ADR-0046 DC3), `handle_key` routes every + key to the navigation handler *before* the input-field keymap, where + Esc exits navigation mode (`nav_exit`). Entering nav mode never + touched the input buffer, so Esc-to-close-the-panel returns focus to + the input with the partly-typed command intact — it cannot reach the + clear binding. Locked by a regression test. +- **Single Esc clears** (user-chosen over double-Esc). Discoverable and + fast; the trade-off (an accidental Esc wipes an unsubmitted line) was + accepted. A submitted line is always recoverable from history; only + *unsubmitted* draft text is lost. +- **Cursor-only keys don't touch history navigation.** Ctrl-A/Ctrl-E, + like Home/End, move the cursor without ending history recall. +- **Buffer-mutating keys end history navigation.** Esc-clear and + Ctrl-W/K/U call `cancel_history_navigation` (the cleared/edited line + *is* the new draft), matching Backspace/Delete. +- **Ctrl-W is readline-style and UTF-8 safe.** It eats any run of + trailing whitespace, then the preceding run of non-whitespace; word + boundaries are found on char boundaries so multi-byte words delete + cleanly. It only ever deletes back to the cursor (a mid-line Ctrl-W + leaves the suffix intact). + +Helpers added: `clear_input`, `delete_prev_word`, `kill_to_end`, +`kill_to_start` (`src/app.rs`), mirroring the existing `cursor_left` / +`delete_before_cursor` style. + +## Forks (all user-chosen) + +- **Esc semantics:** single-Esc-clears, *not* double-Esc — discoverable + over accident-proof. +- **Scope:** the *full* I1b set (Esc-clear + Ctrl-A/E/W/K/U), not just + the issue's literal Ctrl-A/E + Esc — closes the whole I1b requirement + in one pass rather than leaving Ctrl-W/K/U for a follow-up. +- **Documentation:** a new ADR (this one), recording the input-field + keymap convention and amending ADR-0046's OOS list — over folding it + into ADR-0046 or shipping it I1a-style with no ADR. + +## Consequences + +- I1b is complete; `requirements.md` I1b moves to `[x]`. +- The new keys are **not yet advertised on screen.** Surfacing per-focus + keybindings in the bottom status line is issue #27's domain (a + separate, in-design UX change); this ADR makes the keys *work*, #27 + will make them *discoverable*. +- **Demo-mode badges** (ADR-0047) are *not* extended to the new Ctrl- + chords here. Esc already badges as `[ESC]`; Ctrl-A/E/W/K/U are + glyph-less and would be invisible in an asciinema cast. Whether to add + `[CTRL-A]`…`[CTRL-U]` badges is left to ADR-0047's scope and flagged + as a follow-up — it is a cast-polish concern, not a #29 requirement. + +## Out of scope + +- On-screen keybinding hints for the input field (issue #27). +- Demo badges for the new chords (ADR-0047 follow-up; flagged above). +- Multi-line input (I1) and its Ctrl-Enter submit — unrelated, still + deferred. +- Word-wise *cursor motion* (Alt-B/Alt-F) and transpose/yank — not + requested; not part of I1b. diff --git a/docs/adr/README.md b/docs/adr/README.md index 9389da8..9176b14 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -54,3 +54,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0046 — Schema sidebar focus/navigation mode and responsive input & hint layout (UI #20/#21/#23)](0046-sidebar-navigation-and-responsive-input-hint.md) — **Accepted + implemented 2026-06-10, phased A→B→C** (8 commits `9f5f76b`…`22bec61`; closes Gitea **#20** hint jumpiness, **#21** left-column improvements, **#23** long input — all forks user-confirmed, including the persistent show/hide toggle which is **deferred**: the Ctrl-O peek covers #21's "keystroke to show and hide"). Two decisions landed differently from the draft (recorded inline): relationship data on **`App`** not `SchemaCache` (DB2); the nav overlay clears **only the sidebar strip + a one-column gutter**, panels staying visible behind (DC2). Treats the three UI issues as one coupled decision because they share the terminal's width/height budget. **Phase A (input & hint):** the hint panel's height becomes a function of **terminal geometry, fixed between resizes** (not of hint content), eliminating the #20 jump at its source — measured catalog shows ≥ ~54-col right-column width never needs > 2 hint lines, so 3 lines is a rare narrow-terminal-only case; height buckets `H<40` compact (input 1 row + horizontal scroll / hint 2) vs `H≥40` comfortable (input 2 rows soft-wrap / hint 2), output `Min(5)` honoured first under degradation; input gains horizontal scroll (`input_scroll_offset`, single logical `String` — **not** I1 multi-line) and 2-row soft-wrap display when tall, preserving ADR-0027's 6-col indicator reserve. **Phase B (sidebar):** the 26-col Tables column is **kept but made optional and richer** (not deleted — pedagogy wins ties) — **width-derived session-only** visibility (visible iff width > 90 or a Ctrl-O peek is active — no stored field; hides at width ≤ 90 so the 90-col screencasts drop it; ADR-0015 format untouched), plus a **relationships panel** rendered narrow with endpoints broken at the arrow, ellipsized — a **separate sibling panel** that **overrides S2**'s nested-list extension model (relationships are cross-table). the full records live on a new **`App.relationships`** field (revised from the ADR's original `SchemaCache.relationship_details` at implementation — `SchemaCache` is walker-facing and needs only the names, kept in `relationships: Vec`; details are UI-only, so `App` mirrors `app.tables` and avoids ~23 fixture edits), delivered by `Database::read_all_relationships` + an `AppEvent::RelationshipsRefreshed`; the two left panels split vertically with the relationships panel floored at 5 rows ("(none)" when empty) and capped at 50 % of the column (DB4). **Phase C (navigation mode):** **`Ctrl-O`** enters a focus cycle (Input → Tables → Relationships → Input; `Esc` exits) orthogonal to the ADR-0003 input mode — **`Ctrl-B` was rejected on review as the default tmux prefix** (unreachable inside tmux); the focused panel **expands to ~40–50 cols as a `Clear` overlay** (right panels stay unchanging underneath) and scrolls via **Up/Down (line) + PageUp/PageDown (page)** (context-rebind, reusing the output-scroll viewport mechanism), with an accent focus border; all non-nav keys inert in nav mode (and nav keys inert while a modal is open). Forks all user-chosen: keep-optional-richer (vs remove/narrow); navigation-mode (vs modeless modifier scroll); `Ctrl-O` (Ctrl-B rejected = tmux prefix); overlay (vs layout re-split); inert-non-nav-keys; geometry-fixed hint height; `H<40/≥40` thresholds; session-only persistence; Up/Down line-scroll; **separate relationships panel overriding S2**; **no hint-area toggle** (S4's stale "keyboard-toggleable" claim struck — never implemented, unwanted). A pre-build `/runda` DA pass drove these corrections: caught the `Ctrl-B`/tmux collision, the `SchemaCache` retype that would have broken completion, the 2-row-input/indicator placement, the missing nav-mode key disposition + modal gate, and three unreferenced requirements (S1 evolved, S2 overridden, S4 corrected); also cross-checked open issue **#22** (overlay/annotation layer — separate ADR, adjacent). OOS: true multi-line input (I1); readline shortcuts (I1b); cross-session sidebar persistence; output as a third nav focus; relationship search/edit from the panel; hint-area toggle; #22's annotation layer. Accepted consequence: the 90-col visibility threshold makes a terminal's output *narrower* when widened across the boundary (sidebar appears); **Amendment 1, 2026-06-12** (issue #25): DC3's focus accent is now a **non-bold accent colour** (`theme.mode_simple`, blue) rather than bold bright-`fg` — bold box-drawing glyphs render as broken/gapped line-art in the asciinema cast player (and are fragile in some terminals), so `panel_border_style` carries no `Modifier::BOLD` on a border (bold stays fine on text spans); pure style change — the text-only Tier-2 snapshots were unaffected, the Tier-1 assertion was updated, and a render-level test now checks the focused border cells carry the accent and no bold - [ADR-0047 — Demonstration overlay layer (keystroke badges + step captions)](0047-demonstration-overlay-layer.md) — **Accepted 2026-06-10; implemented 2026-06-11, phased A→B→C (closes Gitea #22)** (commits `f879d54`→`2d0f4b2`; no `requirements.md` item — tracked by issue + ADR per convention; all forks user-confirmed + a pre-build `/runda` pass that produced 10 tightening findings and a whole-implementation `/runda` pass that returned PASS, no blockers). An in-app **demonstration mode** (`--demo` flag / `RDBMS_PLAYGROUND_DEMO` env, **off by default, zero footprint when off**) that renders two transient overlays so `autocast` screencasts — and live teaching, and a future guided-lesson system — can show otherwise-invisible interactions. **Keystroke badges** (`[TAB]`, `[ENTER]`, `[UP]`, …): **automatic, app-detected** over a fixed set of glyph-less keys (the app already sees every key, so it re-records for free), label via a pure `demo_badge_label(&KeyEvent)`; the badge **auto-expires on a ~1.5 s timer** that extends the runtime's existing time-boxed-`recv` arm condition (`debounce.is_armed() || badge_pending`; expiry `Instant` in the runtime, `App.demo_badge` the render mirror — mirroring the `input` vs `input_indicator` split). **Step captions**: a **stealth, control-code-delimited input buffer** toggled by **`Ctrl+]`** (byte `0x1D` → arrives as `Char('5')+CONTROL`, verified against crossterm 0.29 `parse.rs:110-113`; chosen over `Ctrl+!`, which is **not a single ASCII byte so autocast cannot send it** — the same wall as arrow keys, R4) — typed characters accumulate **invisibly** (prompt untouched, no echo/history), `Backspace` edits, other keys inert, a second `Ctrl+]` **commits** to the caption box (empty commit dismisses); lives in pure-sync `App::update()`, **intercepted before the modal gate** so captions/badges work **over the load picker** (the `#24` projects cast). Both render as **floating flat black-on-yellow rectangles** (solid fill, **no border glyphs** — a one-cell text margin, deliberately unlike the app's bordered panels; user decision post-build, `2d0f4b2`) **at the output panel's inner bottom-right**, drawn **last over modals**, badge **stacked above** the caption, **no layout reflow**; caption **word-wraps to ≤ 3 lines** (3–5 rows), badge fixed 3 rows; clamp/skip guard for tiny terminals; a new **`App.last_output_area: Rect`** (set in `render_output_panel`) gives the top-level draw the anchor. Caption persists **until the next keystroke**; badge suppressed while capturing. Forks all user-chosen: `--demo` activation (vs hidden command / chord); automatic badges (vs scripted); stealth buffer (vs typed-command / preloaded-file); floating bottom-right boxes (vs HUD / banner / subtitle); `Ctrl+]` trigger; wrap-to-3-line captions; ~1.5 s badge / next-keystroke caption timing. Tested test-first across Tier 1 (label fn, capture state machine incl. over-modal + demo-off gate, nearest-deadline helper), Tier 2 (insta snapshots: badge/caption/both-stacked at 90×26 light+dark, short-terminal clamp), Tier 3 (`--demo` plumbing, badge set/suppressed, caption-without-input wiring), CLI (`--demo` parse + env fallback) — with an **honest limit** noted: the `tokio` timer wiring inside `run_loop` is exercised via the pure pieces + Tier-3 plumbing, not a standalone integration test of the timeout (same posture as the existing `IndicatorDebounce`). One intentional, user-acknowledged behaviour: `Ctrl-C` is inert while capturing (every non-`Ctrl+]` key is, by spec). Final tally **2290 passing / 0 failing / 0 skipped** (1 long-standing ignored doctest), clippy clean. OOS: scripted/manual badge push; badges for glyph keys; configurable styling/placement; the guided-lesson system itself (own ADR); cross-session/-switch persistence; localised caption content; arrow-only cast interactions (output-pane scroll); wiring the overlays into the website `casts.mjs` scripts (website-branch follow-up). Implementation phased **A** (`--demo` plumbing) → **B** (badges) → **C** (captions) + a flat-rectangle restyle - [ADR-0048 — `seed` fake-data generation command](0048-seed-fake-data-generation.md) — **Accepted 2026-06-11; Phase 1 + Phase 2 implemented 2026-06-11** (Phase 1 commits `202e25a`→`fbd219b`; design settled with the user across an extended fork dialogue, hardened by a pre-build `/runda` pass (six blockers folded in), a post-implementation `/runda` pass (eight gaps closed — FK/shortid determinism so **D4 holds with no exceptions**, plus six untested ADR decisions), and a Phase-2 pre-build `/runda` pass (which caught the no-date-literal-token reality → the D2 quoted-dates amendment), and a post-implementation `/runda` pass (which added a friendly error for a bounded override on a UNIQUE column — see D2); **2400 tests pass, clippy clean**). Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1**. **Phase 1 shipped:** whole-row `seed
[count] [--seed ]` with realistic name-aware generation (the `fake` crate + a type-gated heuristic catalogue, table-context name disambiguation, hand-rolled `product` generator, bounded dates), identifier + constraint uniqueness incl. junction distinct-combos, FK sampling from existing parent rows (empty-parent error), `IN`-CHECK derivation + complex-CHECK advisory, a required-column block guard, `--seed` reproducibility (serial/FK/shortid all deterministic), undo as one batch step, replay as a data write, a capped auto-show preview, the enum/CHECK advisory, and an O(N) single-transaction insert path. **Phase 2 shipped (2026-06-11):** the `set` override clause (D2 — fixed value / pick-list / `as ` / `between` range, **quoted** dates per the D2 amendment, type-aware, override drops the column from the advisory) and the `
.` column-fill form (D1 form 2 — an UPDATE over existing rows, refusing PK/autogen targets, empty-table no-op, FK/unique-respecting, one undo step), with the new `KNOWN_GENERATORS` vocabulary (D9), a range `Generator`, full completion/highlight (`HighlightClass::Function`)/validity (`IdentSource::Generators`)/help/pedagogy wiring, and the D13 advisory's Phase-2/3 wording. Further SD2 increments (custom generators, NULL injection, multi-locale, recursive auto-seed) out of scope. Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1** (the other being `hint`/**H2**). A dedicated `seed` command (own AST variant + `do_seed` executor, **both modes**) generating **realistic, name-aware** fake data. Two forms: **`seed
[count]`** (new rows, default **20**, capped) and **`seed
.`** (fill a column on existing rows, an UPDATE). Generation adds the **`fake` crate** (v5, English) driven by a **type-gated, token-matched name-heuristic catalogue** (~30 patterns, documented false-positive guards), with **table-context** disambiguating the `name`/`title` family (`products.name`→product, `users.name`→person, `vendors.name`→company), a **hand-rolled `product` generator** (`fake` has no commerce module), **bounded dates** (`date`/`timestamp`/`dob`/`*_at` recognised, recent windows — never "all of history"), the **identifier family** (`id`/`code`/`ref`/`number`, non-FK/non-PK) → **unique sequential**, and **enum-ish names** (`role`/`status`/`type`/…) left generic + a **post-seed Hint advisory** pointing at `set … in (…)`. A **`set` override clause** — `= value` / `in (a,b,c)` / `as ` / `between a and b` (numeric **and** date), reusing ADR-0026 operators — answers the heuristic-miss case. **`--seed `** makes runs reproducible (and enables exact-value tests). **FK** columns sampled uniformly from existing parent rows (**empty parent → friendly error**, no recursion v1); **junction/compound-PK** tables seeded with **distinct combinations**, capped + noted (SD1). A **required-column block guard** refuses rather than NULL-violate a `NOT NULL` column it can't fill (e.g. `NOT NULL blob`). Full ambient wiring (completion incl. a new generator-name vocabulary highlighted as `tok_function`, hints, `help seed`, ADR-0042 near-miss matrix, ADR-0027 validity); **no DSL→SQL teaching echo** (seed is a utility command, not a SQL twin). Honours **X5** — `do_seed` reuses insert/update *mechanics as helpers*, not by emitting `Command::Insert`. Implementation phased: (1) core whole-row seed → (2) `set` overrides → (3) column-fill. Deferred (future SD2): recursive auto-seed, NULL injection, multi-locale, user-defined custom generators, full per-column report. **Amendment 1, 2026-06-12** (issues #33/#34): two additive D7 catalogue rules — **year-as-int** (`year`/`*_year`/`published`/`founded` → a bounded `int` year, 1950–2025, or the `dob`-style birth window 1945–2007 for `birth`/`born`/`dob`; fixes nonsense like `9419`; `int`-gated, after the quantity rule so `year_count` stays a count; two new `YearRecent`/`YearBirth` generators, *not* added to the D9 vocabulary) and **conventional choice sets** (`priority`/`prio`, `severity`, `rating`/`stars` → type-gated built-in `PickFrom` value sets reusing the existing generator; `priority` leaves `ENUM_TOKENS`). `status` is **deliberately excluded** (user-confirmed — values too domain-specific; keeps the D12 "don't guess" + advisory); a user `IN`-CHECK still wins. Website `seed` cast re-record tracked on the `website` branch +- [ADR-0049 — Input-field readline keymap: Esc-clear + Ctrl-A/E/W/K/U (I1b)](0049-input-field-readline-keymap.md) — **Accepted + implemented 2026-06-12 (issue #29)**, closes Gitea **#29** and the deferred **I1b** readline requirement. **Amends ADR-0046**, which listed "readline shortcuts (I1b)" as out-of-scope — that item is now in scope and decided here; orthogonal to ADR-0003's input-*mode* model and extends the I1a single-line cursor editing already shipped. Binds, in the input field (non-modal, non-nav, both modes): **`Esc`** clears a partly-typed command (empty buffer, cursor→0, scroll→0); **`Ctrl-A`/`Ctrl-E`** alias Home/End (line start/end); **`Ctrl-W`** deletes the previous word (readline-style — eats trailing whitespace then the preceding non-whitespace run, UTF-8-safe on char boundaries, only back to the cursor); **`Ctrl-K`** kills to end of line; **`Ctrl-U`** kills to start. **Esc precedence:** a live Tab-completion memo still wins (Esc undoes the completion first, ADR-0022; Esc clears only when no memo) — Esc-once backs out the completion, Esc-again clears. Forks all user-chosen: **single-Esc-clears** (not double-Esc — discoverable over accident-proof; an unsubmitted draft can be lost, a submitted line is always in history); the **full I1b set** (not just the issue's literal Ctrl-A/E + Esc); a **new ADR** (not an ADR-0046 amendment / no-ADR). Cursor-only keys (Ctrl-A/E) leave history navigation intact like Home/End; buffer-mutating keys (Esc-clear, Ctrl-W/K/U) end it like Backspace. Helpers `clear_input`/`delete_prev_word`/`kill_to_end`/`kill_to_start` in `src/app.rs`; **22 new Tier-1 tests, 2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. OOS: on-screen keybinding hints (issue #27 owns surfacing per-focus keybindings in the bottom status line — this ADR makes the keys *work*, #27 makes them *discoverable*); demo-mode badges for the new chords (ADR-0047 follow-up — Esc already badges `[ESC]`, the glyph-less Ctrl-chords are flagged but not added); multi-line input (I1); word-wise cursor motion (Alt-B/F) / transpose / yank diff --git a/docs/requirements.md b/docs/requirements.md index 7b2984e..d3fc046 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -147,11 +147,19 @@ since ADR-0027.) cursor editing and is complete on its own terms; the separate **multi-line** entry goal is tracked under I1, which is genuinely not started.)* -- [ ] **I1b** Readline-style cursor shortcuts: Ctrl-A / Ctrl-E +- [x] **I1b** Readline-style cursor shortcuts: Ctrl-A / Ctrl-E as aliases for Home / End for users on keyboards without those keys (and for ergonomics in command-driven workflows). Likely followed by Ctrl-W (delete previous word), Ctrl-K (delete to - end), Ctrl-U (delete to start). Pending. + end), Ctrl-U (delete to start). + *(Done 2026-06-12 — ADR-0049, issue #29: the full set — + Esc-clear + Ctrl-A/E/W/K/U — wired in `App::handle_key` + (`src/app.rs`) with helpers `clear_input` / `delete_prev_word` + / `kill_to_end` / `kill_to_start`; Esc clears only when no + completion memo is alive (the memo wins first, ADR-0022); + cursor-only keys leave history navigation intact, kill keys + end it; 22 Tier-1 tests. On-screen advertisement of these keys + is issue #27's bottom-status-line work.)* - [x] **I2** Persistent navigable input history (project-scoped). *(Implemented across Iterations 2 + 6: per-command append to `history.log` (Iter 2); on project open, the in-memory diff --git a/src/app.rs b/src/app.rs index 2863382..e9c3e74 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1217,6 +1217,13 @@ impl App { match (key.code, key.modifiers) { (KeyCode::Char('c'), KeyModifiers::CONTROL) => vec![Action::Quit], (KeyCode::Enter, _) => self.submit(), + // ADR-0049 (issue #29): Esc clears a partly-typed command. + // Reached only when no completion memo is alive — the memo + // block above consumes Esc first to undo a completion. + (KeyCode::Esc, _) => { + self.clear_input(); + Vec::new() + } (KeyCode::Up, _) => { self.history_back(); Vec::new() @@ -1233,11 +1240,15 @@ impl App { self.cursor_right(); Vec::new() } - (KeyCode::Home, _) => { + // ADR-0049: Ctrl-A / Ctrl-E are readline aliases for + // Home / End — line start / end — for keyboards without + // those keys. Cursor-only, so (like Home/End) they do not + // cancel history navigation. + (KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => { self.input_cursor = 0; Vec::new() } - (KeyCode::End, _) => { + (KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => { self.input_cursor = self.input.len(); Vec::new() } @@ -1251,6 +1262,23 @@ impl App { self.delete_at_cursor(); Vec::new() } + // ADR-0049: readline kill shortcuts. Each mutates the + // buffer, so each ends history navigation like Backspace. + (KeyCode::Char('w'), KeyModifiers::CONTROL) => { + self.cancel_history_navigation(); + self.delete_prev_word(); + Vec::new() + } + (KeyCode::Char('k'), KeyModifiers::CONTROL) => { + self.cancel_history_navigation(); + self.kill_to_end(); + Vec::new() + } + (KeyCode::Char('u'), KeyModifiers::CONTROL) => { + self.cancel_history_navigation(); + self.kill_to_start(); + Vec::new() + } (KeyCode::PageUp, _) => { self.scroll_output_up(); Vec::new() @@ -1545,6 +1573,54 @@ impl App { self.input.replace_range(self.input_cursor..idx, ""); } + /// Esc — clear a partly-typed command (ADR-0049). Empties the + /// buffer, parks the cursor at the start, drops any horizontal + /// scroll, and ends history navigation (the cleared line *is* the + /// new draft). Only reached when no completion memo is alive — Esc + /// undoes a live completion first (handle_key precedence). + fn clear_input(&mut self) { + self.cancel_history_navigation(); + self.input.clear(); + self.input_cursor = 0; + self.input_scroll_offset = 0; + } + + /// Ctrl-W — delete the word before the cursor (ADR-0049). Eats any + /// run of trailing whitespace, then the preceding run of + /// non-whitespace, readline-style. UTF-8 safe: word boundaries are + /// found on char boundaries, so multi-byte words delete cleanly. + fn delete_prev_word(&mut self) { + if self.input_cursor == 0 { + return; + } + let prefix = &self.input[..self.input_cursor]; + // Strip trailing whitespace, then locate the start of the + // word that now ends the prefix. + let after_ws = prefix.trim_end_matches(char::is_whitespace); + // `idx` is the byte offset of the last whitespace char before + // the word; the word starts at the next char. No whitespace at + // all → the word starts at the buffer start. + let start = after_ws.rfind(char::is_whitespace).map_or(0, |idx| { + idx + after_ws[idx..].chars().next().map_or(0, char::len_utf8) + }); + self.input.replace_range(start..self.input_cursor, ""); + self.input_cursor = start; + } + + /// Ctrl-K — kill from the cursor to the end of the line (ADR-0049). + /// The cursor is always a char boundary, so a plain truncate is + /// safe. + fn kill_to_end(&mut self) { + self.input.truncate(self.input_cursor); + } + + /// Ctrl-U — kill from the start of the line to the cursor + /// (ADR-0049). The cursor moves to the start. + fn kill_to_start(&mut self) { + self.input.replace_range(0..self.input_cursor, ""); + self.input_cursor = 0; + } + /// Move backwards in history (towards older entries). fn history_back(&mut self) { if self.history.is_empty() { @@ -5756,6 +5832,245 @@ mod tests { assert_eq!(app.input_cursor, 0); } + // ---- ADR-0049 (issue #29): input-field readline keymap ---- + + fn ctrl(c: char) -> AppEvent { + key_mod(KeyCode::Char(c), KeyModifiers::CONTROL) + } + + #[test] + fn esc_clears_a_partly_typed_command() { + let mut app = App::new(); + type_str(&mut app, "drop table T"); + app.update(key(KeyCode::Esc)); + assert_eq!(app.input, ""); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn esc_clear_resets_horizontal_scroll() { + // A long line that has been horizontally scrolled must + // reset its scroll offset on clear, exactly like submit. + let mut app = App::new(); + type_str(&mut app, "drop table T"); + app.input_scroll_offset = 5; + app.update(key(KeyCode::Esc)); + assert_eq!(app.input, ""); + assert_eq!(app.input_scroll_offset, 0); + } + + #[test] + fn esc_clear_cancels_history_navigation() { + let mut app = App::new(); + type_str(&mut app, "drop table A"); + submit(&mut app); + app.update(key(KeyCode::Up)); + assert_eq!(app.input, "drop table A"); + app.update(key(KeyCode::Esc)); + assert_eq!(app.input, ""); + assert!(app.history_cursor.is_none()); + } + + #[test] + fn esc_with_live_completion_memo_undoes_rather_than_clears() { + // Precedence: while a multi-candidate Tab memo is alive, Esc + // undoes the completion (restoring the original text), it does + // NOT clear the whole input. + let mut app = App::new(); + type_str(&mut app, "show "); + app.update(key(KeyCode::Tab)); // → "show data", memo alive + assert!(app.last_completion.is_some()); + app.update(key(KeyCode::Esc)); + assert_eq!(app.input, "show "); + } + + #[test] + fn ctrl_a_moves_cursor_to_start() { + let mut app = App::new(); + type_str(&mut app, "hello"); + app.update(ctrl('a')); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn ctrl_e_moves_cursor_to_end() { + let mut app = App::new(); + type_str(&mut app, "hello"); + app.update(key(KeyCode::Home)); + assert_eq!(app.input_cursor, 0); + app.update(ctrl('e')); + assert_eq!(app.input_cursor, 5); + } + + #[test] + fn ctrl_w_deletes_the_previous_word() { + let mut app = App::new(); + type_str(&mut app, "drop table T"); + app.update(ctrl('w')); + assert_eq!(app.input, "drop table "); + assert_eq!(app.input_cursor, "drop table ".len()); + } + + #[test] + fn ctrl_w_eats_trailing_whitespace_then_the_word() { + let mut app = App::new(); + type_str(&mut app, "foo bar "); + app.update(ctrl('w')); + assert_eq!(app.input, "foo "); + assert_eq!(app.input_cursor, 4); + } + + #[test] + fn ctrl_w_at_start_is_a_noop() { + let mut app = App::new(); + type_str(&mut app, "hello"); + app.input_cursor = 0; + app.update(ctrl('w')); + assert_eq!(app.input, "hello"); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn ctrl_w_only_deletes_back_to_the_cursor() { + // Mid-line: deletes the word before the cursor, leaving the + // suffix untouched. + let mut app = App::new(); + type_str(&mut app, "drop table T"); + app.input_cursor = "drop table".len(); // cursor right after "table" + app.update(ctrl('w')); + assert_eq!(app.input, "drop T"); + assert_eq!(app.input_cursor, "drop ".len()); + } + + #[test] + fn ctrl_w_handles_multibyte_words() { + let mut app = App::new(); + type_str(&mut app, "héllo wörld"); + app.update(ctrl('w')); + assert_eq!(app.input, "héllo "); + assert_eq!(app.input_cursor, "héllo ".len()); + } + + #[test] + fn ctrl_k_kills_to_end_of_line() { + let mut app = App::new(); + type_str(&mut app, "hello world"); + app.input_cursor = 5; // after "hello" + app.update(ctrl('k')); + assert_eq!(app.input, "hello"); + assert_eq!(app.input_cursor, 5); + } + + #[test] + fn ctrl_u_kills_to_start_of_line() { + let mut app = App::new(); + type_str(&mut app, "hello world"); + app.input_cursor = 6; // after "hello " + app.update(ctrl('u')); + assert_eq!(app.input, "world"); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn ctrl_u_cancels_history_navigation() { + let mut app = App::new(); + type_str(&mut app, "drop table A"); + submit(&mut app); + app.update(key(KeyCode::Up)); + assert_eq!(app.input, "drop table A"); + app.update(ctrl('u')); // cursor is at end after recall → clears all + assert_eq!(app.input, ""); + assert!(app.history_cursor.is_none()); + } + + #[test] + fn ctrl_w_cancels_history_navigation() { + let mut app = App::new(); + type_str(&mut app, "drop table A"); + submit(&mut app); + app.update(key(KeyCode::Up)); + assert_eq!(app.input, "drop table A"); + app.update(ctrl('w')); // deletes the recalled "A" word + assert_eq!(app.input, "drop table "); + assert!(app.history_cursor.is_none()); + } + + #[test] + fn ctrl_w_on_whitespace_only_clears_it() { + let mut app = App::new(); + type_str(&mut app, " "); + app.update(ctrl('w')); + assert_eq!(app.input, ""); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn ctrl_k_at_end_of_line_is_a_noop() { + let mut app = App::new(); + type_str(&mut app, "hello"); + // Cursor at end. + app.update(ctrl('k')); + assert_eq!(app.input, "hello"); + assert_eq!(app.input_cursor, 5); + } + + #[test] + fn ctrl_k_at_start_kills_the_whole_line() { + let mut app = App::new(); + type_str(&mut app, "hello"); + app.input_cursor = 0; + app.update(ctrl('k')); + assert_eq!(app.input, ""); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn ctrl_u_at_start_of_line_is_a_noop() { + let mut app = App::new(); + type_str(&mut app, "hello"); + app.input_cursor = 0; + app.update(ctrl('u')); + assert_eq!(app.input, "hello"); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn ctrl_u_at_end_kills_the_whole_line() { + let mut app = App::new(); + type_str(&mut app, "hello"); + // Cursor at end. + app.update(ctrl('u')); + assert_eq!(app.input, ""); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn esc_on_empty_input_is_harmless() { + let mut app = App::new(); + app.update(key(KeyCode::Esc)); + assert_eq!(app.input, ""); + assert_eq!(app.input_cursor, 0); + } + + #[test] + fn esc_exiting_nav_mode_does_not_clear_the_input() { + // ADR-0049 / ADR-0046 DC3: while a sidebar panel is focused + // (Ctrl-O), Esc exits navigation mode — the nav handler + // consumes it upstream of the input-field keymap, so the + // partly-typed command is preserved, NOT cleared. + let mut app = App::new(); + type_str(&mut app, "create table T"); + app.update(key_mod(KeyCode::Char('o'), KeyModifiers::CONTROL)); + assert_eq!(app.nav_focus, NavFocus::SidebarTables); + // The draft survives entering nav mode. + assert_eq!(app.input, "create table T"); + app.update(key(KeyCode::Esc)); + // Esc returned focus to the input WITHOUT clearing it. + assert_eq!(app.nav_focus, NavFocus::Input); + assert_eq!(app.input, "create table T"); + assert_eq!(app.input_cursor, "create table T".len()); + } + #[test] fn relationships_refreshed_event_updates_the_field() { // ADR-0046 DB2: the runtime posts RelationshipsRefreshed; the From da8bfebc36d6cf1579d94eece5ebcaf6ef5e6b02 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 22:38:34 +0000 Subject: [PATCH 16/50] docs(ci): establish docs/ci/adr namespace (ci-001 pipeline, ci-002 flake) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the CI/release pipeline as ADR-ci-001 and relocates the nix-flake ADR from main's ADR-0049 to ADR-ci-002 (content unchanged, history note added). Both live in docs/ci/adr/ with a README index — a dated, ci-segmented namespace disjoint from main's integer ADR sequence, the same split the website subproject uses to avoid cross-branch number collisions. Drops the ADR-0049 entry from docs/adr/README. ci-001 covers the runner model, the baked nix CI image, the clippy+test gate, the static-musl release on tag, trigger hygiene, auth, and scope. --- docs/adr/README.md | 1 - docs/ci/adr/20260612-adr-ci-001.md | 185 ++++++++++++++++++ .../adr/20260612-adr-ci-002.md} | 20 +- docs/ci/adr/README.md | 22 +++ 4 files changed, 221 insertions(+), 7 deletions(-) create mode 100644 docs/ci/adr/20260612-adr-ci-001.md rename docs/{adr/0049-nix-flake-dev-and-build-env.md => ci/adr/20260612-adr-ci-002.md} (88%) create mode 100644 docs/ci/adr/README.md diff --git a/docs/adr/README.md b/docs/adr/README.md index 7d3d36f..2e15522 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -54,4 +54,3 @@ This directory contains the project's ADRs, recorded per - [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 - [ADR-0048 — `seed` fake-data generation command](0048-seed-fake-data-generation.md) — **Accepted 2026-06-11; Phase 1 + Phase 2 implemented 2026-06-11** (Phase 1 commits `202e25a`→`fbd219b`; design settled with the user across an extended fork dialogue, hardened by a pre-build `/runda` pass (six blockers folded in), a post-implementation `/runda` pass (eight gaps closed — FK/shortid determinism so **D4 holds with no exceptions**, plus six untested ADR decisions), and a Phase-2 pre-build `/runda` pass (which caught the no-date-literal-token reality → the D2 quoted-dates amendment), and a post-implementation `/runda` pass (which added a friendly error for a bounded override on a UNIQUE column — see D2); **2400 tests pass, clippy clean**). Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1**. **Phase 1 shipped:** whole-row `seed
[count] [--seed ]` with realistic name-aware generation (the `fake` crate + a type-gated heuristic catalogue, table-context name disambiguation, hand-rolled `product` generator, bounded dates), identifier + constraint uniqueness incl. junction distinct-combos, FK sampling from existing parent rows (empty-parent error), `IN`-CHECK derivation + complex-CHECK advisory, a required-column block guard, `--seed` reproducibility (serial/FK/shortid all deterministic), undo as one batch step, replay as a data write, a capped auto-show preview, the enum/CHECK advisory, and an O(N) single-transaction insert path. **Phase 2 shipped (2026-06-11):** the `set` override clause (D2 — fixed value / pick-list / `as ` / `between` range, **quoted** dates per the D2 amendment, type-aware, override drops the column from the advisory) and the `
.` column-fill form (D1 form 2 — an UPDATE over existing rows, refusing PK/autogen targets, empty-table no-op, FK/unique-respecting, one undo step), with the new `KNOWN_GENERATORS` vocabulary (D9), a range `Generator`, full completion/highlight (`HighlightClass::Function`)/validity (`IdentSource::Generators`)/help/pedagogy wiring, and the D13 advisory's Phase-2/3 wording. Further SD2 increments (custom generators, NULL injection, multi-locale, recursive auto-seed) out of scope. Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1** (the other being `hint`/**H2**). A dedicated `seed` command (own AST variant + `do_seed` executor, **both modes**) generating **realistic, name-aware** fake data. Two forms: **`seed
[count]`** (new rows, default **20**, capped) and **`seed
.`** (fill a column on existing rows, an UPDATE). Generation adds the **`fake` crate** (v5, English) driven by a **type-gated, token-matched name-heuristic catalogue** (~30 patterns, documented false-positive guards), with **table-context** disambiguating the `name`/`title` family (`products.name`→product, `users.name`→person, `vendors.name`→company), a **hand-rolled `product` generator** (`fake` has no commerce module), **bounded dates** (`date`/`timestamp`/`dob`/`*_at` recognised, recent windows — never "all of history"), the **identifier family** (`id`/`code`/`ref`/`number`, non-FK/non-PK) → **unique sequential**, and **enum-ish names** (`role`/`status`/`type`/…) left generic + a **post-seed Hint advisory** pointing at `set … in (…)`. A **`set` override clause** — `= value` / `in (a,b,c)` / `as ` / `between a and b` (numeric **and** date), reusing ADR-0026 operators — answers the heuristic-miss case. **`--seed `** makes runs reproducible (and enables exact-value tests). **FK** columns sampled uniformly from existing parent rows (**empty parent → friendly error**, no recursion v1); **junction/compound-PK** tables seeded with **distinct combinations**, capped + noted (SD1). A **required-column block guard** refuses rather than NULL-violate a `NOT NULL` column it can't fill (e.g. `NOT NULL blob`). Full ambient wiring (completion incl. a new generator-name vocabulary highlighted as `tok_function`, hints, `help seed`, ADR-0042 near-miss matrix, ADR-0027 validity); **no DSL→SQL teaching echo** (seed is a utility command, not a SQL twin). Honours **X5** — `do_seed` reuses insert/update *mechanics as helpers*, not by emitting `Command::Insert`. Implementation phased: (1) core whole-row seed → (2) `set` overrides → (3) column-fill. Deferred (future SD2): recursive auto-seed, NULL injection, multi-locale, user-defined custom generators, full per-column report -- [ADR-0049 — Nix flake for a reproducible dev + build environment](0049-nix-flake-dev-and-build-env.md) — **Accepted + implemented 2026-06-12** (`ci` branch; first step of the CI work toward `requirements.md` **TT5** + **D1/D2/D3**). Adopts a root **Nix flake** as the single, version-pinned declaration of the dev *and* build toolchain so CI never relies on whatever Rust is installed on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI: no Tauri/WebKit/Node/WASM). Two outputs (user-chosen over dev-shell-only): **`devShells.default`** (pinned toolchain via `rust-toolchain.toml` + `rust-overlay`, plus `cargo-sweep`) and **`packages.default`** (a `rustPlatform.buildRustPackage` building the binary reproducibly from the committed `Cargo.lock` via `importCargoLock`; `doCheck = false` — the suite runs as its own `nix develop -c cargo test` stage, not in the HOME/X-less build sandbox; version read from `Cargo.toml` via `fromTOML`). Toolchain pinned to **exact `1.95.0`** (not floating `stable`) so `nix flake update` can't surprise-bump clippy lints past the `-D warnings` gate; components `rustfmt` + `clippy`; **no** cross/WASM targets yet (added when the release matrix needs them). System inputs are nearly empty by design — `libsqlite3-sys` `bundled` needs only the stdenv C compiler; `arboard`→`x11rb` is pure-Rust (no C X11 libs, X server only needed at *runtime*, OSC 52 otherwise). `.envrc` (`use flake`) kept for direnv parity though direnv isn't on the current VM. **Verified before acceptance:** `nix develop` toolchain pinned, `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest** — all through the flake. Consequences: the `nix build` artifact is glibc-**dynamic** (a reproducible build/test artifact, **not** the D2 static release binary — release uses a static target like `x86_64-unknown-linux-musl`, deferred to the CI release work); the **`fmt` gate is deliberately left out for now** (user decision — the tree isn't clean under stock `rustfmt`, ~100 files would churn and conflict with the website/`main` work; revisit on `main`), so the gate is **`clippy` + `test`**. Alternatives rejected: dev-shell-only (no reproducible artifact); a standard `rust:1.95` CI image (a second toolchain definition = drift, the very thing this prevents); `rustup` on the build host (non-reproducible — the status quo being eliminated). The CI **pipeline** itself (runner wiring, release matrix) is decided separately as it settles. diff --git a/docs/ci/adr/20260612-adr-ci-001.md b/docs/ci/adr/20260612-adr-ci-001.md new file mode 100644 index 0000000..4f34a05 --- /dev/null +++ b/docs/ci/adr/20260612-adr-ci-001.md @@ -0,0 +1,185 @@ +# ADR-ci-001: CI + release pipeline on Gitea Actions + +## Status + +**Accepted (2026-06-12); implemented the same day on the `ci` branch.** Every +fork below was settled with the user as the pipeline was built, and each stage +was verified live before acceptance: + +- a throwaway probe workflow established how the runner executes jobs; +- the CI image was built and checked locally (runner contract, warm devShell); +- the gate ran green (**clippy clean; 2424 tests pass / 0 fail / 1 intentional + ignored doctest**); +- the release was exercised end-to-end — tag `v0.0.0-citest2` published a Gitea + release carrying the static binary (~10 MB) and its `.sha256`. + +This ADR records the **CI/release pipeline**. The **dev/build environment it +runs on** — the nix flake (devShell + reproducible build, pinned Rust 1.95.0) +— is **ADR-ci-002** (relocated here from main's ADR-0049); this ADR builds on +it rather than restating it. + +> **Namespacing.** Kept in `docs/ci/adr/` (id `ADR-ci-001`), disjoint from +> `main`'s integer ADR sequence, mirroring the website subproject's +> `docs/website/adr/`. This avoids the cross-branch number collisions that +> previously forced website ADRs to be renumbered (see that namespace's +> history note and ADR-0000 "Numbering discipline"). + +## Context + +The project is near feature-complete and needs CI (`requirements.md` **TT5**; +the **CI** item in the deferred list) and a release path for its distributed +binaries (**D1**/**D2**/**D3**). The self-hosted Gitea instance +(`git.lazyeval.net`) has its Actions runner freshly set up — a first-time +in-anger use — with a DinD-capable setup and a reusable `docker-build` +template, exercised by a handful of sample workflows. + +The starting constraints, and what the probe found: + +- The runner label is **`ci-public`**. A throwaway probe + (`ci-probe.yaml`, since removed) established that **jobs run *inside* a + container** — `ghcr.io/catthehacker/ubuntu:act-22.04` by default, as **root** + — and therefore the runner *host's* nix is **not** on the steps' PATH + (`nix NOT on PATH`, `no /nix`). A custom job `container:` *can* be pulled + (it pulled `nixos/nix:latest`), but the runner keeps job containers alive + with `entrypoint: /bin/sleep` and runs JS actions (e.g. `actions/checkout`) + with `node`, so the container must provide **`sleep` + `bash` + `node`** — + a bare `nixos/nix` image has none and fails to start. +- The reusable template only does `docker build`; it neither runs a Rust gate + nor pushes images nor uploads release assets — so a Rust pipeline can't just + call it. +- The whole motivation (per the user) is for CI to use the project's **nix + flake** for its tools rather than relying on whatever the build machine has + — i.e. **one toolchain definition shared by dev and CI**. + +## Decision + +### 1. Toolchain delivery — a baked nix CI image + +CI gets its toolchain from a **purpose-built job-container image**, not from +host nix and not by installing nix per-job: + +- **Base `node:22-bookworm-slim`.** Debian slim already provides `bash` + + coreutils (`sleep`); the `node` tag adds the actions runtime. This satisfies + the act_runner job-container contract at a fraction of the size of the + catthehacker runner images (chosen on the user's prompt to avoid those + multi-GB images), and far more reliably than a bare `nixos/nix` (which can't + start). `.gitea/ci-image/Dockerfile`. +- **Single-user nix on top**, flakes enabled, with the **flake's devShell + pre-warmed** (`nix develop` realizes nixpkgs + the pinned Rust toolchain + + `cargo-sweep` + the musl cc into the store). CI then runs `nix develop -c …` + against a warm store — the *same* pinned toolchain as dev (ADR-ci-002), + reaching a ready toolchain in ~1.4 s. +- **Built + pushed by `build-ci-image.yaml`** via the DinD service to the + Gitea container registry as `git.lazyeval.net//rdbms-playground-ci`, + a **public** package (anonymous pull, no gate-side credentials). It runs only + when an image input changes (Dockerfile / `flake.nix` / `flake.lock` / + `rust-toolchain.toml`) or on manual dispatch. + +### 2. Gate — `ci.yaml` + +On branch pushes and PRs, a single job runs **inside the CI image**: +`nix develop -c cargo clippy --all-targets -- -D warnings` then +`nix develop -c cargo test --no-fail-fast`. + +**`fmt` is deliberately not gated.** The tree isn't clean under stock +`rustfmt` (~100 files would change; no `rustfmt.toml` is committed) and +reformatting would churn blame across the in-flight website branch and ongoing +`main` work — so, by user decision, the gate is **clippy + test** and fmt is +revisited on `main` (also recorded in ADR-ci-002). + +### 3. Release — `release.yaml` + +On a `v*` tag, one job in the CI image: + +1. **tests** (`cargo test`) — so a tag can never publish untested code, even + one pointing at a never-gated commit (user choice over relying solely on the + branch gate); +2. **builds the static binary** for **`x86_64-unknown-linux-musl`** (D2: + single static binary, no runtime deps). The glibc/nix-store build is + non-portable; the musl target with `crt-static` is fully static. rusqlite's + `bundled` SQLite C is compiled by a **musl `cc`** (`pkgsCross.musl64`) wired + into the flake devShell via `CC_` + `CARGO_TARGET__LINKER`; + `[profile.release] strip = "symbols"` trims it (~13 MB → ~10 MB); +3. **publishes** the binary + a `.sha256` to a Gitea release via the API and + the auto-provided **`GITEA_TOKEN`** — no third-party action (just `curl` + + `node`, both in the image). + +### 4. Triggers — branch vs tag hygiene + +- Gate and image-build are scoped to **branch** pushes (`branches: ['**']`). + Tag pushes ignore `paths:` filters and would otherwise spuriously rebuild the + unchanged image and re-gate an already-gated commit; the branch filter + excludes tags. **`release.yaml` owns tags** (`tags: ['v*']`). +- Pushing commits + a tag together still gates the commits (via the branch + ref) and releases (via the tag ref) — no lost coverage, no duplicate runs. + +### 5. Auth + +- **Image push:** a dedicated PAT with `write:package`, supplied as the + `REGISTRY_USERNAME` / `REGISTRY_TOKEN` Actions secrets (the package owner + must match the token's user — an `oli`-namespace push with a different user + is refused with `reqPackageAccess`). +- **Release publish:** the auto `GITEA_TOKEN` (repo/release scope). + +### 6. Scope this iteration — Linux x86_64, step by step + +The user's target is the full **D1** matrix, approached incrementally. This +iteration ships **Linux x86_64 only**; the rest is deferred (below). + +## Consequences + +- **One toolchain, dev and CI.** They build through the same flake and cannot + drift. New image rebuilds only when the flake/toolchain/Dockerfile change. +- **D2 is met on Linux.** The release artifact is a genuinely static, + stripped musl binary that runs with no runtime dependencies. +- **DinD is per-job (no layer cache across runs),** so every `build-ci-image` + run rebuilds from scratch (~6 min). Acceptable at its trigger frequency; + base-pull caching via the `dind-cached` proxy variant is a possible later + optimisation. +- **The CI image is ~5.5 GB+** (the Rust toolchain closure, now also musl). + Pulled once per runner and cached; slimming (multi-stage, prune) is optional. +- **Every gate run recompiles the full dependency graph** (warm *toolchain*, + cold *deps*; clippy and test don't share artifacts), ~2 min total. Fine for + now; dependency/`target` caching is a deferred speed item. +- **`GITEA_TOKEN` must retain release scope;** if an instance policy narrows + it, the release publish falls back to a repo-scoped PAT secret. + +## Alternatives considered + +- **Run on the runner host's nix.** Rejected — the probe showed steps run in a + container where host nix is unreachable. +- **Install nix per-job in the default image.** Works but cold every run + (slow) and throwaway once the image exists; rejected in favour of the baked + image. +- **`catthehacker` or bare `nixos/nix` as the base.** catthehacker is a + multi-GB runner emulation we don't need; bare `nixos/nix` lacks + `sleep`/`bash`/`node` and won't start. `node:22-bookworm-slim` is the small, + contract-satisfying middle (user's suggestion). +- **A standard `rust:1.95` CI image instead of the flake.** Simpler in CI but a + *second* toolchain definition (drift) — counter to the unify-with-dev goal. +- **A third-party Gitea release action.** Avoided; the API + auto token keep + the release self-contained and debuggable. + +## Deferred / out of scope (tracked, step by step) + +- **D1 matrix:** aarch64, macOS, Windows builds (cross toolchains; macOS is the + hard part on a Linux runner). +- **D3 packaging:** Homebrew / Scoop / winget / `cargo-binstall` manifests + (and binstall-friendly asset naming/archives). +- **Tier 4 (PTY E2E):** still unwired (`requirements.md` **TT4**); the gate runs + tiers 1–3 only, so **TT5** ("CI runs all tiers on Linux/macOS/Windows") is + partially met — Linux, tiers 1–3. +- **CI speed:** dependency/`target` caching (cargo-chef into the image, or + `actions/cache`), and image slimming / `dind-cached` base-pull caching. +- **Website deploy:** the static site → Cloudflare via Gitea Actions (a + separate, simpler workflow on the website branch). +- **fmt gate:** revisit on `main` once a `rustfmt` style is chosen. + +## Relationship to other decisions + +- **Builds on ADR-ci-002** (nix flake dev + build env). This ADR adds the + musl-target/cc to that flake and consumes it from CI. +- **Advances `requirements.md`:** **TT5** (CI runs the tiers — Linux, 1–3), + **D2** (static binary — Linux, done), **D1**/**D3** (partial/deferred). +- **Mirrors the website subproject's** separate ADR namespace and its + static→Cloudflare-via-Gitea-Actions deployment posture (ADR-website-001). diff --git a/docs/adr/0049-nix-flake-dev-and-build-env.md b/docs/ci/adr/20260612-adr-ci-002.md similarity index 88% rename from docs/adr/0049-nix-flake-dev-and-build-env.md rename to docs/ci/adr/20260612-adr-ci-002.md index 2c2d9aa..6976b1f 100644 --- a/docs/adr/0049-nix-flake-dev-and-build-env.md +++ b/docs/ci/adr/20260612-adr-ci-002.md @@ -1,4 +1,4 @@ -# ADR-0049: Nix flake for a reproducible dev + build environment +# ADR-ci-002: Nix flake for a reproducible dev + build environment ## Status @@ -10,8 +10,15 @@ clippy --all-targets -- -D warnings` is clean and `cargo test` is **2424 passed / 0 failed / 1 ignored** (the ignored item is the intentional ```` ```ignore ```` doctest at `src/friendly/mod.rs:21`), all run *through the flake*. This ADR is the dev/build-environment -half of the CI work; the CI **pipeline** itself (runner wiring, -release matrix) is decided separately as it settles. +foundation; the CI **pipeline** that consumes it (runner model, image, +gate, release) is **ADR-ci-001**. + +> **History.** Created as **ADR-0049** in `main`'s integer ADR namespace +> (`docs/adr/`); moved here to **ADR-ci-002** on 2026-06-12 to keep the +> CI/dev-env decisions out of `main`'s sequence and end the cross-branch +> number collision (`main` independently reaches for the next integer too — +> the same problem the website subproject hit). Content is otherwise +> unchanged. See ADR-0000 "Numbering discipline". ## Context @@ -122,6 +129,7 @@ declaration of the dev *and* build environment. - Mirrors **datamage ADR 0046** (nix flake dev env) and its build hygiene companion. This is the rdbms-playground analogue, scoped to a pure-Rust project. -- Feeds the CI pipeline work for `requirements.md` **TT5** (CI runs - the tiers) and the **D1/D2/D3** distribution items (the release - matrix consumes `nix build` / a static target). +- Feeds **ADR-ci-001** (the CI + release pipeline), which consumes this + flake for `requirements.md` **TT5** (CI runs the tiers) and the + **D1/D2/D3** distribution items (the release uses a static musl target + built through this flake). diff --git a/docs/ci/adr/README.md b/docs/ci/adr/README.md new file mode 100644 index 0000000..38aa095 --- /dev/null +++ b/docs/ci/adr/README.md @@ -0,0 +1,22 @@ +# CI / Build Architecture Decision Records + +Decision records for the **continuous-integration + release pipeline** +subproject — the Gitea Actions workflows under `.gitea/`, the nix CI image, +and the release tooling. These are kept in their own namespace, separate +from the project-wide ADRs in [`docs/adr/`](../../adr/README.md), so CI +decisions never compete with the main global ADR sequence for numbers — the +same split the website subproject uses (`docs/website/adr/`, on the `website` +branch), and for the same reason (see +[ADR-0000 "Numbering discipline"](../../adr/0000-record-architecture-decisions.md)). + +**Numbering.** Files are named `-adr-ci-.md` and referenced in +prose as `ADR-ci-NNN`. The `` (the ADR's accepted/created day, +`YYYYMMDD`) plus the `ci` segment keeps the namespace disjoint from `main`'s +integers. Assign the next free `NNN` from this index. Every ADR change +updates this index in the same edit (the ADR-0000 index-upkeep rule applies +here too). + +## Index + +- [ADR-ci-001 — CI + release pipeline on Gitea Actions](20260612-adr-ci-001.md) — **Accepted 2026-06-12** (implemented the same day on the `ci` branch). Establishes the CI/release pipeline on the self-hosted Gitea instance's Actions runner (`ci-public`). **Runner model** (established by a throwaway probe): jobs execute *inside* a container (`catthehacker/ubuntu:act-22.04` by default), as root, so the runner host's nix is **not** reachable from steps. **Toolchain delivery:** a **baked CI image** — `node:22-bookworm-slim` (satisfies the act_runner job-container contract: `/bin/sleep` keep-alive, `bash`, `node` for JS actions; a bare `nixos/nix` image lacks these and won't start) **+ single-user nix + the flake's devShell pre-warmed** — built by `build-ci-image.yaml` via DinD and pushed to the Gitea container registry as a **public** package, so CI runs `nix develop -c …` against the **same pinned toolchain as dev** (the flake, ADR-ci-002) with a warm store (~1.4 s to a ready toolchain). **Gate** (`ci.yaml`): `clippy -D warnings` + `cargo test` inside that image on branch pushes + PRs; **fmt deliberately not gated** (the tree isn't stock-rustfmt-clean — user decision, revisit on `main`; see ADR-ci-002). **Release** (`release.yaml`): on a `v*` tag, runs the tests, builds the **static `x86_64-unknown-linux-musl` binary** (D2: single static binary, no runtime deps — the glibc/nix build is non-portable), strips it, and publishes it + a `.sha256` to a Gitea release via the API and the auto-provided `GITEA_TOKEN`. **Triggers:** gate + image-build are scoped to **branch** pushes (`branches: ['**']`) so a release tag doesn't spuriously re-run them; the image-build additionally path-filters to its inputs (Dockerfile/flake/toolchain); the release owns tags. **Auth:** a dedicated PAT (`REGISTRY_USERNAME`/`REGISTRY_TOKEN` secrets) pushes the image; the auto `GITEA_TOKEN` publishes releases. **Scope this iteration:** Linux x86_64 only — the rest of the D1 matrix (aarch64/macOS/Windows), D3 package-manager manifests, CI-speed dependency caching, and the website's static→Cloudflare deploy are deferred, to be added step by step. Verified live: probe → runner facts; image built + checked locally; gate green (**2424 tests**); release exercised end-to-end (`v0.0.0-citest2` published with binary + checksum). Builds on **ADR-ci-002** (the nix flake, relocated here from main's ADR-0049 to avoid exactly this cross-branch collision). +- [ADR-ci-002 — Nix flake for a reproducible dev + build environment](20260612-adr-ci-002.md) — **Accepted 2026-06-12** (relocated from main's **ADR-0049** on the same day — content unchanged — to keep CI/dev-env decisions out of `main`'s integer sequence). The single, version-pinned declaration of the **dev *and* build toolchain** so CI never relies on whatever Rust is on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI). Root **Nix flake** with two outputs: **`devShells.default`** (pinned **Rust 1.95.0** via `rust-toolchain.toml` + `rust-overlay`, `cargo-sweep`, and the musl cc for the static release build) and **`packages.default`** (`rustPlatform.buildRustPackage` from the committed `Cargo.lock`; `doCheck = false`). Exact-pin (not floating `stable`) so `nix flake update` can't surprise-bump clippy past the `-D warnings` gate. System inputs near-empty by design (`libsqlite3-sys bundled` → stdenv cc only; `arboard`→`x11rb` pure-Rust). `.envrc` (`use flake`) for direnv parity. Verified through the flake: `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest**. Consumed by **ADR-ci-001** (the pipeline). Alternatives rejected: dev-shell-only; a standard `rust:1.95` CI image (a second toolchain definition = drift); `rustup` on the build host (non-reproducible). From 18d08642d75d92a715fc7cc55aab28b9f8f5122c Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 22:42:50 +0000 Subject: [PATCH 17/50] ci: skip the gate for docs-only changes Add paths-ignore (docs/**, **/*.md) to the gate's push + pull_request triggers so markdown/docs-only changes don't run a full clippy+test that can't change the outcome. Mixed code+docs pushes still gate (not all files are ignored); flake/toolchain changes are deliberately not ignored. Also refresh a stale ADR-0049 -> ADR-ci-002 comment reference. --- .gitea/workflows/ci.yaml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 0505252..8f717f4 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -2,7 +2,7 @@ # build-ci-image.yaml), so the pinned 1.95.0 toolchain is already warm — steps # just enter the flake devShell and run cargo. # -# Gate = clippy + test. fmt is deliberately NOT gated yet (ADR-0049: the tree +# Gate = clippy + test. fmt is deliberately NOT gated yet (ADR-ci-002: the tree # isn't clean under stock rustfmt; revisit on main). The release job (static # binary for D2) and the platform matrix layer on later, step by step. name: ci @@ -13,7 +13,17 @@ on: # run (the release workflow owns tags). Pushing commits + a tag together # still gates the commits via the branch push. branches: ['**'] + # Skip the gate for docs-only changes — markdown can't affect clippy/test. + # A push touching code *and* docs still runs (not all files are ignored). + # Note: flake/toolchain changes are NOT ignored — they can shift the + # toolchain and thus lint/test outcomes. + paths-ignore: + - 'docs/**' + - '**/*.md' pull_request: + paths-ignore: + - 'docs/**' + - '**/*.md' jobs: gate: From 8ac3537df06a905e10bb57a29cc96b4fcf4088d4 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Fri, 12 Jun 2026 22:45:18 +0000 Subject: [PATCH 18/50] feat(render): incidental-DDL confirmations show structure only, no relationships (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per ADR-0050 (closing issue #28): the confirmation echo after an incidental structural edit — create table, add/drop/rename/change column, add/drop index — now renders the structure only (header + column box + indexes + constraints) and no longer appends the References:/Referenced by: relationship block. Rationale: a confirmation reports the change just made, not the table's relationships, which the user didn't touch. Relationship info is still one `show table ` away, and the relationship-subject surfaces (show table, add/drop relationship) keep their ADR-0044 diagrams unchanged. Scope is all incidental DDL (user-confirmed). Mechanism: drop the relationship-block call from render_structure (all its callers are incidental DDL); the handle_dsl_success diagram-vs-structure routing is unchanged. The orphaned relationship_prose_lines + cols_disp helpers are deleted (the prose format survives in ADR-0016 §5 + git history for a future OOS-7 always-prose setting). ADR-0050 supersedes ADR-0044 §1's incidental-DDL prose clause and the relationship-block half of ADR-0016 §5 (both annotated). Tests: prose- presence unit test + snapshot removed; new unit test locks structure- only with inbound+outbound relationships present; the misnamed add- column integration test inverted + renamed. 2458 pass / 0 fail / 0 skip, clippy clean. --- docs/adr/0016-pretty-table-rendering.md | 10 ++ docs/adr/0044-relationship-visualization.md | 4 + ...al-ddl-confirmations-omit-relationships.md | 119 ++++++++++++++++++ docs/adr/README.md | 1 + src/app.rs | 6 +- src/output_render.rs | 97 +++++--------- ...__render_structure_with_relationships.snap | 12 -- tests/it/walking_skeleton.rs | 20 ++- 8 files changed, 189 insertions(+), 80 deletions(-) create mode 100644 docs/adr/0050-incidental-ddl-confirmations-omit-relationships.md delete mode 100644 src/snapshots/rdbms_playground__output_render__tests__render_structure_with_relationships.snap diff --git a/docs/adr/0016-pretty-table-rendering.md b/docs/adr/0016-pretty-table-rendering.md index 3a2b3d8..26dfc10 100644 --- a/docs/adr/0016-pretty-table-rendering.md +++ b/docs/adr/0016-pretty-table-rendering.md @@ -197,6 +197,16 @@ Referenced by: The relationship sections retain today's plain-text format to leave room for the future relationship-rendering ADR. +> **Superseded.** ADR-0044 replaced this prose block with compact +> diagrams on relationship-subject surfaces (`show table`, +> `add`/`drop relationship`). **ADR-0050 (2026-06-12, issue #28)** then +> removed the relationship block entirely from incidental-DDL structure +> echoes (`create table`, `add`/`drop`/`rename`/`change column`, +> `add`/`drop index`) — those render structure only — and **deleted the +> prose renderer**. The `References:` / `Referenced by:` format above is +> retained here as documentation/provenance should the OOS-7 +> always-prose display setting ever be built. + ### 6. Theme integration Theme colors apply to the box-drawing characters via the diff --git a/docs/adr/0044-relationship-visualization.md b/docs/adr/0044-relationship-visualization.md index 1210646..dfbb203 100644 --- a/docs/adr/0044-relationship-visualization.md +++ b/docs/adr/0044-relationship-visualization.md @@ -103,6 +103,10 @@ Prose-retained surfaces (**unchanged** from ADR-0016 §5): `add`/`drop index` — keep the terse `References:` / `Referenced by:` prose. A simple `add column` on a heavily-related table should not print a wall of diagrams. + *(**Superseded 2026-06-12 by ADR-0050** (issue #28): these incidental + DDL echoes now render **structure only** — no relationship block at + all, neither prose nor diagram. The prose renderer was deleted. The + diagram surfaces below are unchanged.)* So this **partially supersedes ADR-0016 §5**: the prose block is replaced by diagrams on the relationship-subject surfaces and diff --git a/docs/adr/0050-incidental-ddl-confirmations-omit-relationships.md b/docs/adr/0050-incidental-ddl-confirmations-omit-relationships.md new file mode 100644 index 0000000..c67e6fc --- /dev/null +++ b/docs/adr/0050-incidental-ddl-confirmations-omit-relationships.md @@ -0,0 +1,119 @@ +# ADR-0050: Incidental-DDL confirmations omit relationship info (structure-only) + +## Status + +**Accepted + implemented 2026-06-12 (issue #28).** Closes Gitea **#28**. +Both forks below were escalated to the user and user-chosen before any +code was written; implemented test-first. **Supersedes** the +incidental-DDL clause of **ADR-0044 §1** and the part of **ADR-0016 §5** +that placed a relationship block under every structure echo. The +diagram behaviour ADR-0044 introduced for relationship-subject surfaces +is unchanged. + +## Context + +ADR-0016 §5 rendered a structure box followed by a plain-text +`References:` / `Referenced by:` relationship block under **every** +structure echo. ADR-0044 §1 split that by surface: + +- **Relationship-subject surfaces** — `show table `, + `add 1:n relationship`, `drop relationship`, `show relationship ` + — render relationships as compact **diagrams** (the user asked for, or + acted on, a relationship). +- **Incidental DDL auto-shows** — `create table`, `add`/`drop`/`rename`/ + `change column`, `add`/`drop index` — kept the terse **prose** block, + with the rationale *"a simple `add column` on a heavily-related table + should not print a wall of diagrams."* + +Issue #28 reconsiders the deeper question ADR-0044 did not ask: should +an incidental-DDL confirmation show relationship information **at all**? +Owner preference: **no.** A confirmation echo should focus on the change +just made — the new / updated structure — not re-print the table's +relationships, which the user did not touch. The terse prose was the +lesser of "prose vs diagram", but the right answer for these surfaces is +**neither**. + +## Decision + +**Incidental-DDL confirmation echoes render the structure only** — the +table-name header, the column / type / constraints box, the `Indexes:` +section, and the constraint section — with **no relationship section** +(neither prose nor diagram). + +- **Scope: all incidental DDL** (user-chosen, over "just `add column`"): + `create table`, `add column`, `drop column`, `rename column`, + `change column`, `add index`, `drop index`. The rule is uniform — a + structural edit confirms structure, never relationships. (For a + freshly `create`d table the relationship section was empty anyway; the + rule still applies for consistency of the mental model.) +- **Relationship-subject surfaces are unchanged.** `show table`, + `add`/`drop relationship`, and `show relationship ` still render + diagrams. Relationships appear **only** when the user asks for them + (`show table` / `show relationship`) or acts on one + (`add`/`drop relationship`). +- **No information is lost.** Anything dropped from an incidental echo is + one `show table ` away. + +### Mechanism + +The `handle_dsl_success` routing (`app.rs`) is **unchanged**: it still +sends relationship-subject commands to the diagram renderer and +everything else to `render_structure`. The change is entirely inside +`render_structure` (`output_render.rs`): it no longer appends the +relationship block — `render_structure` = structure box + indexes + +constraints. All of `render_structure`'s callers are incidental DDL +(verified), so this single edit covers the whole scope with no +per-command branching. + +### Prose renderer disposition + +The orphaned prose renderer (`relationship_prose_lines`, and its +sole helper `cols_disp`) is **deleted** (user-chosen, over retaining it +dormant). After this change no shipped surface renders the prose form, +so keeping it would be dead code. The prose format remains documented in +**ADR-0016 §5** and in git history; if ADR-0044's OOS-7 user-configurable +"always-prose" display setting is ever built, it re-introduces the ~30 +lines from that provenance. + +## Forks (all user-chosen) + +- **Scope:** *all incidental DDL*, not just `add column` — the owner's + rationale ("confirm the change, not untouched relationships") applies + uniformly, gives a clean mental model, and is the simpler edit (remove + one call vs a per-command flag). +- **Prose renderer:** *delete* it — no dead code — over retaining a + public, tested-but-uncalled renderer for the speculative OOS-7 setting. + +## Consequences + +- Incidental confirmations are shorter and on-topic; a heavily-related + table no longer prints a relationship wall after `add column`. +- One relationship renderer (prose) leaves the codebase; the diagram + renderer (ADR-0044) is the only relationship render path that ships. +- `requirements.md` is unaffected (this is an ADR-tracked refinement of a + decided area, like ADR-0044 itself); the change is cross-referenced + from the commit + this ADR. + +## Tests + +- **Unit (`output_render.rs`):** the prose-asserting test + `render_structure_with_relationships` (+ its snapshot) is removed; a + new test asserts `render_structure` on a description **carrying** both + inbound and outbound relationships emits the structure box but **no** + `References:` / `Referenced by:` lines. The box/index/constraint tests + are unaffected (their descriptions have no relationships). +- **Integration (`walking_skeleton.rs`):** the misnamed + `add_relationship_flow_shows_inbound_section_on_parent` (which sends an + `AddColumn` and asserted the inbound prose) is inverted + renamed to + assert the add-column confirmation shows the structure but **omits** + the relationship prose. +- **Unchanged:** the diagram tests (`show_list.rs` `show table`, + `walking_skeleton.rs` `add relationship`) still pass — they already + assert prose is absent and diagrams are present. + +## Out of scope + +- The diagram form and its per-surface defaults (ADR-0044) — unchanged. +- The OOS-7 user-configurable display setting (always-prose / -diagram / + auto-by-width) — still a future follow-up; this ADR removes the prose + *renderer*, not the *idea* of a prose mode. diff --git a/docs/adr/README.md b/docs/adr/README.md index 9176b14..97253f2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -55,3 +55,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0047 — Demonstration overlay layer (keystroke badges + step captions)](0047-demonstration-overlay-layer.md) — **Accepted 2026-06-10; implemented 2026-06-11, phased A→B→C (closes Gitea #22)** (commits `f879d54`→`2d0f4b2`; no `requirements.md` item — tracked by issue + ADR per convention; all forks user-confirmed + a pre-build `/runda` pass that produced 10 tightening findings and a whole-implementation `/runda` pass that returned PASS, no blockers). An in-app **demonstration mode** (`--demo` flag / `RDBMS_PLAYGROUND_DEMO` env, **off by default, zero footprint when off**) that renders two transient overlays so `autocast` screencasts — and live teaching, and a future guided-lesson system — can show otherwise-invisible interactions. **Keystroke badges** (`[TAB]`, `[ENTER]`, `[UP]`, …): **automatic, app-detected** over a fixed set of glyph-less keys (the app already sees every key, so it re-records for free), label via a pure `demo_badge_label(&KeyEvent)`; the badge **auto-expires on a ~1.5 s timer** that extends the runtime's existing time-boxed-`recv` arm condition (`debounce.is_armed() || badge_pending`; expiry `Instant` in the runtime, `App.demo_badge` the render mirror — mirroring the `input` vs `input_indicator` split). **Step captions**: a **stealth, control-code-delimited input buffer** toggled by **`Ctrl+]`** (byte `0x1D` → arrives as `Char('5')+CONTROL`, verified against crossterm 0.29 `parse.rs:110-113`; chosen over `Ctrl+!`, which is **not a single ASCII byte so autocast cannot send it** — the same wall as arrow keys, R4) — typed characters accumulate **invisibly** (prompt untouched, no echo/history), `Backspace` edits, other keys inert, a second `Ctrl+]` **commits** to the caption box (empty commit dismisses); lives in pure-sync `App::update()`, **intercepted before the modal gate** so captions/badges work **over the load picker** (the `#24` projects cast). Both render as **floating flat black-on-yellow rectangles** (solid fill, **no border glyphs** — a one-cell text margin, deliberately unlike the app's bordered panels; user decision post-build, `2d0f4b2`) **at the output panel's inner bottom-right**, drawn **last over modals**, badge **stacked above** the caption, **no layout reflow**; caption **word-wraps to ≤ 3 lines** (3–5 rows), badge fixed 3 rows; clamp/skip guard for tiny terminals; a new **`App.last_output_area: Rect`** (set in `render_output_panel`) gives the top-level draw the anchor. Caption persists **until the next keystroke**; badge suppressed while capturing. Forks all user-chosen: `--demo` activation (vs hidden command / chord); automatic badges (vs scripted); stealth buffer (vs typed-command / preloaded-file); floating bottom-right boxes (vs HUD / banner / subtitle); `Ctrl+]` trigger; wrap-to-3-line captions; ~1.5 s badge / next-keystroke caption timing. Tested test-first across Tier 1 (label fn, capture state machine incl. over-modal + demo-off gate, nearest-deadline helper), Tier 2 (insta snapshots: badge/caption/both-stacked at 90×26 light+dark, short-terminal clamp), Tier 3 (`--demo` plumbing, badge set/suppressed, caption-without-input wiring), CLI (`--demo` parse + env fallback) — with an **honest limit** noted: the `tokio` timer wiring inside `run_loop` is exercised via the pure pieces + Tier-3 plumbing, not a standalone integration test of the timeout (same posture as the existing `IndicatorDebounce`). One intentional, user-acknowledged behaviour: `Ctrl-C` is inert while capturing (every non-`Ctrl+]` key is, by spec). Final tally **2290 passing / 0 failing / 0 skipped** (1 long-standing ignored doctest), clippy clean. OOS: scripted/manual badge push; badges for glyph keys; configurable styling/placement; the guided-lesson system itself (own ADR); cross-session/-switch persistence; localised caption content; arrow-only cast interactions (output-pane scroll); wiring the overlays into the website `casts.mjs` scripts (website-branch follow-up). Implementation phased **A** (`--demo` plumbing) → **B** (badges) → **C** (captions) + a flat-rectangle restyle - [ADR-0048 — `seed` fake-data generation command](0048-seed-fake-data-generation.md) — **Accepted 2026-06-11; Phase 1 + Phase 2 implemented 2026-06-11** (Phase 1 commits `202e25a`→`fbd219b`; design settled with the user across an extended fork dialogue, hardened by a pre-build `/runda` pass (six blockers folded in), a post-implementation `/runda` pass (eight gaps closed — FK/shortid determinism so **D4 holds with no exceptions**, plus six untested ADR decisions), and a Phase-2 pre-build `/runda` pass (which caught the no-date-literal-token reality → the D2 quoted-dates amendment), and a post-implementation `/runda` pass (which added a friendly error for a bounded override on a UNIQUE column — see D2); **2400 tests pass, clippy clean**). Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1**. **Phase 1 shipped:** whole-row `seed
[count] [--seed ]` with realistic name-aware generation (the `fake` crate + a type-gated heuristic catalogue, table-context name disambiguation, hand-rolled `product` generator, bounded dates), identifier + constraint uniqueness incl. junction distinct-combos, FK sampling from existing parent rows (empty-parent error), `IN`-CHECK derivation + complex-CHECK advisory, a required-column block guard, `--seed` reproducibility (serial/FK/shortid all deterministic), undo as one batch step, replay as a data write, a capped auto-show preview, the enum/CHECK advisory, and an O(N) single-transaction insert path. **Phase 2 shipped (2026-06-11):** the `set` override clause (D2 — fixed value / pick-list / `as ` / `between` range, **quoted** dates per the D2 amendment, type-aware, override drops the column from the advisory) and the `
.` column-fill form (D1 form 2 — an UPDATE over existing rows, refusing PK/autogen targets, empty-table no-op, FK/unique-respecting, one undo step), with the new `KNOWN_GENERATORS` vocabulary (D9), a range `Generator`, full completion/highlight (`HighlightClass::Function`)/validity (`IdentSource::Generators`)/help/pedagogy wiring, and the D13 advisory's Phase-2/3 wording. Further SD2 increments (custom generators, NULL injection, multi-locale, recursive auto-seed) out of scope. Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1** (the other being `hint`/**H2**). A dedicated `seed` command (own AST variant + `do_seed` executor, **both modes**) generating **realistic, name-aware** fake data. Two forms: **`seed
[count]`** (new rows, default **20**, capped) and **`seed
.`** (fill a column on existing rows, an UPDATE). Generation adds the **`fake` crate** (v5, English) driven by a **type-gated, token-matched name-heuristic catalogue** (~30 patterns, documented false-positive guards), with **table-context** disambiguating the `name`/`title` family (`products.name`→product, `users.name`→person, `vendors.name`→company), a **hand-rolled `product` generator** (`fake` has no commerce module), **bounded dates** (`date`/`timestamp`/`dob`/`*_at` recognised, recent windows — never "all of history"), the **identifier family** (`id`/`code`/`ref`/`number`, non-FK/non-PK) → **unique sequential**, and **enum-ish names** (`role`/`status`/`type`/…) left generic + a **post-seed Hint advisory** pointing at `set … in (…)`. A **`set` override clause** — `= value` / `in (a,b,c)` / `as ` / `between a and b` (numeric **and** date), reusing ADR-0026 operators — answers the heuristic-miss case. **`--seed `** makes runs reproducible (and enables exact-value tests). **FK** columns sampled uniformly from existing parent rows (**empty parent → friendly error**, no recursion v1); **junction/compound-PK** tables seeded with **distinct combinations**, capped + noted (SD1). A **required-column block guard** refuses rather than NULL-violate a `NOT NULL` column it can't fill (e.g. `NOT NULL blob`). Full ambient wiring (completion incl. a new generator-name vocabulary highlighted as `tok_function`, hints, `help seed`, ADR-0042 near-miss matrix, ADR-0027 validity); **no DSL→SQL teaching echo** (seed is a utility command, not a SQL twin). Honours **X5** — `do_seed` reuses insert/update *mechanics as helpers*, not by emitting `Command::Insert`. Implementation phased: (1) core whole-row seed → (2) `set` overrides → (3) column-fill. Deferred (future SD2): recursive auto-seed, NULL injection, multi-locale, user-defined custom generators, full per-column report. **Amendment 1, 2026-06-12** (issues #33/#34): two additive D7 catalogue rules — **year-as-int** (`year`/`*_year`/`published`/`founded` → a bounded `int` year, 1950–2025, or the `dob`-style birth window 1945–2007 for `birth`/`born`/`dob`; fixes nonsense like `9419`; `int`-gated, after the quantity rule so `year_count` stays a count; two new `YearRecent`/`YearBirth` generators, *not* added to the D9 vocabulary) and **conventional choice sets** (`priority`/`prio`, `severity`, `rating`/`stars` → type-gated built-in `PickFrom` value sets reusing the existing generator; `priority` leaves `ENUM_TOKENS`). `status` is **deliberately excluded** (user-confirmed — values too domain-specific; keeps the D12 "don't guess" + advisory); a user `IN`-CHECK still wins. Website `seed` cast re-record tracked on the `website` branch - [ADR-0049 — Input-field readline keymap: Esc-clear + Ctrl-A/E/W/K/U (I1b)](0049-input-field-readline-keymap.md) — **Accepted + implemented 2026-06-12 (issue #29)**, closes Gitea **#29** and the deferred **I1b** readline requirement. **Amends ADR-0046**, which listed "readline shortcuts (I1b)" as out-of-scope — that item is now in scope and decided here; orthogonal to ADR-0003's input-*mode* model and extends the I1a single-line cursor editing already shipped. Binds, in the input field (non-modal, non-nav, both modes): **`Esc`** clears a partly-typed command (empty buffer, cursor→0, scroll→0); **`Ctrl-A`/`Ctrl-E`** alias Home/End (line start/end); **`Ctrl-W`** deletes the previous word (readline-style — eats trailing whitespace then the preceding non-whitespace run, UTF-8-safe on char boundaries, only back to the cursor); **`Ctrl-K`** kills to end of line; **`Ctrl-U`** kills to start. **Esc precedence:** a live Tab-completion memo still wins (Esc undoes the completion first, ADR-0022; Esc clears only when no memo) — Esc-once backs out the completion, Esc-again clears. Forks all user-chosen: **single-Esc-clears** (not double-Esc — discoverable over accident-proof; an unsubmitted draft can be lost, a submitted line is always in history); the **full I1b set** (not just the issue's literal Ctrl-A/E + Esc); a **new ADR** (not an ADR-0046 amendment / no-ADR). Cursor-only keys (Ctrl-A/E) leave history navigation intact like Home/End; buffer-mutating keys (Esc-clear, Ctrl-W/K/U) end it like Backspace. Helpers `clear_input`/`delete_prev_word`/`kill_to_end`/`kill_to_start` in `src/app.rs`; **22 new Tier-1 tests, 2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. OOS: on-screen keybinding hints (issue #27 owns surfacing per-focus keybindings in the bottom status line — this ADR makes the keys *work*, #27 makes them *discoverable*); demo-mode badges for the new chords (ADR-0047 follow-up — Esc already badges `[ESC]`, the glyph-less Ctrl-chords are flagged but not added); multi-line input (I1); word-wise cursor motion (Alt-B/F) / transpose / yank +- [ADR-0050 — Incidental-DDL confirmations omit relationship info (structure-only)](0050-incidental-ddl-confirmations-omit-relationships.md) — **Accepted + implemented 2026-06-12 (issue #28)**, closes Gitea **#28**. **Supersedes** the incidental-DDL clause of **ADR-0044 §1** and the relationship-block half of **ADR-0016 §5**. Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/`rename`/`change column`, `add`/`drop index`) now render **structure only** — header + column box + `Indexes:` + constraints — with **no `References:` / `Referenced by:` block** (neither prose nor diagram), even when the table carries relationships the user did not touch. Rationale (owner): a confirmation echo reports the change just made, not untouched relationships; ADR-0044's terse prose was the lesser of "prose vs diagram", but the right answer for these surfaces is **neither**. **Relationship-subject surfaces are unchanged** — `show table`, `add`/`drop relationship`, `show relationship` still render ADR-0044 diagrams; relationships appear only when the user asks for (`show table`) or acts on (`add`/`drop relationship`) one, and are one `show table ` away — **no information lost**. Forks both user-chosen: **scope = all incidental DDL** (not just `add column` — the rationale is uniform, the mental model clean, and it's the simpler edit) and **delete the prose renderer** (not retain it dormant — no dead code). **Mechanism:** the `handle_dsl_success` `matches!` routing is unchanged (relationship-subject → diagrams; else → `render_structure`); the change is one line inside `render_structure` (`output_render.rs` — drop the relationship-block call) since all its callers are incidental DDL, plus deletion of the orphaned `relationship_prose_lines` + `cols_disp` helpers. The prose format survives in ADR-0016 §5 + git history for a future OOS-7 always-prose setting. **Tests:** the prose-presence unit test + its snapshot removed; a new unit test asserts `render_structure` on a description carrying **both** inbound and outbound relationships emits the box but no prose; the misnamed `add_relationship_flow_shows_inbound_section_on_parent` integration test (which sent an `AddColumn`) inverted + renamed to assert the add-column echo omits the prose; the diagram tests (`show table`, `add relationship`) unaffected. **2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. `requirements.md` unaffected (ADR-tracked refinement of a decided area, like ADR-0044 itself) diff --git a/src/app.rs b/src/app.rs index e9c3e74..e414642 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2110,8 +2110,10 @@ impl App { // ADR-0044 §1 "relationship-relevant" reach: when a // relationship is the subject of the command (`show table`, // `add`/`drop relationship`), render the table's - // relationships as compact diagrams; every other DDL echo - // keeps the prose `References:` / `Referenced by:` form. + // relationships as compact diagrams. Every other (incidental + // DDL) echo renders structure only — no relationship block + // at all (ADR-0050, issue #28; supersedes ADR-0044 §1's + // prose retention for these surfaces). if matches!( command, Command::ShowTable { .. } diff --git a/src/output_render.rs b/src/output_render.rs index 24554c4..ed00a6d 100644 --- a/src/output_render.rs +++ b/src/output_render.rs @@ -71,27 +71,22 @@ pub fn render_data_table(data: &DataResult) -> Vec { render_table(&header_cells, &body, &alignments) } -/// Render a table-structure listing. +/// Render an incidental-DDL structure echo (ADR-0050, issue #28). /// -/// Produces a header line (``), the schema table -/// itself, and — for a structure that has FK relationships -/// — `References:` / `Referenced by:` blocks below as plain -/// indented text (relationship visualization is its own -/// future ADR per §5 OOS-1). -/// Display a relationship-endpoint column list (ADR-0043): the bare -/// column for a single-column FK, `(a, b)` for a compound one. -fn cols_disp(cols: &[String]) -> String { - if cols.len() == 1 { - cols[0].clone() - } else { - format!("({})", cols.join(", ")) - } -} - +/// Produces a header line (``), the schema table, the +/// `Indexes:` section, and the constraint section — **structure only**. +/// Relationship information is deliberately omitted: a confirmation +/// echo for a structural edit (`create table`, `add`/`drop`/`rename`/ +/// `change column`, `add`/`drop index`) reports the change just made, +/// not the table's relationships, which the user did not touch. The +/// relationship-subject surfaces (`show table`, `add`/`drop +/// relationship`) render diagrams via [`render_structure_with_diagrams`] +/// instead; relationships are one `show table ` away. ADR-0050 +/// supersedes ADR-0044 §1's "incidental DDL keeps prose" and the +/// relationship-block half of ADR-0016 §5. #[must_use] pub fn render_structure(desc: &TableDescription) -> Vec { let mut out = structure_box_lines(desc); - out.extend(relationship_prose_lines(desc)); out.extend(index_lines(desc)); out.extend(constraint_lines(desc)); out @@ -118,41 +113,6 @@ fn structure_box_lines(desc: &TableDescription) -> Vec { out } -/// The `References:` / `Referenced by:` prose blocks (ADR-0016 §5), -/// retained for the incidental DDL echoes (ADR-0044 §1). -fn relationship_prose_lines(desc: &TableDescription) -> Vec { - let mut out: Vec = Vec::new(); - if !desc.outbound_relationships.is_empty() { - out.push("References:".to_string()); - for r in &desc.outbound_relationships { - out.push(format!( - " {} → {}.{} ({}, on delete {}, on update {})", - cols_disp(&r.local_columns), - r.other_table, - cols_disp(&r.other_columns), - r.name, - r.on_delete, - r.on_update, - )); - } - } - if !desc.inbound_relationships.is_empty() { - out.push("Referenced by:".to_string()); - for r in &desc.inbound_relationships { - out.push(format!( - " {}.{} → {} ({}, on delete {}, on update {})", - r.other_table, - cols_disp(&r.other_columns), - cols_disp(&r.local_columns), - r.name, - r.on_delete, - r.on_update, - )); - } - } - out -} - /// Indexes section (ADR-0025), only when the table carries a /// user-created index. A UNIQUE index is marked `[unique]` (ADR-0035 /// §4d). @@ -1591,11 +1551,23 @@ mod tests { } #[test] - fn render_structure_with_relationships() { + fn render_structure_omits_relationship_prose() { + // ADR-0050 (issue #28): the incidental-DDL structure echo never + // carries the `References:` / `Referenced by:` block, even when + // the description carries both inbound and outbound + // relationships. (Relationship-subject surfaces render diagrams + // via `render_structure_with_diagrams`, not this function.) let desc = TableDescription { name: "Customers".to_string(), columns: vec![col("id", Type::Serial, true, false)], - outbound_relationships: Vec::new(), + outbound_relationships: vec![RelationshipEnd { + name: "cust_region".to_string(), + other_table: "Regions".to_string(), + other_columns: vec!["id".to_string()], + local_columns: vec!["region_id".to_string()], + on_delete: ReferentialAction::NoAction, + on_update: ReferentialAction::NoAction, + }], inbound_relationships: vec![RelationshipEnd { name: "cust_orders".to_string(), other_table: "Orders".to_string(), @@ -1609,15 +1581,14 @@ mod tests { check_constraints: Vec::new(), }; let out = render_structure(&desc).join("\n"); - assert!( - out.contains("Referenced by:"), - "expected inbound relationship section:\n{out}", - ); - assert!( - out.contains("Orders.cust_id → id"), - "expected inbound relationship line:\n{out}", - ); - assert_snapshot!(out); + // The structure box still renders. + assert!(out.contains("Customers"), "structure header:\n{out}"); + assert!(out.contains("│ id"), "column row:\n{out}"); + // No relationship block in either direction. + assert!(!out.contains("References:"), "no outbound prose:\n{out}"); + assert!(!out.contains("Referenced by:"), "no inbound prose:\n{out}"); + assert!(!out.contains("Orders.cust_id"), "no prose line:\n{out}"); + assert!(!out.contains("Regions"), "no prose line:\n{out}"); } #[test] diff --git a/src/snapshots/rdbms_playground__output_render__tests__render_structure_with_relationships.snap b/src/snapshots/rdbms_playground__output_render__tests__render_structure_with_relationships.snap deleted file mode 100644 index f0d764c..0000000 --- a/src/snapshots/rdbms_playground__output_render__tests__render_structure_with_relationships.snap +++ /dev/null @@ -1,12 +0,0 @@ ---- -source: src/output_render.rs -expression: out ---- -Customers -┌──────┬────────┬─────────────┐ -│ Name │ Type │ Constraints │ -├──────┼────────┼─────────────┤ -│ id │ serial │ PK │ -└──────┴────────┴─────────────┘ -Referenced by: - Orders.cust_id → id (cust_orders, on delete cascade, on update no action) diff --git a/tests/it/walking_skeleton.rs b/tests/it/walking_skeleton.rs index 98b8400..5793393 100644 --- a/tests/it/walking_skeleton.rs +++ b/tests/it/walking_skeleton.rs @@ -494,7 +494,12 @@ fn add_relationship_flow_shows_parent_side_with_inbound_section() { } #[test] -fn add_relationship_flow_shows_inbound_section_on_parent() { +fn add_column_confirmation_omits_relationship_prose() { + // ADR-0050 (issue #28): an incidental-DDL confirmation echo (here + // `add column`) renders the structure only — never the + // `References:` / `Referenced by:` relationship block — even when + // the table carries relationships the user did not touch. The + // relationships remain one `show table` away. let mut app = App::new(); let customers = TableDescription { name: "Customers".to_string(), @@ -535,8 +540,17 @@ fn add_relationship_flow_shows_inbound_section_on_parent() { echo: None, }); let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); - assert!(rendered.contains("Referenced by:"), "{rendered}"); - assert!(rendered.contains("Orders.CustId → Id"), "{rendered}"); + // The structure box still renders (table name + the column box from + // the returned description). + assert!(rendered.contains("Customers"), "structure header:\n{rendered}"); + assert!(rendered.contains("Constraints"), "structure box:\n{rendered}"); + // The relationship block is gone — neither prose heading nor line. + assert!(!rendered.contains("Referenced by:"), "no prose heading:\n{rendered}"); + assert!(!rendered.contains("References:"), "no prose heading:\n{rendered}"); + assert!( + !rendered.contains("Orders.CustId → Id"), + "no prose line:\n{rendered}", + ); } #[test] From 04ebd83f08c70b03b2a292e8cfc8945adc8b076a Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sat, 13 Jun 2026 12:14:49 +0000 Subject: [PATCH 19/50] build: D1 cross-compile via cargo-zigbuild (4 non-macOS targets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single-target musl cc with cargo-zigbuild + zig in the flake devShell — one universal cross cc/linker (incl. rusqlite's bundled SQLite C) for all four non-macOS D1 targets, added to rust-toolchain.toml: x86_64/aarch64-unknown-linux-musl (static, D2) x86_64-pc-windows-gnu, aarch64-pc-windows-gnullvm (standalone .exe) Windows links -lsynchronization (std WaitOnAddress), which rust-overlay's toolchain and zig's mingw don't ship; the symbols are forwarded by kernel32, so an empty stub libsynchronization.a (ci/winstub/, wired via .cargo/config.toml for the windows targets only) satisfies the linker. Verified: all four build; linux static; windows valid PE32+. --- .cargo/config.toml | 17 +++++++++++++++++ ci/winstub/README.md | 30 ++++++++++++++++++++++++++++++ ci/winstub/libsynchronization.a | 1 + flake.nix | 28 +++++++++++----------------- rust-toolchain.toml | 18 ++++++++++++------ 5 files changed, 71 insertions(+), 23 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 ci/winstub/README.md create mode 100644 ci/winstub/libsynchronization.a diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..5f1ba45 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,17 @@ +# Windows cross-link fix for the D1 release matrix (cargo-zigbuild). +# +# Rust's std links `-lsynchronization` on Windows (WaitOnAddress-based thread +# parking). Rust normally satisfies this from the `self-contained` mingw libs +# of its `rust-mingw` component — which rust-overlay does NOT ship — and Zig's +# bundled mingw (used by `cargo zigbuild`) doesn't provide `libsynchronization.a` +# either. The actual symbols are *forwarded by kernel32* (already linked), so an +# empty stub import lib is enough to satisfy the linker. See `ci/winstub/`. +# +# These sections apply ONLY when building for the Windows targets, so host +# builds (the gate's `cargo test`/`clippy`) and the Linux release targets are +# unaffected. +[target.x86_64-pc-windows-gnu] +rustflags = ["-L", "native=ci/winstub"] + +[target.aarch64-pc-windows-gnullvm] +rustflags = ["-L", "native=ci/winstub"] diff --git a/ci/winstub/README.md b/ci/winstub/README.md new file mode 100644 index 0000000..ad05111 --- /dev/null +++ b/ci/winstub/README.md @@ -0,0 +1,30 @@ +# `ci/winstub/` — empty Windows import-lib stub + +`libsynchronization.a` here is an **empty `ar` archive** (8 bytes: `!\n`), +referenced by `.cargo/config.toml` via `-L native=ci/winstub` for the Windows +release targets. + +## Why + +The D1 release matrix cross-compiles Windows binaries from Linux with +`cargo zigbuild` (see `docs/ci/adr/`). Rust's `std` links `-lsynchronization` +for its `WaitOnAddress`-based thread parking. That import library is normally +provided by Rust's `rust-mingw` "self-contained" component — which `rust-overlay` +does not ship — and Zig's bundled mingw doesn't carry it either, so the link +fails with: + +``` +error: unable to find dynamic system library 'synchronization' +``` + +The functions it would import (`WaitOnAddress`, `WakeByAddressSingle`, +`WakeByAddressAll`) are **forwarded by `kernel32.dll`**, which is already linked, +so they resolve at link and run time without a real `synchronization` import +library. An **empty** stub is therefore sufficient: it satisfies the `-l` +lookup and contributes no symbols. + +## Regenerating + +``` +zig ar rcs ci/winstub/libsynchronization.a +``` diff --git a/ci/winstub/libsynchronization.a b/ci/winstub/libsynchronization.a new file mode 100644 index 0000000..8b277f0 --- /dev/null +++ b/ci/winstub/libsynchronization.a @@ -0,0 +1 @@ +! diff --git a/flake.nix b/flake.nix index 77e74d3..6407308 100644 --- a/flake.nix +++ b/flake.nix @@ -27,13 +27,6 @@ # from the crate metadata (no hand-maintained duplicate here). cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); - # musl-targeting C compiler for the static release build: rusqlite's - # `bundled` SQLite is compiled from C, so a `--target …-musl` cargo build - # needs a musl `cc` for that C, plus a musl linker. `targetPrefix` is - # "x86_64-unknown-linux-musl-", so the wrapped binary is - # `${muslCC}/bin/x86_64-unknown-linux-musl-cc`. - muslCC = pkgs.pkgsCross.musl64.stdenv.cc; - # System build inputs are deliberately tiny — this is a pure-Rust TUI: # * libsqlite3-sys is built with the `bundled` feature, so SQLite is # compiled from vendored C. That needs a C compiler, which the @@ -75,20 +68,21 @@ # CLAUDE.md "Build hygiene"). cargo-sweep prunes them; run it # periodically between milestones. pkgs.cargo-sweep - # musl cc/linker for the static release build (see muslCC above). - muslCC + # Cross-compilation for the D1 release matrix. `cargo zigbuild` uses + # Zig's bundled clang + libc as one universal cross cc/linker for + # every non-macOS target (Linux musl x64/arm64, Windows gnu/gnullvm + # x64/arm64) — including the `cc`-crate compile of rusqlite's bundled + # SQLite C — with no per-target toolchain or SDK. It auto-discovers + # `zig` on PATH, so no extra env is needed. + pkgs.cargo-zigbuild + pkgs.zig ]; - # Point cargo's musl target at the musl cc for both the bundled-C - # compile (CC_, consumed by the `cc` crate) and the final link - # (CARGO_TARGET__LINKER). Harmless for normal glibc builds — - # these only take effect when building `--target x86_64-…-musl`. shellHook = '' - export CC_x86_64_unknown_linux_musl="${muslCC}/bin/${muslCC.targetPrefix}cc" - export CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER="${muslCC}/bin/${muslCC.targetPrefix}cc" echo "RDBMS Playground dev shell ($(uname -s))" - echo " rust: $(rustc --version | cut -d' ' -f1-2)" - echo " cargo: $(cargo --version | cut -d' ' -f1-2)" + echo " rust: $(rustc --version | cut -d' ' -f1-2)" + echo " cargo: $(cargo --version | cut -d' ' -f1-2)" + echo " zig: $(zig version 2>/dev/null || echo '?') (cargo-zigbuild cross targets)" ''; }; }); diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 68cd111..64a9490 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -7,9 +7,15 @@ channel = "1.95.0" # rustfmt + clippy back the `fmt`/`clippy` CI stages; no coverage or WASM # tooling is needed here (pure-Rust TUI). components = ["rustfmt", "clippy"] -# x86_64 musl for the static release binary (D2: single static binary, no -# runtime deps). The glibc default links the host/nix-store glibc dynamically -# and isn't portable; the musl target with crt-static produces a fully static -# binary. Further D1 matrix targets (aarch64, windows-gnu, …) are added as the -# release matrix expands, step by step. -targets = ["x86_64-unknown-linux-musl"] +# The non-macOS D1 release matrix, all cross-built from Linux x86_64 via +# `cargo zigbuild` (D1: cross-platform binaries; D2: single static binary). +# Linux uses musl + crt-static for fully static, portable binaries; Windows +# uses the gnu/gnullvm ABIs (Zig statically links libc, so the .exe is +# standalone). macOS is deferred — its arboard/AppKit link needs Apple's SDK, +# which a Linux runner can't supply cleanly (see docs/ci/adr ADR-ci-001). +targets = [ + "x86_64-unknown-linux-musl", + "aarch64-unknown-linux-musl", + "x86_64-pc-windows-gnu", + "aarch64-pc-windows-gnullvm", +] From 298475b326d560421039c53a65706262c1d9e301 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sat, 13 Jun 2026 12:14:49 +0000 Subject: [PATCH 20/50] ci: D1 release matrix over the four non-macOS targets release.yaml becomes test (once, host) -> build (matrix) over the four cargo-zigbuild targets; each matrix job uploads its binary + .sha256 to the shared release (idempotent create-or-get). Records the expansion in ADR-ci-001 (2026-06-13 amendment); macOS stays deferred. --- .gitea/workflows/release.yaml | 76 ++++++++++++++++++------------ docs/ci/adr/20260612-adr-ci-001.md | 34 ++++++++++++- docs/ci/adr/README.md | 2 +- 3 files changed, 79 insertions(+), 33 deletions(-) diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 98f7fe4..14110ea 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -1,13 +1,15 @@ -# Release: on a version tag, build the static Linux binary (D2) and publish it -# to a Gitea release with a checksum. Runs in the same prebuilt CI image as the -# gate, so the pinned toolchain + musl target/cc are already warm. +# Release: on a version tag, build the cross-platform binaries and publish them +# to a Gitea release with checksums. Runs in the prebuilt CI image, so the +# pinned toolchain + the release targets + cargo-zigbuild/zig are already warm. # -# Scope: x86_64-unknown-linux-musl only, for now. The rest of the D1 matrix -# (aarch64, macOS, Windows) and the D3 package-manager manifests layer on later, -# step by step. +# Matrix (D1, cross-built from Linux x86_64 via cargo-zigbuild): +# x86_64-unknown-linux-musl aarch64-unknown-linux-musl (static, D2) +# x86_64-pc-windows-gnu aarch64-pc-windows-gnullvm (standalone .exe) +# macOS is deferred — its arboard/AppKit link needs Apple's SDK (see ADR-ci-001). +# D3 package-manager manifests layer on later. # -# Tests run here before the build so a tag can never publish untested code, -# even one pointing at a commit that was never gated on a branch. +# Tests run once (host) before the matrix, so a tag can never publish untested +# code, even one pointing at a commit that was never gated on a branch. name: release on: push: @@ -15,46 +17,59 @@ on: - 'v*' jobs: - release: + test: runs-on: ci-public container: image: git.lazyeval.net/oli/rdbms-playground-ci:latest - env: - TARGET: x86_64-unknown-linux-musl steps: - uses: actions/checkout@v4 - - name: test run: nix develop -c cargo test --no-fail-fast - - name: build static binary - run: nix develop -c cargo build --release --target "$TARGET" + build: + needs: test + runs-on: ci-public + container: + image: git.lazyeval.net/oli/rdbms-playground-ci:latest + strategy: + fail-fast: false + matrix: + target: + - x86_64-unknown-linux-musl + - aarch64-unknown-linux-musl + - x86_64-pc-windows-gnu + - aarch64-pc-windows-gnullvm + steps: + - uses: actions/checkout@v4 - - name: package artifacts + - name: build + run: nix develop -c cargo zigbuild --release --target ${{ matrix.target }} + + - name: package + publish # Pin bash: the runner defaults scripted steps to dash, which rejects # `set -o pipefail`. bash is in the CI image. - shell: bash - run: | - set -euo pipefail - BIN="target/$TARGET/release/rdbms-playground" - ls -l "$BIN" - OUT="rdbms-playground-${{ github.ref_name }}-$TARGET" - mkdir -p dist - cp "$BIN" "dist/$OUT" - ( cd dist && sha256sum "$OUT" > "$OUT.sha256" ) - ls -l dist - - - name: publish gitea release + assets shell: bash env: - # Auto-provided by Gitea Actions; has repo write (release) scope. + TARGET: ${{ matrix.target }} + # GITEA_TOKEN is auto-provided with repo write (release) scope. TOKEN: ${{ secrets.GITEA_TOKEN }} API: ${{ github.server_url }}/api/v1 REPO: ${{ github.repository }} TAG: ${{ github.ref_name }} run: | set -euo pipefail - # Create the release for this tag; if it already exists, look it up. + + # Windows targets produce a .exe; the rest a bare binary. + case "$TARGET" in *windows*) EXT=.exe ;; *) EXT= ;; esac + BIN="target/$TARGET/release/rdbms-playground$EXT" + OUT="rdbms-playground-$TAG-$TARGET$EXT" + mkdir -p dist + cp "$BIN" "dist/$OUT" + ( cd dist && sha256sum "$OUT" > "$OUT.sha256" ) + ls -l dist + + # Create the release for this tag; if a sibling matrix job already + # created it, look it up instead (idempotent + race-tolerant). created=$(curl -sS -X POST "$API/repos/$REPO/releases" \ -H "Authorization: token $TOKEN" \ -H "Content-Type: application/json" \ @@ -66,6 +81,7 @@ jobs: | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{process.stdout.write(String(JSON.parse(s).id))})') fi echo "release id: $id" + for f in dist/*; do name=$(basename "$f") echo "uploading $name" @@ -73,4 +89,4 @@ jobs: -H "Authorization: token $TOKEN" \ -F "attachment=@$f" > /dev/null done - echo "published $TAG" + echo "published $TARGET assets for $TAG" diff --git a/docs/ci/adr/20260612-adr-ci-001.md b/docs/ci/adr/20260612-adr-ci-001.md index 4f34a05..8389a59 100644 --- a/docs/ci/adr/20260612-adr-ci-001.md +++ b/docs/ci/adr/20260612-adr-ci-001.md @@ -24,6 +24,35 @@ it rather than restating it. > previously forced website ADRs to be renumbered (see that namespace's > history note and ADR-0000 "Numbering discipline"). +## Amendment — 2026-06-13: D1 matrix expanded (non-macOS targets) + +The release now builds the **four non-macOS D1 targets**, all cross-compiled +from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + +libc as one universal cross cc/linker — including the `cc`-crate compile of +rusqlite's bundled SQLite C — added to the flake devShell, replacing the +single-target musl cc): + +- `x86_64-unknown-linux-musl`, `aarch64-unknown-linux-musl` — static (D2); +- `x86_64-pc-windows-gnu`, `aarch64-pc-windows-gnullvm` — standalone `.exe`. + +`release.yaml` became a **`test` (once, host) → `build` (matrix over the four +targets)** workflow; each matrix job uploads its artifact + `.sha256` to the +shared release (idempotent create-or-get). + +**Windows link fix:** Rust's std links `-lsynchronization` (WaitOnAddress +thread-parking), an import lib that rust-overlay's toolchain doesn't ship and +Zig's mingw lacks. Those symbols are forwarded by `kernel32` (already linked), +so an **empty stub** `libsynchronization.a` (committed at `ci/winstub/`, wired +via `.cargo/config.toml` for the Windows targets only) satisfies the linker. +Verified locally: all four build; the Linux binaries are statically linked; the +Windows artifacts are valid PE32+ (x86-64 / Aarch64) — not yet runtime +smoke-tested on Windows. + +**macOS stays deferred** (see Deferred): `arboard`→AppKit needs Apple's SDK, +which a Linux runner can't supply cleanly — and the CI image is *public*, so the +SDK can't be baked in even if the licensing grey area were accepted. macOS is +its own step (osxcross + a private SDK, or a real Mac runner). + ## Context The project is near feature-complete and needs CI (`requirements.md` **TT5**; @@ -162,8 +191,9 @@ iteration ships **Linux x86_64 only**; the rest is deferred (below). ## Deferred / out of scope (tracked, step by step) -- **D1 matrix:** aarch64, macOS, Windows builds (cross toolchains; macOS is the - hard part on a Linux runner). +- **D1 matrix:** **macOS only** now (x86_64 + aarch64). The four non-macOS + targets shipped via cargo-zigbuild (see the 2026-06-13 amendment); macOS needs + Apple's SDK (osxcross + private SDK, or a Mac runner). - **D3 packaging:** Homebrew / Scoop / winget / `cargo-binstall` manifests (and binstall-friendly asset naming/archives). - **Tier 4 (PTY E2E):** still unwired (`requirements.md` **TT4**); the gate runs diff --git a/docs/ci/adr/README.md b/docs/ci/adr/README.md index 38aa095..0a2339c 100644 --- a/docs/ci/adr/README.md +++ b/docs/ci/adr/README.md @@ -18,5 +18,5 @@ here too). ## Index -- [ADR-ci-001 — CI + release pipeline on Gitea Actions](20260612-adr-ci-001.md) — **Accepted 2026-06-12** (implemented the same day on the `ci` branch). Establishes the CI/release pipeline on the self-hosted Gitea instance's Actions runner (`ci-public`). **Runner model** (established by a throwaway probe): jobs execute *inside* a container (`catthehacker/ubuntu:act-22.04` by default), as root, so the runner host's nix is **not** reachable from steps. **Toolchain delivery:** a **baked CI image** — `node:22-bookworm-slim` (satisfies the act_runner job-container contract: `/bin/sleep` keep-alive, `bash`, `node` for JS actions; a bare `nixos/nix` image lacks these and won't start) **+ single-user nix + the flake's devShell pre-warmed** — built by `build-ci-image.yaml` via DinD and pushed to the Gitea container registry as a **public** package, so CI runs `nix develop -c …` against the **same pinned toolchain as dev** (the flake, ADR-ci-002) with a warm store (~1.4 s to a ready toolchain). **Gate** (`ci.yaml`): `clippy -D warnings` + `cargo test` inside that image on branch pushes + PRs; **fmt deliberately not gated** (the tree isn't stock-rustfmt-clean — user decision, revisit on `main`; see ADR-ci-002). **Release** (`release.yaml`): on a `v*` tag, runs the tests, builds the **static `x86_64-unknown-linux-musl` binary** (D2: single static binary, no runtime deps — the glibc/nix build is non-portable), strips it, and publishes it + a `.sha256` to a Gitea release via the API and the auto-provided `GITEA_TOKEN`. **Triggers:** gate + image-build are scoped to **branch** pushes (`branches: ['**']`) so a release tag doesn't spuriously re-run them; the image-build additionally path-filters to its inputs (Dockerfile/flake/toolchain); the release owns tags. **Auth:** a dedicated PAT (`REGISTRY_USERNAME`/`REGISTRY_TOKEN` secrets) pushes the image; the auto `GITEA_TOKEN` publishes releases. **Scope this iteration:** Linux x86_64 only — the rest of the D1 matrix (aarch64/macOS/Windows), D3 package-manager manifests, CI-speed dependency caching, and the website's static→Cloudflare deploy are deferred, to be added step by step. Verified live: probe → runner facts; image built + checked locally; gate green (**2424 tests**); release exercised end-to-end (`v0.0.0-citest2` published with binary + checksum). Builds on **ADR-ci-002** (the nix flake, relocated here from main's ADR-0049 to avoid exactly this cross-branch collision). +- [ADR-ci-001 — CI + release pipeline on Gitea Actions](20260612-adr-ci-001.md) — **Accepted 2026-06-12** (implemented the same day on the `ci` branch). Establishes the CI/release pipeline on the self-hosted Gitea instance's Actions runner (`ci-public`). **Runner model** (established by a throwaway probe): jobs execute *inside* a container (`catthehacker/ubuntu:act-22.04` by default), as root, so the runner host's nix is **not** reachable from steps. **Toolchain delivery:** a **baked CI image** — `node:22-bookworm-slim` (satisfies the act_runner job-container contract: `/bin/sleep` keep-alive, `bash`, `node` for JS actions; a bare `nixos/nix` image lacks these and won't start) **+ single-user nix + the flake's devShell pre-warmed** — built by `build-ci-image.yaml` via DinD and pushed to the Gitea container registry as a **public** package, so CI runs `nix develop -c …` against the **same pinned toolchain as dev** (the flake, ADR-ci-002) with a warm store (~1.4 s to a ready toolchain). **Gate** (`ci.yaml`): `clippy -D warnings` + `cargo test` inside that image on branch pushes + PRs; **fmt deliberately not gated** (the tree isn't stock-rustfmt-clean — user decision, revisit on `main`; see ADR-ci-002). **Release** (`release.yaml`): on a `v*` tag, runs the tests, builds the **static `x86_64-unknown-linux-musl` binary** (D2: single static binary, no runtime deps — the glibc/nix build is non-portable), strips it, and publishes it + a `.sha256` to a Gitea release via the API and the auto-provided `GITEA_TOKEN`. **Triggers:** gate + image-build are scoped to **branch** pushes (`branches: ['**']`) so a release tag doesn't spuriously re-run them; the image-build additionally path-filters to its inputs (Dockerfile/flake/toolchain); the release owns tags. **Auth:** a dedicated PAT (`REGISTRY_USERNAME`/`REGISTRY_TOKEN` secrets) pushes the image; the auto `GITEA_TOKEN` publishes releases. **Scope:** the **four non-macOS D1 targets** (Linux + Windows × x86_64/aarch64) cross-built from Linux via cargo-zigbuild (2026-06-13 amendment); macOS, D3 package-manager manifests, CI-speed dependency caching, and the website's static→Cloudflare deploy remain deferred, added step by step. Verified live: probe → runner facts; image built + checked locally; gate green (**2424 tests**); release exercised end-to-end (`v0.0.0-citest2` published with binary + checksum). Builds on **ADR-ci-002** (the nix flake, relocated here from main's ADR-0049 to avoid exactly this cross-branch collision). - [ADR-ci-002 — Nix flake for a reproducible dev + build environment](20260612-adr-ci-002.md) — **Accepted 2026-06-12** (relocated from main's **ADR-0049** on the same day — content unchanged — to keep CI/dev-env decisions out of `main`'s integer sequence). The single, version-pinned declaration of the **dev *and* build toolchain** so CI never relies on whatever Rust is on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI). Root **Nix flake** with two outputs: **`devShells.default`** (pinned **Rust 1.95.0** via `rust-toolchain.toml` + `rust-overlay`, `cargo-sweep`, and the musl cc for the static release build) and **`packages.default`** (`rustPlatform.buildRustPackage` from the committed `Cargo.lock`; `doCheck = false`). Exact-pin (not floating `stable`) so `nix flake update` can't surprise-bump clippy past the `-D warnings` gate. System inputs near-empty by design (`libsqlite3-sys bundled` → stdenv cc only; `arboard`→`x11rb` pure-Rust). `.envrc` (`use flake`) for direnv parity. Verified through the flake: `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest**. Consumed by **ADR-ci-001** (the pipeline). Alternatives rejected: dev-shell-only; a standard `rust:1.95` CI image (a second toolchain definition = drift); `rustup` on the build host (non-reproducible). From eceedc19b74e202964c63d4556528ff9bf2b226d Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sat, 13 Jun 2026 12:18:37 +0000 Subject: [PATCH 21/50] feat(ui): context- and state-aware bottom keybinding strip (#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per ADR-0051 (closing issue #27): the bottom status line is now a keystrokes-only, state-selected strip. A pure status_bar_bindings() picks the binding set by priority (first match wins): sidebar focus → Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input completion live → Tab/Shift-Tab cycle · Esc cancel · Enter run history nav → ↑↓ browse · Esc clear · Enter run editing → Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run default (empty) → Ctrl-O sidebar · Tab complete · ↑ history · Enter run The editing state surfaces the #29 readline keys (ADR-0049's deferred advertisement). Typed-command words (mode advanced/simple, the ':' one-shot) and Ctrl-C quit leave the strip; simple mode's empty-input hint gains a '`mode advanced` for SQL' pointer (advanced mode shows none — a switcher knows the way back, and help covers it). Mechanism: status_bar_bindings + a thin renderer (unit-testable); App::is_browsing_history() exposes the private history_cursor; the mode pointer lives in resolve_hint_lines. Catalog: 12 new shortcut labels + panel.hint_mode_advanced (en-US.yaml + keys.rs, validator 1:1), 5 dead strip strings removed. Forks user-chosen: editing state shows #29 keys; quit omitted; no width-drop machinery (longest strip ~65 cols fits; a width-budget test keeps it lean). Modal-aware strip is OOS (pre-existing). Tests: 9 Tier-1 unit (per-state key sets, width budget, mode pointer), 1 Tier-3 rewritten, 15 full-panel snapshots re-accepted (reviewed). 2467 pass / 0 fail / 0 skip, clippy clean. --- ...51-context-state-aware-keybinding-strip.md | 147 ++++++++++ docs/adr/README.md | 1 + src/app.rs | 11 + src/friendly/keys.rs | 18 +- src/friendly/strings/en-US.yaml | 26 +- ...und__ui__tests__default_advanced_dark.snap | 4 +- ...round__ui__tests__default_simple_dark.snap | 8 +- ...ound__ui__tests__default_simple_light.snap | 8 +- ..._demo_badge_and_caption_stacked_90x26.snap | 5 +- ...__tests__demo_badge_enter_light_90x26.snap | 5 +- ..._ui__tests__demo_badge_tab_dark_90x26.snap | 5 +- ...d__ui__tests__demo_caption_dark_90x26.snap | 5 +- ...ui__tests__demo_caption_wrapped_90x26.snap | 5 +- ...hlighted_input_all_token_classes_dark.snap | 4 +- ...av_overlay_relationships_focused_dark.snap | 6 +- ...nd__ui__tests__one_shot_advanced_dark.snap | 4 +- ..._ui__tests__populated_with_table_dark.snap | 8 +- ...ui__tests__rebuild_confirm_modal_dark.snap | 8 +- ...__ui__tests__relationships_panel_dark.snap | 8 +- ...ground__ui__tests__two_row_input_dark.snap | 4 +- src/ui.rs | 258 ++++++++++++++++-- tests/it/walking_skeleton.rs | 31 ++- 22 files changed, 493 insertions(+), 86 deletions(-) create mode 100644 docs/adr/0051-context-state-aware-keybinding-strip.md diff --git a/docs/adr/0051-context-state-aware-keybinding-strip.md b/docs/adr/0051-context-state-aware-keybinding-strip.md new file mode 100644 index 0000000..bc50aab --- /dev/null +++ b/docs/adr/0051-context-state-aware-keybinding-strip.md @@ -0,0 +1,147 @@ +# ADR-0051: Bottom keybinding strip — context- and state-aware + +## Status + +**Accepted 2026-06-13 (issue #27).** Closes Gitea **#27**. All forks +below were escalated to the user and user-chosen before any code was +written; to be implemented test-first. Builds on ADR-0046 (nav focus), +ADR-0003 (input modes), ADR-0049 (the #29 readline keys this strip now +advertises), and ADR-0022 (the Tab-completion memo). + +## Context + +The bottom status line (`render_status_bar`, `ui.rs`) mixed keystrokes +with typed-command words: `Enter submit · : advanced once · mode +advanced switch · Ctrl-C quit`. That is redundant — the hint panel +already teaches `help` and `Enter` when the input is empty — and it is +static apart from a three-way mode branch, so it never reflects what the +user can actually do *right now* (navigating the sidebar, cycling a +completion, browsing history, editing a line). + +Issue #27: repurpose the line as a **keybindings-only** strip that is +**context-sensitive to nav focus** and **state-aware of the current +transient interaction**, and move mode discovery into the empty-input +hint. + +## Decision + +### 1. The strip is keybindings-only and state-selected + +A single pure function `status_bar_bindings(app) -> Vec` +computes the strip from app state; `render_status_bar` is a thin +renderer over it (so the binding sets are unit-testable without a +Frame). `history_cursor` is private to `App`, so a small +`pub fn is_browsing_history(&self) -> bool` accessor exposes the +history-navigation predicate; `mode` / `nav_focus` / `last_completion` +are already `pub` and `effective_mode()` is a `pub` method. The state is +chosen by **priority — first match wins**: + +| Priority | State (predicate) | Strip | +|---|---|---| +| 1 | **Sidebar focus** (`nav_focus` in a sidebar) | `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input` | +| 2 | **Completion memo live** (`last_completion.is_some()`) | `Tab/Shift-Tab cycle · Esc cancel · Enter run` | +| 3 | **History navigation** (`history_cursor.is_some()`) | `↑↓ browse · Esc clear · Enter run` | +| 4 | **Editing** (Input focus, input non-empty) | `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` | +| 5 | **Default** (Input focus, input empty) | `Ctrl-O sidebar · Tab complete · ↑ history · Enter run` | + +Priority order matters: a completion memo or history navigation is a +non-empty-input situation, so states 2 and 3 must precede state 4. The +sidebar overlay occludes the input entirely (ADR-0046), so state 1 wins +outright. + +### 2. Mode discovery moves off the strip, into the empty-input hint + +The typed-command advertisements (`mode advanced` / `mode simple` +switch, the `:` one-shot) leave the strip — they are not keystrokes. +Mode discovery moves to the **empty-input hint** (`resolve_hint_lines`'s +`(None, None)` arm), in **simple mode only**: + +- **Simple:** `… · \`mode advanced\` for SQL` +- **Advanced (persistent):** no pointer. + +The pointer omits the verb "type" — the surrounding prompt already +implies it (we don't say "type `help`" either). Advanced mode shows +**no** pointer (user decision, post-trial): a user who switched into +advanced mode knows how they got there, and `help` covers the way back — +a "switch back" pointer only reads naturally in the moment right after +switching, so it earns its space poorly. + +The one-shot advanced state's old `Backspace cancel one-shot` label is +**subsumed** by the editing state (the input is non-empty in one-shot; +Esc-clear and Backspace both cancel it). No behaviour is lost — only the +dedicated label. + +### 3. Width: no drop machinery; a budget test instead + +The longest strip (state 4, editing) is ≈ **65 display columns**, which +fits every supported width (90-col screencasts, 80-col terminals) with +margin — so the priority-drop / abbreviation machinery considered would +never trigger and is not built (user-confirmed). Ratatui's existing +**clip-at-edge** is the trivial fallback for pathologically narrow +(< 65-col) terminals. Instead, a **width-budget unit test** pins the +longest rendered strip within an 80-col budget, keeping the strip lean +*by construction* — a future over-long strip fails the test rather than +silently clipping in a cast. + +## Forks (all user-chosen) + +- **Editing state — yes:** when the input has text, surface the #29 + readline keys (Esc-clear, Ctrl-A/E, Ctrl-W); the strip stays lean + (nav/complete/history) when empty. (vs not advertising the #29 keys.) +- **`Ctrl-C quit` — omitted** from the strip (vs always shown): quit is + a near-universal convention; omitting it keeps the strips lean and + matches the issue's sketch. +- **Width — budget test, no drop logic** (vs graceful priority-drop / + abbreviation): the strips fit at supported widths, so the machinery + would be dead weight (user's own observation). + +## Consequences + +- The strip now teaches the keys for the *current* situation; learners + see `Tab/Shift-Tab cycle` exactly while cycling, the editing keys + exactly while editing, etc. +- The #29 readline keys (ADR-0049) gain their on-screen advertisement, + closing that ADR's deferred item. +- 15 existing full-panel insta snapshots churn (the bottom line — and, + on empty-input views, the hint pointer — changes in every one, + including the rebuild-confirm modal view, whose modal box is itself + unchanged); each diff was reviewed, not blind-accepted. +- `requirements.md` is unaffected (an ADR-tracked UI refinement); the + change is cross-referenced from the commit + this ADR. + +## Tests + +- **Tier-1 (`ui.rs` unit):** `status_bar_bindings` returns the expected + key set for each of the five states (sidebar, completion-live, + history-nav, editing, default) — the completion/history states driven + through real key events (`update`) so the predicate transitions are + exercised, the others by setting `App` fields; plus the width-budget + assertion across states. (Per-state coverage is these unit tests, not + snapshots — a one-line strip is asserted more precisely by its exact + key list than by a full-panel snapshot.) +- **Tier-1:** the empty-input hint appends the correct mode pointer in + Simple vs Advanced, and does **not** append it when an ambient hint is + showing (non-empty input). +- **Tier-3 (`walking_skeleton`):** the old `status_bar_lists_quit_and_ + submit_in_all_modes` (which asserted the pre-ADR strip) is rewritten + + renamed to assert the keystroke-only, state-aware strip end-to-end + through the real render path (default → editing transition). +- **Tier-2 (insta):** the 15 full-panel snapshots re-accepted (each diff + reviewed — strip line and/or hint pointer only). + +## Out of scope + +- **Modal-aware strip.** While a modal is open (load picker, rebuild / + undo confirm) it owns the keyboard and carries its own in-box key + hints; the bottom strip under a modal computes from input state + exactly as it does today (modals render *over* the status bar). This + issue does not redesign the modal case — pre-existing behaviour, + unchanged and not worsened. +- A persistent/togglable help overlay listing *all* keys (the strip is a + contextual subset, not a cheatsheet). +- Per-key colour theming beyond the existing key/label/separator styles. +- Localisation of the new label strings beyond adding catalog entries. +- The remaining I1b kill keys' (Ctrl-K/Ctrl-U) advertisement — the + editing strip shows the highest-value subset (Esc/Ctrl-A/E/Ctrl-W) to + stay within the width budget; Ctrl-K/U remain unadvertised muscle + memory. diff --git a/docs/adr/README.md b/docs/adr/README.md index 97253f2..2d81708 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -56,3 +56,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0048 — `seed` fake-data generation command](0048-seed-fake-data-generation.md) — **Accepted 2026-06-11; Phase 1 + Phase 2 implemented 2026-06-11** (Phase 1 commits `202e25a`→`fbd219b`; design settled with the user across an extended fork dialogue, hardened by a pre-build `/runda` pass (six blockers folded in), a post-implementation `/runda` pass (eight gaps closed — FK/shortid determinism so **D4 holds with no exceptions**, plus six untested ADR decisions), and a Phase-2 pre-build `/runda` pass (which caught the no-date-literal-token reality → the D2 quoted-dates amendment), and a post-implementation `/runda` pass (which added a friendly error for a bounded override on a UNIQUE column — see D2); **2400 tests pass, clippy clean**). Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1**. **Phase 1 shipped:** whole-row `seed
[count] [--seed ]` with realistic name-aware generation (the `fake` crate + a type-gated heuristic catalogue, table-context name disambiguation, hand-rolled `product` generator, bounded dates), identifier + constraint uniqueness incl. junction distinct-combos, FK sampling from existing parent rows (empty-parent error), `IN`-CHECK derivation + complex-CHECK advisory, a required-column block guard, `--seed` reproducibility (serial/FK/shortid all deterministic), undo as one batch step, replay as a data write, a capped auto-show preview, the enum/CHECK advisory, and an O(N) single-transaction insert path. **Phase 2 shipped (2026-06-11):** the `set` override clause (D2 — fixed value / pick-list / `as ` / `between` range, **quoted** dates per the D2 amendment, type-aware, override drops the column from the advisory) and the `
.` column-fill form (D1 form 2 — an UPDATE over existing rows, refusing PK/autogen targets, empty-table no-op, FK/unique-respecting, one undo step), with the new `KNOWN_GENERATORS` vocabulary (D9), a range `Generator`, full completion/highlight (`HighlightClass::Function`)/validity (`IdentSource::Generators`)/help/pedagogy wiring, and the D13 advisory's Phase-2/3 wording. Further SD2 increments (custom generators, NULL injection, multi-locale, recursive auto-seed) out of scope. Closes `requirements.md` **SD1** and the core of **SD2**; closes the `seed` half of **A1** (the other being `hint`/**H2**). A dedicated `seed` command (own AST variant + `do_seed` executor, **both modes**) generating **realistic, name-aware** fake data. Two forms: **`seed
[count]`** (new rows, default **20**, capped) and **`seed
.`** (fill a column on existing rows, an UPDATE). Generation adds the **`fake` crate** (v5, English) driven by a **type-gated, token-matched name-heuristic catalogue** (~30 patterns, documented false-positive guards), with **table-context** disambiguating the `name`/`title` family (`products.name`→product, `users.name`→person, `vendors.name`→company), a **hand-rolled `product` generator** (`fake` has no commerce module), **bounded dates** (`date`/`timestamp`/`dob`/`*_at` recognised, recent windows — never "all of history"), the **identifier family** (`id`/`code`/`ref`/`number`, non-FK/non-PK) → **unique sequential**, and **enum-ish names** (`role`/`status`/`type`/…) left generic + a **post-seed Hint advisory** pointing at `set … in (…)`. A **`set` override clause** — `= value` / `in (a,b,c)` / `as ` / `between a and b` (numeric **and** date), reusing ADR-0026 operators — answers the heuristic-miss case. **`--seed `** makes runs reproducible (and enables exact-value tests). **FK** columns sampled uniformly from existing parent rows (**empty parent → friendly error**, no recursion v1); **junction/compound-PK** tables seeded with **distinct combinations**, capped + noted (SD1). A **required-column block guard** refuses rather than NULL-violate a `NOT NULL` column it can't fill (e.g. `NOT NULL blob`). Full ambient wiring (completion incl. a new generator-name vocabulary highlighted as `tok_function`, hints, `help seed`, ADR-0042 near-miss matrix, ADR-0027 validity); **no DSL→SQL teaching echo** (seed is a utility command, not a SQL twin). Honours **X5** — `do_seed` reuses insert/update *mechanics as helpers*, not by emitting `Command::Insert`. Implementation phased: (1) core whole-row seed → (2) `set` overrides → (3) column-fill. Deferred (future SD2): recursive auto-seed, NULL injection, multi-locale, user-defined custom generators, full per-column report. **Amendment 1, 2026-06-12** (issues #33/#34): two additive D7 catalogue rules — **year-as-int** (`year`/`*_year`/`published`/`founded` → a bounded `int` year, 1950–2025, or the `dob`-style birth window 1945–2007 for `birth`/`born`/`dob`; fixes nonsense like `9419`; `int`-gated, after the quantity rule so `year_count` stays a count; two new `YearRecent`/`YearBirth` generators, *not* added to the D9 vocabulary) and **conventional choice sets** (`priority`/`prio`, `severity`, `rating`/`stars` → type-gated built-in `PickFrom` value sets reusing the existing generator; `priority` leaves `ENUM_TOKENS`). `status` is **deliberately excluded** (user-confirmed — values too domain-specific; keeps the D12 "don't guess" + advisory); a user `IN`-CHECK still wins. Website `seed` cast re-record tracked on the `website` branch - [ADR-0049 — Input-field readline keymap: Esc-clear + Ctrl-A/E/W/K/U (I1b)](0049-input-field-readline-keymap.md) — **Accepted + implemented 2026-06-12 (issue #29)**, closes Gitea **#29** and the deferred **I1b** readline requirement. **Amends ADR-0046**, which listed "readline shortcuts (I1b)" as out-of-scope — that item is now in scope and decided here; orthogonal to ADR-0003's input-*mode* model and extends the I1a single-line cursor editing already shipped. Binds, in the input field (non-modal, non-nav, both modes): **`Esc`** clears a partly-typed command (empty buffer, cursor→0, scroll→0); **`Ctrl-A`/`Ctrl-E`** alias Home/End (line start/end); **`Ctrl-W`** deletes the previous word (readline-style — eats trailing whitespace then the preceding non-whitespace run, UTF-8-safe on char boundaries, only back to the cursor); **`Ctrl-K`** kills to end of line; **`Ctrl-U`** kills to start. **Esc precedence:** a live Tab-completion memo still wins (Esc undoes the completion first, ADR-0022; Esc clears only when no memo) — Esc-once backs out the completion, Esc-again clears. Forks all user-chosen: **single-Esc-clears** (not double-Esc — discoverable over accident-proof; an unsubmitted draft can be lost, a submitted line is always in history); the **full I1b set** (not just the issue's literal Ctrl-A/E + Esc); a **new ADR** (not an ADR-0046 amendment / no-ADR). Cursor-only keys (Ctrl-A/E) leave history navigation intact like Home/End; buffer-mutating keys (Esc-clear, Ctrl-W/K/U) end it like Backspace. Helpers `clear_input`/`delete_prev_word`/`kill_to_end`/`kill_to_start` in `src/app.rs`; **22 new Tier-1 tests, 2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. OOS: on-screen keybinding hints (issue #27 owns surfacing per-focus keybindings in the bottom status line — this ADR makes the keys *work*, #27 makes them *discoverable*); demo-mode badges for the new chords (ADR-0047 follow-up — Esc already badges `[ESC]`, the glyph-less Ctrl-chords are flagged but not added); multi-line input (I1); word-wise cursor motion (Alt-B/F) / transpose / yank - [ADR-0050 — Incidental-DDL confirmations omit relationship info (structure-only)](0050-incidental-ddl-confirmations-omit-relationships.md) — **Accepted + implemented 2026-06-12 (issue #28)**, closes Gitea **#28**. **Supersedes** the incidental-DDL clause of **ADR-0044 §1** and the relationship-block half of **ADR-0016 §5**. Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/`rename`/`change column`, `add`/`drop index`) now render **structure only** — header + column box + `Indexes:` + constraints — with **no `References:` / `Referenced by:` block** (neither prose nor diagram), even when the table carries relationships the user did not touch. Rationale (owner): a confirmation echo reports the change just made, not untouched relationships; ADR-0044's terse prose was the lesser of "prose vs diagram", but the right answer for these surfaces is **neither**. **Relationship-subject surfaces are unchanged** — `show table`, `add`/`drop relationship`, `show relationship` still render ADR-0044 diagrams; relationships appear only when the user asks for (`show table`) or acts on (`add`/`drop relationship`) one, and are one `show table ` away — **no information lost**. Forks both user-chosen: **scope = all incidental DDL** (not just `add column` — the rationale is uniform, the mental model clean, and it's the simpler edit) and **delete the prose renderer** (not retain it dormant — no dead code). **Mechanism:** the `handle_dsl_success` `matches!` routing is unchanged (relationship-subject → diagrams; else → `render_structure`); the change is one line inside `render_structure` (`output_render.rs` — drop the relationship-block call) since all its callers are incidental DDL, plus deletion of the orphaned `relationship_prose_lines` + `cols_disp` helpers. The prose format survives in ADR-0016 §5 + git history for a future OOS-7 always-prose setting. **Tests:** the prose-presence unit test + its snapshot removed; a new unit test asserts `render_structure` on a description carrying **both** inbound and outbound relationships emits the box but no prose; the misnamed `add_relationship_flow_shows_inbound_section_on_parent` integration test (which sent an `AddColumn`) inverted + renamed to assert the add-column echo omits the prose; the diagram tests (`show table`, `add relationship`) unaffected. **2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. `requirements.md` unaffected (ADR-tracked refinement of a decided area, like ADR-0044 itself) +- [ADR-0051 — Bottom keybinding strip: context- and state-aware](0051-context-state-aware-keybinding-strip.md) — **Accepted + implemented 2026-06-13 (issue #27)**, closes Gitea **#27**. Repurposes the bottom status line into a **keystrokes-only, state-selected** strip (builds on ADR-0046 nav focus, ADR-0003 modes, ADR-0049 the #29 readline keys it now advertises, ADR-0022 the completion memo). A pure `status_bar_bindings(app) -> Vec<(key,label)>` chooses the strip by **priority, first match wins**: (1) **sidebar focus** → `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input`; (2) **completion memo live** (`last_completion`) → `Tab/Shift-Tab cycle · Esc cancel · Enter run`; (3) **history navigation** (new `App::is_browsing_history()` exposing the private `history_cursor`) → `↑↓ browse · Esc clear · Enter run`; (4) **editing** (input non-empty) → `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` (surfaces the #29 keys, closing ADR-0049's deferred advertisement); (5) **default** (empty) → `Ctrl-O sidebar · Tab complete · ↑ history · Enter run`. Priority is correct because Up clears the completion memo and Tab cancels history nav, so states 2/3 never co-occur, and the five are exhaustive for Input focus. **Typed-command words leave the strip** (`mode advanced`/`mode simple` switch, `:` one-shot) and **mode discovery moves to the empty-input hint** (`resolve_hint_lines`), **simple mode only**: `\`mode advanced\` for SQL` (the verb "type" omitted — the prompt implies it; advanced mode shows **no** pointer per a post-trial user decision — a switcher knows how they got there and `help` covers the way back). The one-shot's old `Backspace cancel one-shot` label is subsumed by the editing state (behaviour intact). Forks all user-chosen: **editing state shows the #29 keys** (vs unadvertised); **`Ctrl-C quit` omitted** from the strip (vs always shown); **no width-drop machinery** — the longest strip (~65 cols) fits all supported widths, so a **width-budget unit test** keeps it lean by construction instead (the user's own observation). Catalog: 12 new `shortcut.*` labels + the `panel.hint_mode_advanced` string added to `en-US.yaml`+`keys.rs` (validator-checked 1:1), 5 now-dead strip strings removed. **Modal-aware strip is OOS** (pre-existing: a modal owns the keyboard and carries its own hints; the strip under it is unchanged-in-kind, not worsened). Tests: 9 Tier-1 unit (per-state key sets — completion/history driven through real key events; width budget; mode-pointer presence/absence), 1 Tier-3 rewritten (`status_bar_is_keystroke_only_and_state_aware`), 15 full-panel snapshots re-accepted (reviewed — strip/hint only). **2467 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: modal-aware strip; a full-key cheatsheet overlay; Ctrl-K/U advertisement (editing strip shows the highest-value subset within the width budget) diff --git a/src/app.rs b/src/app.rs index e414642..8efcb9f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -646,6 +646,17 @@ impl App { } } + /// Whether the user is currently browsing a recalled history entry + /// (Up/Down navigation, unedited). Exposes the private + /// `history_cursor` predicate so the context-aware status strip + /// (ADR-0051) can select its history-navigation state. Editing the + /// recalled line ends navigation (`cancel_history_navigation`), so + /// this is `false` again the moment the user types. + #[must_use] + pub const fn is_browsing_history(&self) -> bool { + self.history_cursor.is_some() + } + /// The input view the **live-feedback** walkers (completion, ambient /// hint, validity verdict, highlight overlays) should see, plus the /// byte offset stripped from the front and the cursor mapped into the diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index f5244b6..a6c6ae8 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -446,6 +446,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("undo.redo_failed", &["error"]), // ---- Status bar + panels ---- ("panel.hint_empty", &[]), + ("panel.hint_mode_advanced", &[]), ("panel.hint_title", &[]), ("panel.output_title", &[]), ("panel.relationships_empty", &[]), @@ -462,18 +463,25 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("save.title_as", &[]), ("save.title_save", &[]), // ---- Shortcut hint labels ---- - ("shortcut.advanced_once", &[]), ("shortcut.back_to_list", &[]), + ("shortcut.browse", &[]), ("shortcut.browse_path", &[]), ("shortcut.cancel", &[]), - ("shortcut.cancel_one_shot", &[]), + ("shortcut.clear", &[]), + ("shortcut.complete", &[]), ("shortcut.confirm", &[]), + ("shortcut.cycle", &[]), + ("shortcut.del_word", &[]), + ("shortcut.history", &[]), + ("shortcut.home_end", &[]), ("shortcut.load", &[]), + ("shortcut.nav", &[]), + ("shortcut.next_pane", &[]), ("shortcut.no", &[]), - ("shortcut.quit", &[]), + ("shortcut.run", &[]), + ("shortcut.scroll", &[]), ("shortcut.select", &[]), - ("shortcut.submit", &[]), - ("shortcut.switch", &[]), + ("shortcut.to_input", &[]), ("shortcut.yes", &[]), // ---- mode / messages banners ---- ("messages.set_short", &[]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 40930df..c8e45f1 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -883,14 +883,21 @@ panel: relationships_title: "Relationships" relationships_empty: "(none)" hint_empty: "Type a command — press Tab for options, `help` for a list" + # Mode-discovery pointer appended to the empty-input hint in SIMPLE + # mode (ADR-0051): the `mode advanced` switch left the keybinding + # strip, so the hint advertises it. Leading separator continues the + # prompt line. Advanced mode shows no pointer — users know how they + # got there, and `help` covers the way back. + hint_mode_advanced: " · `mode advanced` for SQL" # Panel titles for the output and hint panels (rendered inside # the rounded border, hence the leading/trailing space). output_title: "Output" hint_title: "Hint" # ---- Shortcut hints (paired with key names in the bottom bar) ------- +# The bottom strip is keystrokes-only and state-aware (ADR-0051). Labels +# pair with a key name in the renderer (e.g. `Enter` + `run`). shortcut: - submit: "submit" confirm: "confirm" cancel: "cancel" yes: "Yes" @@ -899,10 +906,19 @@ shortcut: select: "select" browse_path: "browse path" back_to_list: "back to list" - switch: "switch" - advanced_once: "advanced once" - cancel_one_shot: "cancel one-shot" - quit: "quit" + # Status-strip labels (ADR-0051, issue #27). + run: "run" + nav: "sidebar" + next_pane: "next pane" + scroll: "scroll" + to_input: "input" + cycle: "cycle" + browse: "browse" + clear: "clear" + complete: "complete" + history: "history" + home_end: "home/end" + del_word: "del word" # ---- mode / messages banners (app-level commands) ------------------- mode: diff --git a/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap b/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap index 46a7503..0505791 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 2326 +assertion_line: 2836 expression: snapshot --- ╭ Output ──────────────────────────────────────────────────────────────────────╮ @@ -26,4 +26,4 @@ expression: snapshot │ │ ╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Enter submit · mode simple switch · Ctrl-C quit +Ctrl-O sidebar · Tab complete · ↑ history · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap b/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap index c49a798..e192380 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 2309 +assertion_line: 2819 expression: snapshot --- ╭ Output ──────────────────────────────────────────────────────────────────────╮ @@ -22,8 +22,8 @@ expression: snapshot │ │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭ Hint ────────────────────────────────────────────────────────────────────────╮ -│Type a command — press Tab for options, `help` for a list │ -│ │ +│Type a command — press Tab for options, `help` for a list · `mode advanced` │ +│for SQL │ ╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Enter submit · : advanced once · mode advanced switch · Ctrl-C quit +Ctrl-O sidebar · Tab complete · ↑ history · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap b/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap index 4a41ef7..a86c7b3 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 2317 +assertion_line: 2827 expression: snapshot --- ╭ Output ──────────────────────────────────────────────────────────────────────╮ @@ -22,8 +22,8 @@ expression: snapshot │ │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭ Hint ────────────────────────────────────────────────────────────────────────╮ -│Type a command — press Tab for options, `help` for a list │ -│ │ +│Type a command — press Tab for options, `help` for a list · `mode advanced` │ +│for SQL │ ╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Enter submit · : advanced once · mode advanced switch · Ctrl-C quit +Ctrl-O sidebar · Tab complete · ↑ history · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap index 7f82289..f429374 100644 --- a/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap +++ b/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 3442 expression: snapshot --- ╭ Output ────────────────────────────────────────────────────────────────────────────────╮ @@ -23,8 +24,8 @@ expression: snapshot │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ -│Type a command — press Tab for options, `help` for a list │ +│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Enter submit · : advanced once · mode advanced switch · Ctrl-C quit +Ctrl-O sidebar · Tab complete · ↑ history · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap index 7120bbd..86f0ce5 100644 --- a/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap +++ b/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 3388 expression: snapshot --- ╭ Output ────────────────────────────────────────────────────────────────────────────────╮ @@ -23,8 +24,8 @@ expression: snapshot │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ -│Type a command — press Tab for options, `help` for a list │ +│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Enter submit · : advanced once · mode advanced switch · Ctrl-C quit +Ctrl-O sidebar · Tab complete · ↑ history · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap index d6358c1..e9b9e4a 100644 --- a/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap +++ b/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 3378 expression: snapshot --- ╭ Output ────────────────────────────────────────────────────────────────────────────────╮ @@ -23,8 +24,8 @@ expression: snapshot │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ -│Type a command — press Tab for options, `help` for a list │ +│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Enter submit · : advanced once · mode advanced switch · Ctrl-C quit +Ctrl-O sidebar · Tab complete · ↑ history · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap index b132bbd..1d2e68a 100644 --- a/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap +++ b/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 3431 expression: snapshot --- ╭ Output ────────────────────────────────────────────────────────────────────────────────╮ @@ -23,8 +24,8 @@ expression: snapshot │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ -│Type a command — press Tab for options, `help` for a list │ +│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Enter submit · : advanced once · mode advanced switch · Ctrl-C quit +Ctrl-O sidebar · Tab complete · ↑ history · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap index 9d2184d..b3e064d 100644 --- a/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap +++ b/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap @@ -1,5 +1,6 @@ --- source: src/ui.rs +assertion_line: 3457 expression: snapshot --- ╭ Output ────────────────────────────────────────────────────────────────────────────────╮ @@ -23,8 +24,8 @@ expression: snapshot │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ ╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ -│Type a command — press Tab for options, `help` for a list │ +│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Enter submit · : advanced once · mode advanced switch · Ctrl-C quit +Ctrl-O sidebar · Tab complete · ↑ history · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap index 34a6f6a..0c1353e 100644 --- a/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 2369 +assertion_line: 2880 expression: snapshot --- ╭ Output ──────────────────────────────────────────────────────────────────────╮ @@ -26,4 +26,4 @@ expression: snapshot │insert into
[([, ...])] [values] ([, ...]) │ ╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Enter submit · : advanced once · mode advanced switch · Ctrl-C quit +Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap b/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap index 57a76f8..feeda07 100644 --- a/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__nav_overlay_relationships_focused_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 2967 +assertion_line: 3347 expression: snapshot --- ╭ Tables ───────────────────────────────────╮ ─────────────────────────────────╮ @@ -22,8 +22,8 @@ expression: snapshot ╰───────────────────────────────────────────╯ │ ╭ Relationships ────────────────────────────╮ ─────────────────────────────────╯ │Customers_Orders │ ─────────────────────────────────╮ -│ Customers.id -> │ ` for a list │ +│ Customers.id -> │ ` for a list · `mode advanced` │ │ Orders.customer_id │ │ ╰───────────────────────────────────────────╯ ─────────────────────────────────╯ Project: Term Planner -Enter submit · : advanced once · mode advanced switch · Ctrl-C quit +Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input diff --git a/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap b/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap index 5afcd79..99c972e 100644 --- a/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 2385 +assertion_line: 2896 expression: snapshot --- ╭ Output ──────────────────────────────────────────────────────────────────────╮ @@ -26,4 +26,4 @@ expression: snapshot │ │ ╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Enter submit · Backspace cancel one-shot · Ctrl-C quit +Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap b/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap index 012b295..bebe44f 100644 --- a/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 2679 +assertion_line: 3099 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮ @@ -22,8 +22,8 @@ expression: snapshot ╰──────────────────────────╯│ │ ╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯ │(none) │╭ Hint ──────────────────────────────────────────────────────────────────────────╮ -│ ││Type a command — press Tab for options, `help` for a list │ -│ ││ │ +│ ││Type a command — press Tab for options, `help` for a list · `mode advanced` │ +│ ││for SQL │ ╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Enter submit · : advanced once · mode advanced switch · Ctrl-C quit +Ctrl-O sidebar · Tab complete · ↑ history · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap b/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap index 2b36e30..f396dff 100644 --- a/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 2399 +assertion_line: 2909 expression: snapshot --- ╭ Output ──────────────────────────────────────────────────────────────────────╮ @@ -22,8 +22,8 @@ expression: snapshot │ │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭ Hint ────────────────────────────────────────────────────────────────────────╮ -│Type a command — press Tab for options, `help` for a list │ -│ │ +│Type a command — press Tab for options, `help` for a list · `mode advanced` │ +│for SQL │ ╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Enter submit · : advanced once · mode advanced switch · Ctrl-C quit +Ctrl-O sidebar · Tab complete · ↑ history · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap b/src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap index 3840ae1..87afd3b 100644 --- a/src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 2789 +assertion_line: 3209 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮ @@ -22,8 +22,8 @@ expression: snapshot ╰──────────────────────────╯│ │ ╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯ │Customers_Orders │╭ Hint ──────────────────────────────────────────────────────────────────────────╮ -│ Customers.id -> ││Type a command — press Tab for options, `help` for a list │ -│ Orders.customer_id ││ │ +│ Customers.id -> ││Type a command — press Tab for options, `help` for a list · `mode advanced` │ +│ Orders.customer_id ││for SQL │ ╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Enter submit · : advanced once · mode advanced switch · Ctrl-C quit +Ctrl-O sidebar · Tab complete · ↑ history · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap b/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap index 9168780..88166eb 100644 --- a/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 2265 +assertion_line: 2616 expression: snapshot --- ╭ Output ──────────────────────────────────────────────────╮ @@ -46,4 +46,4 @@ expression: snapshot │with `mode advanced`, or prefix the line with `:` to run… │ ╰──────────────────────────────────────────────────────────╯ Project: Term Planner -Enter submit · : advanced once · mode advanced switch · +Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Ente diff --git a/src/ui.rs b/src/ui.rs index 16ac859..c50e95d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1694,7 +1694,19 @@ fn resolve_hint_lines( (None, Some(crate::input_render::AmbientHint::Candidates { items, selected })) => { vec![render_candidate_line(&items, selected, inner, theme)] } - (None, None) => prose(&crate::t!("panel.hint_empty")), + // Empty input: the base prompt, plus — in simple mode only — a + // pointer to advanced mode (ADR-0051, issue #27), since the + // `mode advanced` switch left the keybinding strip. Advanced + // mode shows no pointer: users know how they reached it, and + // `help` covers the way back. (One-shot never reaches here — its + // `:` makes the input non-empty → ambient path.) + (None, None) => { + let mut text = crate::t!("panel.hint_empty"); + if matches!(app.effective_mode(), EffectiveMode::Simple) { + text.push_str(&crate::t!("panel.hint_mode_advanced")); + } + prose(&text) + } } } @@ -1845,6 +1857,63 @@ fn render_candidate_line( Line::from(spans) } +/// The keybinding strip is keystrokes-only and **state-selected** +/// (ADR-0051, issue #27): it advertises the keys for the user's *current* +/// interaction, chosen by priority — first matching state wins. +/// +/// Returns `(key, label)` pairs (label localised via `t!`); the renderer +/// is a thin span builder over this list, so the binding sets are +/// unit-testable without a `Frame`. Mode-switch / `:` advertisements +/// deliberately leave the strip — they are typed commands, not +/// keystrokes — and move to the empty-input hint (`resolve_hint_lines`). +fn status_bar_bindings(app: &App) -> Vec<(&'static str, String)> { + // 1. Sidebar focus (Ctrl-O): the input is occluded by the overlay, + // so the panel-scroll keys win outright (ADR-0046). + if app.nav_focus.in_sidebar() { + return vec![ + ("Ctrl-O", crate::t!("shortcut.next_pane")), + ("↑↓/PgUp/PgDn", crate::t!("shortcut.scroll")), + ("Esc", crate::t!("shortcut.to_input")), + ]; + } + // 2. A live Tab-completion memo (ADR-0022): cycling keys. Pressing + // Up clears the memo, so this never co-occurs with state 3. + if app.last_completion.is_some() { + return vec![ + ("Tab/Shift-Tab", crate::t!("shortcut.cycle")), + ("Esc", crate::t!("shortcut.cancel")), + ("Enter", crate::t!("shortcut.run")), + ]; + } + // 3. Browsing recalled history (unedited): browse keys. Editing the + // recalled line ends navigation, dropping to state 4. + if app.is_browsing_history() { + return vec![ + ("↑↓", crate::t!("shortcut.browse")), + ("Esc", crate::t!("shortcut.clear")), + ("Enter", crate::t!("shortcut.run")), + ]; + } + // 4. Editing — the input has text: surface the readline edit keys + // (ADR-0049). The highest-value subset stays within the width + // budget; Ctrl-K/U remain unadvertised muscle memory. + if !app.input.is_empty() { + return vec![ + ("Esc", crate::t!("shortcut.clear")), + ("Ctrl-A/E", crate::t!("shortcut.home_end")), + ("Ctrl-W", crate::t!("shortcut.del_word")), + ("Enter", crate::t!("shortcut.run")), + ]; + } + // 5. Default — empty input, Input focus. + vec![ + ("Ctrl-O", crate::t!("shortcut.nav")), + ("Tab", crate::t!("shortcut.complete")), + ("↑", crate::t!("shortcut.history")), + ("Enter", crate::t!("shortcut.run")), + ] +} + fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) { let key_style = Style::default() .fg(theme.fg) @@ -1855,35 +1924,14 @@ fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect let separator = Span::styled(" · ", sep_style); let mut spans: Vec> = Vec::new(); - - let push_shortcut = |spans: &mut Vec>, key: &'static str, label: &str| { + for (key, label) in status_bar_bindings(app) { if !spans.is_empty() { spans.push(separator.clone()); } spans.push(Span::styled(key, key_style)); spans.push(Span::raw(" ")); - spans.push(Span::styled(label.to_string(), label_style)); - }; - - let submit = crate::t!("shortcut.submit"); - push_shortcut(&mut spans, "Enter", &submit); - let switch = crate::t!("shortcut.switch"); - let advanced_once = crate::t!("shortcut.advanced_once"); - let cancel_one_shot = crate::t!("shortcut.cancel_one_shot"); - let quit = crate::t!("shortcut.quit"); - match app.effective_mode() { - EffectiveMode::Simple => { - push_shortcut(&mut spans, ":", &advanced_once); - push_shortcut(&mut spans, "mode advanced", &switch); - } - EffectiveMode::AdvancedPersistent => { - push_shortcut(&mut spans, "mode simple", &switch); - } - EffectiveMode::AdvancedOneShot => { - push_shortcut(&mut spans, "Backspace", &cancel_one_shot); - } + spans.push(Span::styled(label, label_style)); } - push_shortcut(&mut spans, "Ctrl-C", &quit); let paragraph = Paragraph::new(Line::from(spans)).style(bar_style); frame.render_widget(paragraph, area); @@ -2582,6 +2630,168 @@ mod tests { .expect("hint bottom border present") } + // ---- ADR-0051 (issue #27): context- and state-aware strip ---- + + fn key_event(code: crossterm::event::KeyCode) -> crate::event::AppEvent { + crate::event::AppEvent::Key(crossterm::event::KeyEvent::new( + code, + crossterm::event::KeyModifiers::NONE, + )) + } + + /// The `key` column of the strip's bindings, in order. + fn strip_keys(app: &App) -> Vec<&'static str> { + status_bar_bindings(app).into_iter().map(|(k, _)| k).collect() + } + + /// The full rendered strip text (keys + labels + separators). + fn strip_text(app: &App) -> String { + status_bar_bindings(app) + .iter() + .map(|(k, l)| format!("{k} {l}")) + .collect::>() + .join(" · ") + } + + fn hint_text(lines: &[Line<'_>]) -> String { + lines + .iter() + .map(|l| l.spans.iter().map(|s| s.content.clone()).collect::()) + .collect::>() + .join("\n") + } + + #[test] + fn strip_default_state_is_nav_complete_history_run() { + let app = App::new(); + assert_eq!(strip_keys(&app), vec!["Ctrl-O", "Tab", "↑", "Enter"]); + } + + #[test] + fn strip_editing_state_surfaces_readline_keys() { + // Input has text (no completion/history transient) → the #29 + // editing keys (ADR-0049). + let mut app = App::new(); + app.input.push_str("create ta"); + assert_eq!( + strip_keys(&app), + vec!["Esc", "Ctrl-A/E", "Ctrl-W", "Enter"], + ); + } + + #[test] + fn strip_sidebar_focus_state_is_pane_scroll_input() { + let mut app = App::new(); + app.nav_focus = NavFocus::SidebarTables; + assert_eq!( + strip_keys(&app), + vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"], + ); + // ...and the relationships sidebar is the same state. + app.nav_focus = NavFocus::SidebarRelationships; + assert_eq!(strip_keys(&app), vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"]); + } + + #[test] + fn strip_completion_memo_state_is_cycle_cancel_run() { + // Drive the real flow: `show ` + Tab leaves a multi-candidate + // memo (ADR-0022). The strip must win over the editing state. + let mut app = App::new(); + for c in "show ".chars() { + app.update(key_event(crossterm::event::KeyCode::Char(c))); + } + app.update(key_event(crossterm::event::KeyCode::Tab)); + assert!(app.last_completion.is_some(), "memo set by Tab"); + assert!(!app.input.is_empty(), "input non-empty — would be editing"); + assert_eq!( + strip_keys(&app), + vec!["Tab/Shift-Tab", "Esc", "Enter"], + "completion state wins over editing", + ); + } + + #[test] + fn strip_history_navigation_state_is_browse_clear_run() { + // Submit a command, then Up to recall it — `history_cursor` is + // set, input is the (non-empty) recalled line, no memo. + let mut app = App::new(); + for c in "drop table T".chars() { + app.update(key_event(crossterm::event::KeyCode::Char(c))); + } + app.update(key_event(crossterm::event::KeyCode::Enter)); // submit + app.update(key_event(crossterm::event::KeyCode::Up)); // recall + assert!(app.is_browsing_history(), "browsing recalled history"); + assert!(app.last_completion.is_none(), "no completion memo"); + assert_eq!( + strip_keys(&app), + vec!["↑↓", "Esc", "Enter"], + "history state wins over editing", + ); + } + + #[test] + fn every_strip_state_fits_the_eighty_column_budget() { + // ADR-0051 §3: the strips are kept lean by construction — the + // longest must fit an 80-col status line, so no graceful-drop + // machinery is needed. A future over-long strip fails here. + let sidebar = { + let mut a = App::new(); + a.nav_focus = NavFocus::SidebarTables; + a + }; + let editing = { + let mut a = App::new(); + a.input.push('x'); + a + }; + for app in [&App::new(), &sidebar, &editing] { + let text = strip_text(app); + assert!( + text.chars().count() <= 80, + "strip {} cols > 80: {text:?}", + text.chars().count(), + ); + } + } + + #[test] + fn empty_hint_advertises_advanced_mode_in_simple() { + let app = App::new(); + // Wide width so the pointer never wrap-splits. + let text = hint_text(&resolve_hint_lines(&app, &Theme::dark(), 200, 3)); + assert!( + text.contains("`mode advanced` for SQL"), + "simple empty hint carries the advanced pointer:\n{text}", + ); + } + + #[test] + fn advanced_mode_empty_hint_has_no_mode_pointer() { + // ADR-0051: advanced mode shows no mode pointer (users know how + // they got there; `help` covers the way back). + let mut app = App::new(); + app.mode = Mode::Advanced; + let text = hint_text(&resolve_hint_lines(&app, &Theme::dark(), 200, 3)); + assert!( + !text.contains("mode simple") && !text.contains("mode advanced"), + "advanced empty hint carries no mode pointer:\n{text}", + ); + } + + #[test] + fn typing_replaces_the_empty_hint_mode_pointer() { + // Non-empty input → ambient hint path, not the empty-hint + // mode pointer. + let mut app = App::new(); + app.input.push_str("create table"); + app.input_cursor = app.input.len(); + let text = hint_text(&resolve_hint_lines(&app, &Theme::dark(), 200, 3)); + assert!( + !text.contains("for SQL"), + "no mode pointer once typing:\n{text}", + ); + } + #[test] fn clamp_wrapped_truncates_with_ellipsis_past_max() { // ≤ max rows: untouched. diff --git a/tests/it/walking_skeleton.rs b/tests/it/walking_skeleton.rs index 5793393..375a8b3 100644 --- a/tests/it/walking_skeleton.rs +++ b/tests/it/walking_skeleton.rs @@ -223,21 +223,30 @@ fn typing_colon_in_simple_mode_flips_prompt_to_advanced() { } #[test] -fn status_bar_lists_quit_and_submit_in_all_modes() { +fn status_bar_is_keystroke_only_and_state_aware() { + // ADR-0051 (issue #27): the bottom strip is keystrokes-only and + // tracks the interaction state. Typed-command words (`:` advanced + // once, `mode advanced`/`mode simple` switch) and `Ctrl-C quit` + // leave the strip; mode discovery moves to the hint (locked by the + // ui.rs unit tests). This test exercises the real render path. let mut app = App::new(); let theme = Theme::dark(); - let simple = rendered_text(&mut app, &theme, 80, 24); - assert!(simple.contains("Enter"), "status bar lists Enter"); - assert!(simple.contains("Ctrl-C"), "status bar lists Ctrl-C"); - assert!(simple.contains("mode advanced")); + // Default (empty input): nav / complete / history / run keystrokes. + let default_view = rendered_text(&mut app, &theme, 80, 24); + assert!(default_view.contains("Ctrl-O sidebar"), "strip lists sidebar:\n{default_view}"); + assert!(default_view.contains("Enter run"), "strip lists run:\n{default_view}"); + assert!(!default_view.contains("Ctrl-C"), "quit dropped from the strip:\n{default_view}"); + assert!( + !default_view.contains("advanced once"), + "`:` command word dropped from the strip:\n{default_view}", + ); - type_str(&mut app, "mode advanced"); - submit(&mut app); - let advanced = rendered_text(&mut app, &theme, 80, 24); - assert!(advanced.contains("Enter")); - assert!(advanced.contains("Ctrl-C")); - assert!(advanced.contains("mode simple")); + // Editing (input has text): the #29 readline edit keys appear. + type_str(&mut app, "create"); + let editing = rendered_text(&mut app, &theme, 80, 24); + assert!(editing.contains("Esc clear"), "editing strip lists clear:\n{editing}"); + assert!(editing.contains("Ctrl-W del word"), "editing strip lists del word:\n{editing}"); } // --------------------------------------------------------------- From 5869eec4f4275d35abf3cc01727edd7f4bba142d Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sat, 13 Jun 2026 19:11:29 +0000 Subject: [PATCH 22/50] =?UTF-8?q?docs(ci):=20ADR-ci-003=20=E2=80=94=20cros?= =?UTF-8?q?s-platform=20release=20builds=20(D1=20matrix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the multi-platform build strategy as its own decision: cargo-zigbuild for the four non-macOS targets, the static/standalone posture per platform, the Windows synchronization stub, the test->build matrix workflow, and the macOS deferral with its licensing rationale (the public CI image can't carry the SDK). Shrinks the ci-001 amendment to a pointer; updates the index. Runtime-verified by the user: Linux x86_64 + Windows aarch64 run correctly. --- docs/ci/adr/20260612-adr-ci-001.md | 34 ++----- docs/ci/adr/20260613-adr-ci-003.md | 151 +++++++++++++++++++++++++++++ docs/ci/adr/README.md | 3 +- 3 files changed, 160 insertions(+), 28 deletions(-) create mode 100644 docs/ci/adr/20260613-adr-ci-003.md diff --git a/docs/ci/adr/20260612-adr-ci-001.md b/docs/ci/adr/20260612-adr-ci-001.md index 8389a59..1d64ccf 100644 --- a/docs/ci/adr/20260612-adr-ci-001.md +++ b/docs/ci/adr/20260612-adr-ci-001.md @@ -24,34 +24,14 @@ it rather than restating it. > previously forced website ADRs to be renumbered (see that namespace's > history note and ADR-0000 "Numbering discipline"). -## Amendment — 2026-06-13: D1 matrix expanded (non-macOS targets) +## Amendment — 2026-06-13: D1 matrix (non-macOS) -The release now builds the **four non-macOS D1 targets**, all cross-compiled -from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + -libc as one universal cross cc/linker — including the `cc`-crate compile of -rusqlite's bundled SQLite C — added to the flake devShell, replacing the -single-target musl cc): - -- `x86_64-unknown-linux-musl`, `aarch64-unknown-linux-musl` — static (D2); -- `x86_64-pc-windows-gnu`, `aarch64-pc-windows-gnullvm` — standalone `.exe`. - -`release.yaml` became a **`test` (once, host) → `build` (matrix over the four -targets)** workflow; each matrix job uploads its artifact + `.sha256` to the -shared release (idempotent create-or-get). - -**Windows link fix:** Rust's std links `-lsynchronization` (WaitOnAddress -thread-parking), an import lib that rust-overlay's toolchain doesn't ship and -Zig's mingw lacks. Those symbols are forwarded by `kernel32` (already linked), -so an **empty stub** `libsynchronization.a` (committed at `ci/winstub/`, wired -via `.cargo/config.toml` for the Windows targets only) satisfies the linker. -Verified locally: all four build; the Linux binaries are statically linked; the -Windows artifacts are valid PE32+ (x86-64 / Aarch64) — not yet runtime -smoke-tested on Windows. - -**macOS stays deferred** (see Deferred): `arboard`→AppKit needs Apple's SDK, -which a Linux runner can't supply cleanly — and the CI image is *public*, so the -SDK can't be baked in even if the licensing grey area were accepted. macOS is -its own step (osxcross + a private SDK, or a real Mac runner). +§3 (Release) below describes the original **single-target** (x86_64 Linux) job. +The release is now a **`test` → `build` matrix** over the four non-macOS D1 +targets (Linux + Windows × x86_64/aarch64), cross-built with `cargo-zigbuild`. +The full decision — tooling, targets, the Windows `synchronization` stub, the +matrix shape, and the macOS deferral with its licensing rationale — is recorded +in its own record: **[ADR-ci-003](20260613-adr-ci-003.md)**. ## Context diff --git a/docs/ci/adr/20260613-adr-ci-003.md b/docs/ci/adr/20260613-adr-ci-003.md new file mode 100644 index 0000000..f840fc3 --- /dev/null +++ b/docs/ci/adr/20260613-adr-ci-003.md @@ -0,0 +1,151 @@ +# ADR-ci-003: Cross-platform release builds (the D1 matrix) + +## Status + +**Accepted (2026-06-13); implemented the same day on the `ci` branch.** Every +fork was settled with the user. Verified end-to-end: + +- all four targets cross-build locally from Linux x86_64; +- the Linux binaries are statically linked (D2); the Windows artifacts are + valid PE32+ (x86-64 / Aarch64); +- a real release-matrix run (tag `v.0.0.0-citest3`) published **8 assets** — the + four binaries + a `.sha256` each. + +**Runtime-verified (2026-06-13, by the user):** the **Linux x86_64** and +**Windows aarch64** binaries launch and run correctly — one of each OS family +and both architectures. The remaining two (**Linux aarch64**, **Windows +x86_64**) are link-clean and valid format but not yet runtime smoke-tested. + +This ADR records the **cross-platform build strategy**; it sits on top of +**ADR-ci-002** (the nix flake, which now carries the cross toolchain) and +**ADR-ci-001** (the pipeline, whose release job this fills in). + +## Context + +`requirements.md` **D1** asks for binaries on **Linux, macOS, Windows × x86_64 +and aarch64** (six targets); **D2** asks for a **single static binary, no +runtime deps**. The CI runner executes jobs in a **Linux x86_64** container +(ADR-ci-001), so every target is **cross-compiled from Linux**. + +What's feasible is decided almost entirely by one dependency — **`arboard`** +(the clipboard backend for the `copy` command). Its per-platform backends in +`Cargo.lock`: + +| Target family | arboard backend | Needs a platform SDK to cross-link? | +|---|---|---| +| Linux x86_64 / aarch64 | `x11rb` (pure Rust) | No | +| Windows x86_64 / aarch64 | `clipboard-win` + `windows-sys` (import libs bundled) | No | +| **macOS x86_64 / aarch64** | **`objc2-app-kit` → links AppKit** | **Yes — Apple's SDK** | + +So **four targets cross-compile with no SDK**; **macOS is the hard wall** — +AppKit can only be linked against Apple's SDK. + +## Decision + +### 1. Tooling — `cargo-zigbuild` + +Cross-compile with **`cargo-zigbuild`** (Zig's bundled clang + libc as a single +universal cross `cc`/linker), added to the flake devShell alongside `zig`. One +tool serves every non-macOS target, **including the `cc`-crate compile of +rusqlite's bundled SQLite C**, with no per-target toolchain. It replaced the +earlier single-target musl `cc` (ADR-ci-002's first cut). + +### 2. Targets this iteration — the four non-macOS + +Added to `rust-toolchain.toml` and the release matrix: + +- **`x86_64-unknown-linux-musl`**, **`aarch64-unknown-linux-musl`** — musl + + `crt-static`, so **fully static** portable binaries (D2); +- **`x86_64-pc-windows-gnu`**, **`aarch64-pc-windows-gnullvm`** — Zig statically + links its libc, so the `.exe` is **standalone** (no mingw runtime DLLs). + +### 3. The Windows `synchronization` stub + +Rust's `std` links **`-lsynchronization`** (its `WaitOnAddress`-based thread +parking). That import library is normally supplied by Rust's `rust-mingw` +"self-contained" component — which **rust-overlay does not ship** — and Zig's +mingw doesn't carry it either, so the link fails with *"unable to find dynamic +system library 'synchronization'"*. The functions (`WaitOnAddress`, +`WakeByAddress*`) are **forwarded by `kernel32`** (already linked), so an +**empty stub** `libsynchronization.a` (committed at **`ci/winstub/`**, 8 bytes, +wired via **`.cargo/config.toml`** for the Windows targets *only*) satisfies the +linker without contributing symbols. Host and Linux builds are untouched by it. + +### 4. Workflow shape — test once, then a build matrix + +`release.yaml` is **`test` → `build`**: + +- **`test`** runs once on the host (`cargo test`) — a tag never publishes + untested code; +- **`build`** is a **matrix over the four targets** (`needs: test`, + `fail-fast: false`), each `cargo zigbuild --release --target `, then + packages the binary (`.exe` for Windows) + a `.sha256` and uploads both to the + **shared release** via an **idempotent create-or-get** (the first matrix job + creates the release; the rest fetch it). + +### 5. macOS — deferred, with rationale + +macOS is **not** in this iteration. `arboard`→AppKit needs the macOS SDK, and: + +- the SDK ships **only inside Xcode**; Apple's license ties its use to + **Apple-branded hardware**, so using it on a Linux runner is a **grey area** + (widely done, low enforcement, but technically against the terms); +- **redistributing** the SDK is a clearer violation — and our **CI image is + public**, so the SDK **cannot be baked into it** even if the grey area were + accepted; it would have to live in a private store; +- the **clean** path is building on **real Apple hardware** (a Mac registered as + a Gitea runner, or hosted Mac CI), where the SDK is fully licensed. + +macOS therefore becomes its **own step**, choosing between **(a)** osxcross + a +**private** SDK kept out of the public image, or **(b)** a **Mac runner**. The +user decides when we get there. + +## Consequences + +- **D1: four of six targets met** from a single Linux runner; **D2 met on + Linux** (static musl). Windows `.exe`s are standalone. +- **Runtime coverage:** Linux x86_64 + Windows aarch64 confirmed running + (user, 2026-06-13); Linux aarch64 + Windows x86_64 are the outstanding + runtime checks. +- **Each matrix target recompiles from scratch** (~2–4 min; ~10 min total on the + single runner), and Zig's per-target libc cache is cold each run. Fine at + release frequency; cacheable later if it matters. +- **The empty stub depends on `kernel32` forwarding `WaitOnAddress`** (true on + Windows 8+), which covers every supported target. +- **Asset naming** `rdbms-playground--[.exe]` is close to what + `cargo-binstall` / the D3 package managers will want. + +## Alternatives considered + +- **`cross` (cross-rs).** Docker-image-per-target; covers Linux + Windows but + **not macOS** (no legally redistributable Apple images), and needs DinD + orchestration inside our job. Rejected — no macOS, more moving parts than + zigbuild. +- **Per-target nix cross (`pkgsCross`).** Clean for Linux-musl and + Windows-x86_64 (mingw-w64, which *does* ship `libsynchronization.a`), but + Windows-aarch64 isn't readily packaged and **macOS-from-Linux is unsupported** + in nixpkgs. Rejected — incomplete. +- **Native runners per OS.** Cleanest for macOS/Windows, but needs mac/windows + runners we don't have. Kept on the table specifically for the deferred macOS + step. +- **A real `libsynchronization.a`** (from nixpkgs mingw or a `rust-mingw` + component) instead of the empty stub. More principled, but more flake + machinery, doesn't cover Windows-aarch64, and unnecessary — the stub links + clean because the symbols resolve via `kernel32`. + +## Deferred / out of scope + +- **macOS** (x86_64 + aarch64) — the SDK/runner decision above. +- **D3 packaging** — Homebrew / Scoop / winget / `cargo-binstall` manifests + (and binstall-friendly archive naming). +- **CI speed** — caching per-target builds / Zig's libc cache. +- **Runtime smoke test** of the two not-yet-checked targets (Linux aarch64, + Windows x86_64). + +## Relationship to other decisions + +- **Extends ADR-ci-002** — the flake devShell now carries `cargo-zigbuild` + + `zig` and the four release targets. +- **Fills in ADR-ci-001 §3 (Release)** — that single-target job is now this + matrix. +- **Advances `requirements.md`** **D1** (4/6) and **D2** (Linux, done). diff --git a/docs/ci/adr/README.md b/docs/ci/adr/README.md index 0a2339c..74a2223 100644 --- a/docs/ci/adr/README.md +++ b/docs/ci/adr/README.md @@ -18,5 +18,6 @@ here too). ## Index -- [ADR-ci-001 — CI + release pipeline on Gitea Actions](20260612-adr-ci-001.md) — **Accepted 2026-06-12** (implemented the same day on the `ci` branch). Establishes the CI/release pipeline on the self-hosted Gitea instance's Actions runner (`ci-public`). **Runner model** (established by a throwaway probe): jobs execute *inside* a container (`catthehacker/ubuntu:act-22.04` by default), as root, so the runner host's nix is **not** reachable from steps. **Toolchain delivery:** a **baked CI image** — `node:22-bookworm-slim` (satisfies the act_runner job-container contract: `/bin/sleep` keep-alive, `bash`, `node` for JS actions; a bare `nixos/nix` image lacks these and won't start) **+ single-user nix + the flake's devShell pre-warmed** — built by `build-ci-image.yaml` via DinD and pushed to the Gitea container registry as a **public** package, so CI runs `nix develop -c …` against the **same pinned toolchain as dev** (the flake, ADR-ci-002) with a warm store (~1.4 s to a ready toolchain). **Gate** (`ci.yaml`): `clippy -D warnings` + `cargo test` inside that image on branch pushes + PRs; **fmt deliberately not gated** (the tree isn't stock-rustfmt-clean — user decision, revisit on `main`; see ADR-ci-002). **Release** (`release.yaml`): on a `v*` tag, runs the tests, builds the **static `x86_64-unknown-linux-musl` binary** (D2: single static binary, no runtime deps — the glibc/nix build is non-portable), strips it, and publishes it + a `.sha256` to a Gitea release via the API and the auto-provided `GITEA_TOKEN`. **Triggers:** gate + image-build are scoped to **branch** pushes (`branches: ['**']`) so a release tag doesn't spuriously re-run them; the image-build additionally path-filters to its inputs (Dockerfile/flake/toolchain); the release owns tags. **Auth:** a dedicated PAT (`REGISTRY_USERNAME`/`REGISTRY_TOKEN` secrets) pushes the image; the auto `GITEA_TOKEN` publishes releases. **Scope:** the **four non-macOS D1 targets** (Linux + Windows × x86_64/aarch64) cross-built from Linux via cargo-zigbuild (2026-06-13 amendment); macOS, D3 package-manager manifests, CI-speed dependency caching, and the website's static→Cloudflare deploy remain deferred, added step by step. Verified live: probe → runner facts; image built + checked locally; gate green (**2424 tests**); release exercised end-to-end (`v0.0.0-citest2` published with binary + checksum). Builds on **ADR-ci-002** (the nix flake, relocated here from main's ADR-0049 to avoid exactly this cross-branch collision). +- [ADR-ci-001 — CI + release pipeline on Gitea Actions](20260612-adr-ci-001.md) — **Accepted 2026-06-12** (implemented the same day on the `ci` branch). Establishes the CI/release pipeline on the self-hosted Gitea instance's Actions runner (`ci-public`). **Runner model** (established by a throwaway probe): jobs execute *inside* a container (`catthehacker/ubuntu:act-22.04` by default), as root, so the runner host's nix is **not** reachable from steps. **Toolchain delivery:** a **baked CI image** — `node:22-bookworm-slim` (satisfies the act_runner job-container contract: `/bin/sleep` keep-alive, `bash`, `node` for JS actions; a bare `nixos/nix` image lacks these and won't start) **+ single-user nix + the flake's devShell pre-warmed** — built by `build-ci-image.yaml` via DinD and pushed to the Gitea container registry as a **public** package, so CI runs `nix develop -c …` against the **same pinned toolchain as dev** (the flake, ADR-ci-002) with a warm store (~1.4 s to a ready toolchain). **Gate** (`ci.yaml`): `clippy -D warnings` + `cargo test` inside that image on branch pushes + PRs; **fmt deliberately not gated** (the tree isn't stock-rustfmt-clean — user decision, revisit on `main`; see ADR-ci-002). **Release** (`release.yaml`): on a `v*` tag, runs the tests, builds the **static `x86_64-unknown-linux-musl` binary** (D2: single static binary, no runtime deps — the glibc/nix build is non-portable), strips it, and publishes it + a `.sha256` to a Gitea release via the API and the auto-provided `GITEA_TOKEN`. **Triggers:** gate + image-build are scoped to **branch** pushes (`branches: ['**']`) so a release tag doesn't spuriously re-run them; the image-build additionally path-filters to its inputs (Dockerfile/flake/toolchain); the release owns tags. **Auth:** a dedicated PAT (`REGISTRY_USERNAME`/`REGISTRY_TOKEN` secrets) pushes the image; the auto `GITEA_TOKEN` publishes releases. **Scope:** the original release job was Linux x86_64 only; it's now the **four non-macOS D1 targets** (Linux + Windows × x86_64/aarch64) cross-built via cargo-zigbuild — see **ADR-ci-003**. macOS, D3 package-manager manifests, CI-speed dependency caching, and the website's static→Cloudflare deploy remain deferred, added step by step. Verified live: probe → runner facts; image built + checked locally; gate green (**2424 tests**); release exercised end-to-end (`v0.0.0-citest2` published with binary + checksum). Builds on **ADR-ci-002** (the nix flake, relocated here from main's ADR-0049 to avoid exactly this cross-branch collision). - [ADR-ci-002 — Nix flake for a reproducible dev + build environment](20260612-adr-ci-002.md) — **Accepted 2026-06-12** (relocated from main's **ADR-0049** on the same day — content unchanged — to keep CI/dev-env decisions out of `main`'s integer sequence). The single, version-pinned declaration of the **dev *and* build toolchain** so CI never relies on whatever Rust is on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI). Root **Nix flake** with two outputs: **`devShells.default`** (pinned **Rust 1.95.0** via `rust-toolchain.toml` + `rust-overlay`, `cargo-sweep`, and the musl cc for the static release build) and **`packages.default`** (`rustPlatform.buildRustPackage` from the committed `Cargo.lock`; `doCheck = false`). Exact-pin (not floating `stable`) so `nix flake update` can't surprise-bump clippy past the `-D warnings` gate. System inputs near-empty by design (`libsqlite3-sys bundled` → stdenv cc only; `arboard`→`x11rb` pure-Rust). `.envrc` (`use flake`) for direnv parity. Verified through the flake: `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest**. Consumed by **ADR-ci-001** (the pipeline). Alternatives rejected: dev-shell-only; a standard `rust:1.95` CI image (a second toolchain definition = drift); `rustup` on the build host (non-reproducible). +- [ADR-ci-003 — Cross-platform release builds (the D1 matrix)](20260613-adr-ci-003.md) — **Accepted 2026-06-13** (implemented + a real matrix release verified the same day — tag `v.0.0.0-citest3` published 8 assets). Cross-compiles the **four non-macOS D1 targets** from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + libc as one universal cross cc/linker, incl. rusqlite's bundled SQLite C; added to the flake devShell, replacing the single-target musl cc): **`x86_64`/`aarch64-unknown-linux-musl`** (musl + crt-static → fully static, **D2**) and **`x86_64-pc-windows-gnu`** / **`aarch64-pc-windows-gnullvm`** (Zig statically links libc → standalone `.exe`). **Windows `synchronization` stub:** Rust std links `-lsynchronization` (WaitOnAddress thread-parking), an import lib rust-overlay's toolchain doesn't ship and Zig's mingw lacks; the symbols are forwarded by `kernel32`, so an **empty 8-byte stub** `libsynchronization.a` (`ci/winstub/`, wired via `.cargo/config.toml` for the Windows targets only) satisfies the linker. **Workflow:** `release.yaml` = **`test` once (host) → `build` matrix** over the four targets (`needs: test`, `fail-fast: false`); each job packages binary (`.exe` for Windows) + `.sha256` and uploads to the **shared release** via idempotent create-or-get. **macOS deferred** — `arboard`→AppKit needs Apple's SDK, a licensing grey area on a Linux runner, and the **public** CI image can't carry it; its own step (osxcross + a private SDK, or a Mac runner). Alternatives rejected: `cross` (no macOS, needs DinD), per-target nix cross (Windows-aarch64 unpackaged, macOS-from-Linux unsupported), a real `libsynchronization.a` (more machinery, doesn't cover Windows-aarch64). Runtime-verified by the user (2026-06-13): Linux x86_64 + Windows aarch64 run correctly; Linux aarch64 + Windows x86_64 are the outstanding runtime checks. Builds on ADR-ci-002 (flake) and fills in ADR-ci-001 §3 (Release). From 4aeea559843ed67e0b9dae9b608adb0d4408efd4 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 14 Jun 2026 11:20:55 +0000 Subject: [PATCH 23/50] feat(history): mode-tagged history + top-of-chain journaling (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the submission mode per history entry so advanced commands are reusable in simple mode, and fix the bug where a ':'-one-shot command lost its ':' across sessions (ADR-0052, closing #30). Format: the history.log status token gains an optional ':adv' suffix (ok / ok:adv / err / err:adv); 'source' stays last and canonical, so replay is unaffected. The in-memory ring (still Vec) stores advanced entries ': '-prefixed; recall strips the ':' in advanced mode and keeps it in simple; hydration reconstructs the prefix from the tag. Journaling moved from the worker to the dispatch layer (spawn_dsl_- dispatch / run_replay / app-command sites), where the mode is in scope with no worker plumbing; finalize_persistence writes only yaml/csv (commit-db-last still atomic for state). The journal write is now best-effort (command already committed), consistent with the failure path. App commands journal simple, so they recall bare. Journaling is now uniform (every successful command, per ADR-0034) — closing a gap where show tables/relationships/explain didn't journal. Amends ADR-0034 (status tag + journaling location), ADR-0015 §6 (history.log out of the worker tx), ADR-0040 (journal-write best-effort). 15 worker-level journaling tests retired, re-covered at the new layer (history.rs format, app.rs recall matrix, iteration6 cross-session regression, replay). 2471 pass / 0 fail / 0 skip, clippy clean. --- docs/adr/0015-project-storage-runtime.md | 8 + .../0034-history-journal-and-replay-filter.md | 8 +- ...0-completion-marker-replaces-ok-summary.md | 6 +- ...2-mode-tagged-history-cross-mode-recall.md | 241 +++++++++++++++++ docs/adr/README.md | 1 + ...260613-issue-30-top-of-chain-journaling.md | 247 ++++++++++++++++++ src/action.rs | 5 + src/app.rs | 157 +++++++++-- src/db.rs | 115 ++++---- src/event.rs | 5 + src/persistence/history.rs | 116 +++++++- src/persistence/mod.rs | 41 ++- src/runtime.rs | 57 +++- tests/it/iteration2_persistence.rs | 62 +---- tests/it/iteration4a_rebuild_command.rs | 9 +- tests/it/iteration6_resume_history.rs | 38 ++- tests/it/seed.rs | 18 -- tests/it/sql_create_index.rs | 6 +- tests/it/sql_create_table.rs | 6 +- tests/it/sql_delete.rs | 13 - tests/it/sql_drop_index.rs | 6 +- tests/it/sql_drop_table.rs | 6 +- tests/it/sql_insert.rs | 44 ---- tests/it/sql_select.rs | 20 -- tests/it/sql_update.rs | 13 - tests/it/walking_skeleton.rs | 1 + 26 files changed, 955 insertions(+), 294 deletions(-) create mode 100644 docs/adr/0052-mode-tagged-history-cross-mode-recall.md create mode 100644 docs/plans/20260613-issue-30-top-of-chain-journaling.md diff --git a/docs/adr/0015-project-storage-runtime.md b/docs/adr/0015-project-storage-runtime.md index 7730fc5..ddadd54 100644 --- a/docs/adr/0015-project-storage-runtime.md +++ b/docs/adr/0015-project-storage-runtime.md @@ -213,6 +213,14 @@ working copy. ### 6. Persistence ordering +> **Amended by ADR-0052 (2026-06-13, issue #30):** `history.log` is no +> longer written inside the worker transaction. It is a *journal* of typed +> commands, not state, so success journaling moved to the dispatch layer +> (next to the already-top-level failure journaling); `commit-db-last` now +> governs the three **state** targets only (db + `project.yaml` + +> `data/*.csv`), which still commit atomically in the worker. The journal +> write is best-effort (amends ADR-0040). + A successful user command produces effects in four targets: the SQLite database, `project.yaml`, the relevant `data/
.csv` file(s), and `history.log`. INV-2 from the diff --git a/docs/adr/0034-history-journal-and-replay-filter.md b/docs/adr/0034-history-journal-and-replay-filter.md index 744c97a..ff2ef63 100644 --- a/docs/adr/0034-history-journal-and-replay-filter.md +++ b/docs/adr/0034-history-journal-and-replay-filter.md @@ -2,7 +2,13 @@ ## Status -Accepted +Accepted. **Amended by ADR-0052 (2026-06-13, issue #30):** the status +field gains an optional `:adv` mode suffix (`ok:adv` / `err:adv`) — the +"non-breaking future extension" this ADR reserved — and **success +journaling moves out of the worker to the dispatch layer** +(`spawn_dsl_dispatch` / `run_replay` / app-command sites), next to the +failure path, where the submission mode is in scope. `status_is_ok` keys +off the base token, so `ok:adv` replays like `ok`. ## Context diff --git a/docs/adr/0040-completion-marker-replaces-ok-summary.md b/docs/adr/0040-completion-marker-replaces-ok-summary.md index bc7cd3a..b90b4e6 100644 --- a/docs/adr/0040-completion-marker-replaces-ok-summary.md +++ b/docs/adr/0040-completion-marker-replaces-ok-summary.md @@ -5,7 +5,11 @@ **Accepted** — 2026-05-30 (issue #9). Amends the output conventions of ADR-0014 (data operations), ADR-0028 (query plans / `explain`), and ADR-0019 (failure rendering); builds on ADR-0037's mode-tagged echo -line. +line. **Amended by ADR-0052 (2026-06-13, issue #30):** a `history.log` +*journal*-write failure on a **successful** command is no longer fatal — +journaling moved to the dispatch layer (after the db commit), so it is +best-effort (logged + ignored), consistent with the failure-journal path. +State-write failures (yaml/csv/db) remain fatal. ## Context diff --git a/docs/adr/0052-mode-tagged-history-cross-mode-recall.md b/docs/adr/0052-mode-tagged-history-cross-mode-recall.md new file mode 100644 index 0000000..7fdaabe --- /dev/null +++ b/docs/adr/0052-mode-tagged-history-cross-mode-recall.md @@ -0,0 +1,241 @@ +# ADR-0052: Mode-tagged history for cross-mode recall + +## Status + +**Accepted + implemented 2026-06-13 (issue #30).** Closes Gitea **#30** — +both the feature ("reuse advanced history commands in simple mode by +prepending `:`") and the bug reported in its comment (the `:` one-shot +prefix lost across sessions). All forks user-chosen before any code. +**Amends ADR-0034** (journal status field gains a `:adv` tag; *journaling +moves from the worker to the dispatch layer*), **ADR-0015 §5/§6** +(history.log leaves the worker transaction — `commit-db-last` now scopes +yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure +is best-effort, no longer fatal); references ADR-0003 (the `:` one-shot +sigil). Plan: `docs/plans/20260613-issue-30-top-of-chain-journaling.md` +(pre-build `/runda`, then a second `/runda` that drove the journaling +relocation + the app-command exclusion). **2471 tests pass / 0 fail / 0 +skip (1 ignored), clippy clean.** + +> **Why journaling moved (the key architectural turn).** The first draft +> kept journaling in the worker and threaded the mode down to it (~30-site +> plumbing). On review the user asked the right question: why is the +> journal written deep in the worker at all, when the failure path already +> journals at the top of the chain where command + mode + outcome are all +> in scope? It shouldn't — `history.log` is a *journal of typed commands*, +> not *state*. So success journaling moved up next to the failure path +> (`spawn_dsl_dispatch` / `run_replay` / the app-command sites), the +> mode-plumbing dilemma dissolved, and the worker's `finalize_persistence` +> now writes only the state sources (yaml/csv). Consequence: the journal +> write is best-effort (the command is already committed), consistent with +> the failure path — see §5. + +## Context + +The input-history ring and `history.log` carry **no mode information**, +which causes two coupled problems: + +1. **Feature gap.** A command typed in advanced mode (`select * from T`) + is stored bare. Recalled in simple mode it is not valid DSL → it just + errors. There is no way to know it was an advanced (SQL) command and + offer it back in a runnable form. + +2. **Bug (issue #30 comment).** A `:`-one-shot advanced command in simple + mode recalls correctly **in-session** (the in-memory ring stores the + raw `:select 1`), but after quit+resume it comes back **without** the + `:` and is unusable. Root cause: the ring stores the raw input + (`:select 1`), but the worker journals the **stripped** `effective_input` + (`select 1`) — submission strips the `:` before dispatch (ADR-0003) — + so the on-disk `source` never carried the `:`, and hydration loses it. + +Both reduce to: **history does not record the submission mode**, and the +in-memory and on-disk representations disagree about the `:`. + +## Decision + +Record the **submission mode** per history entry, keep the on-disk +`source` **canonical** (stripped — replay is unaffected), and have +**recall reconstruct the runnable line** for the current mode. + +### 1. In-memory ring stores the `:`-prefixed runnable form + +`App.history` stays `Vec` — no type change, so the public ring, +the `ProjectSwitched` payload, and `seed_history` are untouched. An +**advanced** entry is stored in its **simple-mode runnable form**, the +`: `-prefixed string (e.g. `: select * from T`); a **simple** entry is +stored bare. This is exactly what the in-session one-shot ring already +does (`:select 1` recalls as typed) — generalised to *persistent*-advanced +commands too, and made reconstructable on hydration. Because a simple +DSL command can never begin with `:` (the sole sigil, ADR-0003), a +leading `:` unambiguously marks an advanced entry. + +`submit` builds the stored line from the submission: advanced → +`": " + effective_input` (the `: ` matches the auto-space the typed +one-shot inserts), simple → `effective_input`. This is computed **after** +`effective_input` (today `push_history` runs on the raw `trimmed` before +stripping; the reorder also drops a bare `:`, which never executed). The +draft (`history_draft`) stays a plain `String`. `push_history` itself is +unchanged — it still takes one `&str`. + +### 2. Recall strips the `:` for advanced mode + +`history_back` / `history_forward` set `self.input` from the stored +string, then strip a leading `:` **iff the current persistent mode is +Advanced**: + +``` +if self.mode == Mode::Advanced && stored.starts_with(':') { stored[1..].trim_start() } else { stored } +``` + +So an advanced entry recalls as `: select * from T` in **simple** mode +(runs via the one-shot escape — the feature, and the cross-session bug +fix) and bare `select * from T` in **advanced** mode (runs as SQL). A +simple entry recalls bare in either mode (simple DSL already runs in +advanced mode — issue #30). In-session and cross-session paths share the +same stored form, so they finally agree. + +### 3. On-disk: a mode tag in the status field + +The record stays three pipe-separated fields `||` +(so `source` remains the last, pipe-tolerant, canonical field — replay +reads it unchanged). The **status token** gains an optional `:adv` +suffix: + +| Submission | Success | Failure | +|---|---|---| +| Simple | `ok` | `err` | +| Advanced (persistent or one-shot) | `ok:adv` | `err:adv` | + +ADR-0034 §1 already reserved the status field for "additional values … +a non-breaking future extension"; this is that extension. The status +parser splits the token on `:`: the base (`ok`/`err`) gives replayability +(`status_is_ok` ⇔ base == `ok`), the `adv` suffix gives the mode — so an +unknown future token degrades to "not ok, simple" rather than mis-parsing. + +### Journaling location: the dispatch layer, not the worker + +Both tags are written **at the dispatch layer**, where command + mode + +outcome are all in scope — so the mode needs no plumbing into the worker: + +- **Success:** `spawn_dsl_dispatch`, immediately after + `execute_command_typed` returns `Ok`, calls + `append_history(source, submission_mode.is_advanced())` (best-effort). + `run_replay` does the same per replayed line (tagged simple — replay is + mode-agnostic), and the app-command sites (`perform_switch` / + `spawn_export` / `spawn_rebuild`) journal **simple** (`advanced = false` + — app commands run in any mode, so no `:` on recall; this also avoids a + redundant `: undo`). +- **Failure:** unchanged location (the App→`JournalFailure`→runtime path, + already at the top), now carrying the mode — `JournalFailure` gains + `advanced`, and `DslFailed` gains `submission_mode` for the + worker-rejection sub-path (the parse-failure sub-path has it in + `dispatch_dsl`). `Ok`/`Err` are exclusive, so success-in-spawn and + failure-in-App-path never double-journal. + +The worker's `finalize_persistence` and the four no-op-skip / three +read-only sites **no longer journal** — they leave the state writes +(yaml/csv) in the worker transaction and let the dispatch layer journal +the `Ok` outcome. + +### 4. Hydration reconstructs the `:`-prefixed form + +`read_recent_sources` parses each record's status tag and, for an +advanced record, **reconstructs** the `: `-prefixed string from the +canonical `source` (`format!(": {source}")`); simple records pass through +bare. It still returns `Vec`, so `read_history_seed`, +`seed_history`, and the `ProjectSwitched` payload are **unchanged**. A +hydrated entry is therefore byte-identical to its in-session form, and +recall behaves identically. + +### Back-compatibility + +Old `history.log` files have only `ok` / `err` tokens → parsed as +`advanced = false` (simple). Their advanced commands stay un-`:`-able on +recall — the pre-existing behaviour, not a regression; nothing migrates. +`status_is_ok` keys off the base token, so `ok:adv` records replay +exactly as `ok` does today (source is canonical either way). + +### Journal write is best-effort (amends ADR-0040) + +Because the journal is now written *after* the worker replies (i.e. after +`tx.commit`), a journal-write failure can no longer roll the command back. +It is **best-effort** — logged and ignored, exactly like the failure path +already is (ADR-0034 §4) — so the two journal paths are finally +consistent. State integrity is unchanged: yaml/csv/db still commit +atomically in the worker (a *state*-write failure still rolls back and is +fatal). The only property given up: on a rare journal-write failure (disk +full) a committed command may be missing from `history.log` — not +recallable/replayable next session, but the state is correct. User-chosen +over keeping journaling coupled in the worker (which would have needed the +~30-site mode plumbing). See the plan's §2 for the full trade-off. + +## Forks (user-chosen) + +- **Format = mode tag in the status field** (`ok:adv`/`err:adv`), over a + new 4th field (ambiguous with unescaped pipes in old `source`s without + a version bump) or a `:`-prefix in `source` (would make `source` + non-canonical and force replay to strip it). +- **Scope = unified** (bug + feature) over bug-only: one mechanism does + both, and keeping `source` canonical for replay needs the mode tag + regardless, so bug-only is barely smaller and leaves the main ask open. +- **Journaling location = dispatch layer, best-effort** over keeping it + worker-coupled-and-fatal (which needed the ~30-site mode plumbing). The + user's architectural call (§Status). + +## Consequences + +- Advanced history is reusable in simple mode; the `:` one-shot survives + resume. The in-memory and on-disk representations agree. +- **Journaling left the worker.** `finalize_persistence` and the + no-op-skip / read-only sites no longer journal; success is journalled at + the dispatch layer (`spawn_dsl_dispatch` / `run_replay` / app-command + sites). The ring stays `Vec`; `seed_history` / `ProjectSwitched` + are untouched. The vestigial worker `source` plumbing (the `_source` + param on `finalize_persistence` / `do_rebuild_from_text` and the thin + read-only `*_request` wrappers) is left in place — a clean follow-up. +- **App commands recall bare.** Because they are dispatched outside the + `ExecuteDsl`/spawn path, app commands journal **simple** (`advanced = + false`) at their own sites, and `submit` excludes them from the ring's + `advanced` flag (`!is_app_command`) — so `mode advanced` / `undo` recall + bare and run fine in simple mode, with no redundant `:`. +- **Journaling is now uniform (user-confirmed).** The spawn journals on + `outcome.is_ok()`, so **every** successful command is recorded — closing + a pre-existing gap where `show table` / `show data` / `select` journalled + but `show tables`/`show relationships`/`show indexes`, `show relationship + `, and `explain` did **not** (their worker arms carried no + `source` / no journal call). The new behaviour matches ADR-0034 §1 + ("record every submitted command"); those reads are now recallable and + are re-run harmlessly on replay (`explain` never executes; shows produce + output, no state change). A DA finding, accepted as the more-correct + behaviour over re-adding command-outcome gating to preserve the old + inconsistency. +- **Replay re-journaling.** When `replay` re-dispatches a line, the + re-written record is tagged from how replay dispatched (mode-agnostic → + `ok`), so a replayed advanced command may be re-journalled without + `:adv`. Replay correctness of execution is unchanged (it already parses + mode-agnostically); this only affects the *tag* of the re-written line. + Noted; not addressed here (replay's own mode-fidelity is out of scope). + +## Tests + +- **Tier-1 (`app.rs`):** an advanced one-shot / persistent-advanced + submission is stored `: `-prefixed; it recalls as `: …` in simple mode + and bare in advanced mode; a simple entry recalls bare in both; a bare + `:` is not stored; a parse-failure is still recallable; dedup/cap hold. +- **Tier-1 (`history.rs`):** the writer emits `ok:adv`/`err:adv`; + `read_recent_sources` reconstructs the `: `-prefix for `:adv` records + and leaves `ok`/`err` records bare (so old logs read as simple); + `status_is_ok` is true for `ok` and `ok:adv`. +- **Tier-3 (`iteration6_resume_history` / it):** the headline + **regression** — type a `:`-one-shot advanced command, journal + + hydrate, and assert it recalls **with** `:` in simple mode (fails on + current code); plus a persistent-advanced command round-tripping to a + `: …` recall. + +## Out of scope + +- Replay re-journaling mode-fidelity (above). +- Special-casing app commands to avoid the redundant recall `: `. +- Distinguishing one-shot from persistent advanced on recall (both are + simply "advanced" — the `:` is what simple mode needs either way). +- A format version marker / pipe-escaping in `source` (unneeded — the + status-tag approach keeps `source` last and canonical). diff --git a/docs/adr/README.md b/docs/adr/README.md index 2d81708..abeba31 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -57,3 +57,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0049 — Input-field readline keymap: Esc-clear + Ctrl-A/E/W/K/U (I1b)](0049-input-field-readline-keymap.md) — **Accepted + implemented 2026-06-12 (issue #29)**, closes Gitea **#29** and the deferred **I1b** readline requirement. **Amends ADR-0046**, which listed "readline shortcuts (I1b)" as out-of-scope — that item is now in scope and decided here; orthogonal to ADR-0003's input-*mode* model and extends the I1a single-line cursor editing already shipped. Binds, in the input field (non-modal, non-nav, both modes): **`Esc`** clears a partly-typed command (empty buffer, cursor→0, scroll→0); **`Ctrl-A`/`Ctrl-E`** alias Home/End (line start/end); **`Ctrl-W`** deletes the previous word (readline-style — eats trailing whitespace then the preceding non-whitespace run, UTF-8-safe on char boundaries, only back to the cursor); **`Ctrl-K`** kills to end of line; **`Ctrl-U`** kills to start. **Esc precedence:** a live Tab-completion memo still wins (Esc undoes the completion first, ADR-0022; Esc clears only when no memo) — Esc-once backs out the completion, Esc-again clears. Forks all user-chosen: **single-Esc-clears** (not double-Esc — discoverable over accident-proof; an unsubmitted draft can be lost, a submitted line is always in history); the **full I1b set** (not just the issue's literal Ctrl-A/E + Esc); a **new ADR** (not an ADR-0046 amendment / no-ADR). Cursor-only keys (Ctrl-A/E) leave history navigation intact like Home/End; buffer-mutating keys (Esc-clear, Ctrl-W/K/U) end it like Backspace. Helpers `clear_input`/`delete_prev_word`/`kill_to_end`/`kill_to_start` in `src/app.rs`; **22 new Tier-1 tests, 2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. OOS: on-screen keybinding hints (issue #27 owns surfacing per-focus keybindings in the bottom status line — this ADR makes the keys *work*, #27 makes them *discoverable*); demo-mode badges for the new chords (ADR-0047 follow-up — Esc already badges `[ESC]`, the glyph-less Ctrl-chords are flagged but not added); multi-line input (I1); word-wise cursor motion (Alt-B/F) / transpose / yank - [ADR-0050 — Incidental-DDL confirmations omit relationship info (structure-only)](0050-incidental-ddl-confirmations-omit-relationships.md) — **Accepted + implemented 2026-06-12 (issue #28)**, closes Gitea **#28**. **Supersedes** the incidental-DDL clause of **ADR-0044 §1** and the relationship-block half of **ADR-0016 §5**. Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/`rename`/`change column`, `add`/`drop index`) now render **structure only** — header + column box + `Indexes:` + constraints — with **no `References:` / `Referenced by:` block** (neither prose nor diagram), even when the table carries relationships the user did not touch. Rationale (owner): a confirmation echo reports the change just made, not untouched relationships; ADR-0044's terse prose was the lesser of "prose vs diagram", but the right answer for these surfaces is **neither**. **Relationship-subject surfaces are unchanged** — `show table`, `add`/`drop relationship`, `show relationship` still render ADR-0044 diagrams; relationships appear only when the user asks for (`show table`) or acts on (`add`/`drop relationship`) one, and are one `show table ` away — **no information lost**. Forks both user-chosen: **scope = all incidental DDL** (not just `add column` — the rationale is uniform, the mental model clean, and it's the simpler edit) and **delete the prose renderer** (not retain it dormant — no dead code). **Mechanism:** the `handle_dsl_success` `matches!` routing is unchanged (relationship-subject → diagrams; else → `render_structure`); the change is one line inside `render_structure` (`output_render.rs` — drop the relationship-block call) since all its callers are incidental DDL, plus deletion of the orphaned `relationship_prose_lines` + `cols_disp` helpers. The prose format survives in ADR-0016 §5 + git history for a future OOS-7 always-prose setting. **Tests:** the prose-presence unit test + its snapshot removed; a new unit test asserts `render_structure` on a description carrying **both** inbound and outbound relationships emits the box but no prose; the misnamed `add_relationship_flow_shows_inbound_section_on_parent` integration test (which sent an `AddColumn`) inverted + renamed to assert the add-column echo omits the prose; the diagram tests (`show table`, `add relationship`) unaffected. **2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. `requirements.md` unaffected (ADR-tracked refinement of a decided area, like ADR-0044 itself) - [ADR-0051 — Bottom keybinding strip: context- and state-aware](0051-context-state-aware-keybinding-strip.md) — **Accepted + implemented 2026-06-13 (issue #27)**, closes Gitea **#27**. Repurposes the bottom status line into a **keystrokes-only, state-selected** strip (builds on ADR-0046 nav focus, ADR-0003 modes, ADR-0049 the #29 readline keys it now advertises, ADR-0022 the completion memo). A pure `status_bar_bindings(app) -> Vec<(key,label)>` chooses the strip by **priority, first match wins**: (1) **sidebar focus** → `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input`; (2) **completion memo live** (`last_completion`) → `Tab/Shift-Tab cycle · Esc cancel · Enter run`; (3) **history navigation** (new `App::is_browsing_history()` exposing the private `history_cursor`) → `↑↓ browse · Esc clear · Enter run`; (4) **editing** (input non-empty) → `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` (surfaces the #29 keys, closing ADR-0049's deferred advertisement); (5) **default** (empty) → `Ctrl-O sidebar · Tab complete · ↑ history · Enter run`. Priority is correct because Up clears the completion memo and Tab cancels history nav, so states 2/3 never co-occur, and the five are exhaustive for Input focus. **Typed-command words leave the strip** (`mode advanced`/`mode simple` switch, `:` one-shot) and **mode discovery moves to the empty-input hint** (`resolve_hint_lines`), **simple mode only**: `\`mode advanced\` for SQL` (the verb "type" omitted — the prompt implies it; advanced mode shows **no** pointer per a post-trial user decision — a switcher knows how they got there and `help` covers the way back). The one-shot's old `Backspace cancel one-shot` label is subsumed by the editing state (behaviour intact). Forks all user-chosen: **editing state shows the #29 keys** (vs unadvertised); **`Ctrl-C quit` omitted** from the strip (vs always shown); **no width-drop machinery** — the longest strip (~65 cols) fits all supported widths, so a **width-budget unit test** keeps it lean by construction instead (the user's own observation). Catalog: 12 new `shortcut.*` labels + the `panel.hint_mode_advanced` string added to `en-US.yaml`+`keys.rs` (validator-checked 1:1), 5 now-dead strip strings removed. **Modal-aware strip is OOS** (pre-existing: a modal owns the keyboard and carries its own hints; the strip under it is unchanged-in-kind, not worsened). Tests: 9 Tier-1 unit (per-state key sets — completion/history driven through real key events; width budget; mode-pointer presence/absence), 1 Tier-3 rewritten (`status_bar_is_keystroke_only_and_state_aware`), 15 full-panel snapshots re-accepted (reviewed — strip/hint only). **2467 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: modal-aware strip; a full-key cheatsheet overlay; Ctrl-K/U advertisement (editing strip shows the highest-value subset within the width budget) +- [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (status field gains a `:adv` tag; **journaling moves from the worker to the dispatch layer**), **ADR-0015 §5/§6** (history.log leaves the worker transaction — `commit-db-last` now scopes yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure is best-effort, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: unwinding the now-vestigial worker `source` plumbing (`_source` params + thin `*_request` wrappers — a clean follow-up); replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression) diff --git a/docs/plans/20260613-issue-30-top-of-chain-journaling.md b/docs/plans/20260613-issue-30-top-of-chain-journaling.md new file mode 100644 index 0000000..54eeeea --- /dev/null +++ b/docs/plans/20260613-issue-30-top-of-chain-journaling.md @@ -0,0 +1,247 @@ +# Plan — issue #30: mode-tagged history + top-of-chain journaling + +**Status:** draft for `/runda` review (2026-06-13). +**Issue:** #30 — advanced history reusable in simple mode (prepend `:`), +and the bug: the `:` one-shot prefix is lost across sessions. +**ADR:** ADR-0052 (new); amends ADR-0015 §6, ADR-0034, ADR-0040; +references ADR-0003. + +## 1. Goal & root cause + +Two coupled needs, one root cause — **history entries carry no mode**: +- **Bug:** the in-memory ring stores the raw `:select 1`, but the worker + journals the *stripped* `select 1`, so cross-session the `:` is lost + and the command recalls bare (unusable in simple mode). +- **Feature:** persistent-advanced commands (`select 1` typed in advanced + mode) can't be told apart from simple DSL, so they can't be offered + back with a `:` in simple mode. + +Fix: **record the submission mode per entry** (status tag `:adv`), keep +the on-disk `source` canonical, and have **recall prepend/strip `:`** for +the current mode. + +## 2. The architecture insight (why this plan is shaped this way) + +Journaling **success** lives deep in the worker: `finalize_persistence` +(db.rs:3096-3099) writes `history.log` *inside the db transaction, before +`tx.commit()`*, alongside yaml/csv — plus four no-op-skip sites and three +read-only helpers. **Failure** journaling already lives at the top +(runtime.rs:484-495, best-effort). Threading the mode *down* to the +worker would mean ~30 `Request` variants + `Database` methods + +`execute_command_typed` arms — because the journal write is far from +where the mode is known. + +So instead: **move success journaling up to the dispatch layer**, next to +where failure journaling already is and where mode + outcome + source are +all in scope. The mode then needs no plumbing. This is the correct +separation anyway — `history.log` is an append-only *journal of what was +typed*, not *state*; the state sources (yaml/csv/db) stay atomic in the +worker. + +### Semantic changes this entails (must be vetted) + +1. **history.log leaves the worker transaction** (amends ADR-0015 §6). + `commit-db-last` still governs yaml/csv/db (the state); the journal is + written *after* the worker replies (i.e. after `tx.commit`), at the + dispatch layer. +2. **Success-journal write failure: fatal → best-effort** (amends + ADR-0040). Today a failed `history.log` write on a *successful* + command rolls the command back and shows a fatal banner. After: the + command stays committed; the journal write is best-effort (logged + + ignored), exactly like the failure path already is. The two journal + paths become *consistent*. +3. **Consequence:** on a rare journal-write failure (disk full / + permissions) a successful command is applied but may be missing from + `history.log` — not recallable next session, not replayable. The state + (yaml/csv/db) is unaffected and consistent. This is a graceful + degradation, not corruption, and is logged. (Today the same disk-full + instead kills the app mid-command.) + +**Open question for review/user:** is trading "fatal on journal-write +failure" for "best-effort, command still succeeds" acceptable? The plan +assumes **yes** (a journal is auxiliary; killing the app over it is worse +UX). If not, journaling must stay coupled in the worker and we pay the +~30-site mode plumbing instead. + +## 3. On-disk format (mode tag in status — already chosen + partly built) + +Record stays `||`; the **status token** gains an +optional `:adv` suffix (ADR-0052). `source` stays canonical so replay is +unaffected. + +| Submission | Success | Failure | +|---|---|---| +| Simple / app command | `ok` | `err` | +| Advanced (SQL, persistent or one-shot) | `ok:adv` | `err:adv` | + +**Done already** (history.rs / mod.rs): +- `status_token(base, advanced)`, `parse_status(status) -> (is_ok, advanced)`. +- `parse_record_source` reconstructs `": {cmd}"` for `:adv` records. +- `parse_journal_record.status_is_ok` via `parse_status` (so `ok:adv` replays). +- `append_history(text, advanced)`, `append_history_failure(text, advanced)`. + +Back-compat: old `ok`/`err` logs → simple; nothing migrates. + +## 4. In-memory ring & recall (app.rs) — the #30 behaviour + +The ring stays `Vec`. An **advanced** entry is stored in its +`: `-prefixed simple-mode runnable form (matching the existing in-session +one-shot ring); a **simple** entry bare. A leading `:` unambiguously +marks advanced (simple DSL can never start with `:`). + +- **`submit`** (app.rs:1704): compute `effective_input` + `submission_mode`, + parse once for the app-command check (already done at 1751), then build + the ring line. The **`advanced` flag excludes app commands** — + `advanced = submission_mode.is_advanced() && !is_app_command` — because + app commands (`undo`, `mode …`, `save as`, …) run in *any* mode and must + **not** get a `:` on recall. Ring line: `": " + effective_input` if + `advanced`, else `effective_input`; `push_history(&ring_line)`. (Today it + pushes the raw `trimmed` *before* stripping; the reorder also drops a + bare `:`, which executed nothing, and is what lets the app-command check + precede the push.) `ExecuteDsl.source` stays the **canonical** + `effective_input`. + - *Why the app-command exclusion matters (DA finding):* without it, + `: save as foo` (an app command via the one-shot) would store `: save + as foo` in the ring but journal `save as foo` (app commands journal + simple at their own sites, §5) — the very in-session-vs-cross-session + divergence #30 is fixing, re-introduced for app commands. Excluding + them keeps ring and disk agreeing (both bare). +- **`history_back` / `history_forward`**: after cloning the stored entry + into `self.input`, strip a leading `:` **iff `self.mode == Advanced`** + (so an advanced entry runs as bare SQL in advanced mode, and as `: …` + one-shot in simple mode). A small helper `recall_display(stored)`. +- `seed_history` / `ProjectSwitched` payload: **unchanged** (`Vec`); + hydration already returns the `: `-prefixed form (§3). + +Recall matrix: +| entry \ current mode | Simple | Advanced | +|---|---|---| +| advanced (`: select 1`) | `: select 1` (one-shot) | `select 1` (SQL) | +| simple (`create …`) | `create …` | `create …` | + +## 5. Move success journaling worker → dispatch layer + +**Remove** (worker stops journaling success): +- `finalize_persistence` history write (db.rs:3096-3099). Keep yaml/csv. + The now-unused `source` param: remove it + drop the arg at its ~30 + callers (mechanical, compiler-guided). (Handlers keep their own + `source` for `snapshot_then`.) +- The 4 no-op-skip `append_history` (db.rs:2267, 2311, 2524, 2560) — these + outcomes (`SchemaSkipped` etc.) are `Ok` at the dispatch layer, so the + new top-level journal covers them. +- The 3 read-only helper `append_history` (db.rs:8372 show table, 9996 + show data, 10014 select) — `Ok(Query)`/`Ok(ShowList)` at the top. + +**Add** (dispatch-layer journaling, all best-effort + logged): +- **`spawn_dsl_dispatch`** (runtime.rs ~1433): pass `project_path` in; + after `execute_command_typed`, `if outcome.is_ok() { + Persistence::new(path).append_history(&source_for_journal, + submission_mode.is_advanced()) }`. (Failures stay in the existing path, + §6 — no double-journal, since Ok and Err are exclusive.) +- **`run_replay`** (runtime.rs ~2540): after each line's + `execute_command_typed`, `if outcome.is_ok() { append_history( + &command_text, false) }` — replay is mode-agnostic, journalled + **simple**. (Preserves ADR-0034 §3 "replayed sub-commands land in + history"; a replayed advanced command re-journals without `:adv` — a + documented OOS, not a regression: today it re-journals as plain `ok`.) +- **`spawn_rebuild`** (runtime.rs ~503): after a successful rebuild, + `append_history("rebuild"/source, false)`. (Rebuild journalled via + `finalize_persistence` today; that write is gone, so add it here.) + +**Unchanged** (already at the dispatch layer, app commands): +- `perform_switch` (974: save-as/load/new) and `spawn_export` (1043) — + already best-effort `append_history(&source)`; add the new `advanced` + arg as `false` (app commands run in any mode → no `:` needed on recall; + this also fixes the would-be "redundant `: undo`" — app commands + journal **simple** because they're dispatched here, never via + `ExecuteDsl`/the spawn). +- `undo`/`redo`/`copy`/`help`/`quit`: not journalled today; unchanged. +- The **`replay` command itself**: dispatched as `Action::Replay`, never + reaches the spawn → not journalled (preserves the ADR-0034 §3 exclusion + without extra work); nested `replay` skip in `run_replay` unchanged. + +### DA-confirmed design choice: split, don't unify + +Success journals in the spawn (`Ok` arm); **all** failures stay in the +existing App→`JournalFailure`→runtime path (just gaining the mode). +Considered and rejected: moving worker-rejection failures into the spawn +too (to "unify"). It doesn't actually unify — parse failures never reach +the spawn, so they'd stay in the App path regardless — and it adds a +double-journal hazard (must also strip the App's `DslFailed`→ +`JournalFailure` emission). The split keeps the failure path **untouched +in structure** (lowest risk); `Ok`/`Err` are exclusive so there is no +double-journal. **Verified safe:** undo/redo never touches `history.log` +(the snapshot copies db+yaml+csv only, undo.rs:15-16), and `snapshot_then`'s +redo-clear keys on `source.is_some()`, independent of journaling — so +removing the worker journal write does not perturb undo/snapshot at all. + +## 6. Failure journaling — add the mode (location unchanged) + +Keep both failure origins where they are (best-effort, dispatch/App +layer); thread the mode so they tag `err:adv`: +- **`Action::JournalFailure`** (action.rs:42): add `advanced: bool` (or + `submission_mode`). +- **`AppEvent::DslFailed`** (event.rs): add `submission_mode` (the + worker-rejection path — the App can't recover the mode from an async + reply otherwise). +- **App**: the parse-failure path (`dispatch_dsl` Err arm) has + `submission_mode` directly; the `DslFailed` handler reads it off the + event. Both emit `JournalFailure { source, advanced }`. +- **runtime.rs:492**: `append_history_failure(&source, advanced)`. + +## 7. Tests + +- **history.rs (Tier-1):** `status_token`/`parse_status` round-trip; + `read_recent_sources` reconstructs `": …"` for `:adv` and leaves + `ok`/`err` bare; `status_is_ok` true for `ok` & `ok:adv`; old-log + back-compat. +- **app.rs (Tier-1):** advanced submission stored `: `-prefixed; recall + prepends in simple / strips in advanced; simple bare in both; bare `:` + not stored; a parse-failure is still recallable; dedup/cap hold. +- **iteration6_resume_history (Tier-3) — headline regression:** journal + an advanced command (`append_history(text, true)`), hydrate, recall in + simple → `: …`; and the full bug repro through `submit` + journal + + hydrate if feasible. +- **replay_command (Tier-3):** replayed commands still land in + history.log (now via `run_replay`'s call); the `replay`-self-exclusion + + nested-skip still hold; advanced lines replay (status `ok:adv` + treated as ok). +- **Journaling relocation:** a success no longer fatals on a journal + write failure (best-effort) — if cheaply testable; at minimum a worker + test that previously asserted worker-side journaling is updated/removed. +- **Update mechanical call sites:** `append_history(_, advanced)` / + `append_history_failure(_, advanced)` at the db.rs inline tests + (8372/9996/10014/11324 — likely now removed with the production sites), + iteration6 (144-170), mod.rs (600). + +## 8. ADR work + +- **ADR-0052 (new):** the #30 feature + bug, the status-tag format, the + `: `-prefixed ring + recall, AND the journaling relocation (it's the + enabling refactor). Forks: status-tag format; unified scope; + dispatch-layer journaling (best-effort). +- **ADR-0015 §6 amendment:** history.log out of the worker transaction; + commit-db-last now scopes yaml/csv/db; journal is a dispatch-layer + best-effort side-record. +- **ADR-0034 amendment:** journaling location (dispatch layer); + status-field `:adv` extension (it already reserved the field). +- **ADR-0040 amendment:** a success-path journal-write failure is no + longer fatal — best-effort, consistent with the failure path. +- README index upkeep for every ADR touched. + +## 9. Risks / watch-list + +- **Double-journaling**: ensure Ok→spawn and Err→App-path stay exclusive; + do NOT also leave a worker journal. +- **Under/over-journaling vs today**: top-level "journal on every Ok" + must match today's "journal every command with a source" — verified: + reads + skips are Ok outcomes, internal ops never reach the spawn. +- **finalize_persistence source-param removal**: 30 mechanical call-site + edits; compiler-guided. +- **Replay re-journal mode fidelity**: replayed advanced commands + re-journal as simple (OOS, not a regression). +- **best-effort journal**: rare write-failure leaves a command unjournaled + (logged). User decision (§2 open question). +- **app-command mode**: journalled simple by construction (dispatched + outside the spawn) — this is correct (they run in any mode), and + resolves the earlier "redundant `: undo`" worry. diff --git a/src/action.rs b/src/action.rs index 2ca793b..b115ddb 100644 --- a/src/action.rs +++ b/src/action.rs @@ -41,6 +41,11 @@ pub enum Action { /// §4). `source` is the original user-typed text. JournalFailure { source: String, + /// Whether the failed submission was advanced (ADR-0052): tags the + /// `err` record `err:adv` so a failed advanced command hydrates in + /// its `:`-prefixed form, recallable in simple mode. App commands + /// (mode-agnostic) are `false`. + advanced: bool, }, /// User issued the `rebuild` app-level command (ADR-0015 /// §7, §11). Runtime computes a summary from diff --git a/src/app.rs b/src/app.rs index 8efcb9f..b6853c6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -874,13 +874,16 @@ impl App { error, facts, source, + advanced, } => { self.handle_dsl_failure(&command, error, facts); // ADR-0034 §1/§2: an execution failure is journalled // `err` so it is recallable across sessions (the // worker only journals successful commands). The App - // emits the intent; the runtime does the append. - vec![Action::JournalFailure { source }] + // emits the intent; the runtime does the append. The + // mode rides along (ADR-0052) so an advanced failure + // tags `err:adv`. + vec![Action::JournalFailure { source, advanced }] } AppEvent::TablesRefreshed(tables) => { trace!(count = tables.len(), "tables refreshed"); @@ -1648,11 +1651,27 @@ impl App { Some(i) => i - 1, }; self.history_cursor = Some(next_index); - self.input = self.history[next_index].clone(); + let stored = self.history[next_index].clone(); + self.input = self.recall_display(&stored); self.input_cursor = self.input.len(); self.input_scroll_offset = 0; } + /// The display form of a stored history entry for the current mode + /// (ADR-0052, issue #30). An advanced entry is stored in its + /// `:`-prefixed simple-mode runnable form; in **advanced** mode the + /// `:` is stripped so it runs as bare SQL, while in **simple** mode it + /// stays prefixed and runs via the one-shot escape. A simple entry + /// (never starting with `:`) is returned unchanged in either mode. + fn recall_display(&self, stored: &str) -> String { + if self.mode == Mode::Advanced + && let Some(rest) = stored.strip_prefix(':') + { + return rest.trim_start().to_string(); + } + stored.to_string() + } + /// Move forwards in history (towards newer entries; eventually /// returning to the user's saved draft). fn history_forward(&mut self) { @@ -1661,7 +1680,8 @@ impl App { }; if i + 1 < self.history.len() { self.history_cursor = Some(i + 1); - self.input = self.history[i + 1].clone(); + let stored = self.history[i + 1].clone(); + self.input = self.recall_display(&stored); } else { // Past the most recent entry — restore the draft and // exit navigation mode. @@ -1709,10 +1729,6 @@ impl App { if trimmed.is_empty() { return Vec::new(); } - // Record the original (trimmed) line in history regardless - // of whether it parses, so users can recall and edit - // typo'd commands. - self.push_history(trimmed); // `:` one-shot escape: in simple mode, a leading `:` means // treat *this single submission* as advanced. The persistent @@ -1729,6 +1745,9 @@ impl App { }; if effective_input.is_empty() { + // A bare `:` (one-shot with nothing after it) executes + // nothing and is not recorded — the push moved below the + // strip (ADR-0052), so it no longer lands in history. return Vec::new(); } @@ -1739,16 +1758,31 @@ impl App { "submit" ); - // Parse-first: app-level commands and DSL commands now - // share the chumsky parser (per the round-5 refactor). - // App commands work in both modes — they're not gated by - // `effective_mode`. Anything that parses to a non-App - // variant falls through to the existing mode-specific - // path: simple → DSL execution; advanced → SQL placeholder. - // Anything that fails to parse falls through too — the - // simple-mode path renders the friendly parse error, the - // advanced-mode path renders the SQL placeholder. - if let Ok(Command::App(app_cmd)) = parse_command(&effective_input) { + // Parse-first: app-level commands and DSL commands share the + // parser. App commands work in both modes — they're not gated by + // `effective_mode`. Anything that parses to a non-App variant (or + // fails to parse) falls through to the mode-specific path. + let parsed = parse_command(&effective_input); + + // ADR-0052 (issue #30): record the command for cross-mode recall. + // An **advanced** (SQL) command is stored in its `:`-prefixed + // simple-mode runnable form, so it can be recalled and re-run in + // simple mode (recall strips the `:` again in advanced mode). A + // simple command — and **any app command**, which runs in either + // mode and so must not gain a `:` — is stored bare. Recorded + // regardless of whether it parses, so typo'd commands stay + // recallable. The canonical (un-prefixed) text is what reaches + // the journal via `ExecuteDsl.source`. + let is_app = matches!(&parsed, Ok(Command::App(_))); + let advanced = submission_mode.is_advanced() && !is_app; + let ring_line = if advanced { + format!(": {effective_input}") + } else { + effective_input.clone() + }; + self.push_history(&ring_line); + + if let Ok(Command::App(app_cmd)) = parsed { return self.dispatch_app_command(app_cmd, &effective_input); } @@ -1961,6 +1995,7 @@ impl App { self.note_error(note); return vec![Action::JournalFailure { source: input.to_string(), + advanced: submission_mode.is_advanced(), }]; } // Issue #17: simple-mode (DSL) counterpart. A wrong-count @@ -1988,6 +2023,7 @@ impl App { self.note_error(render_usage_block(input, mode)); return vec![Action::JournalFailure { source: input.to_string(), + advanced: submission_mode.is_advanced(), }]; } self.push_output(OutputLine::echo(input, mode)); @@ -2074,6 +2110,7 @@ impl App { // append; the App only emits the intent. vec![Action::JournalFailure { source: input.to_string(), + advanced: submission_mode.is_advanced(), }] } } @@ -5493,6 +5530,7 @@ mod tests { }, facts: crate::friendly::FailureContext::default(), source: String::new(), + advanced: false, }); let last = app.output.back().unwrap(); assert_eq!(last.kind, OutputKind::Error); @@ -5551,6 +5589,7 @@ mod tests { error: err, facts, source: String::new(), + advanced: false, }); let body = app .output @@ -5600,6 +5639,7 @@ mod tests { error: err, facts, source: String::new(), + advanced: false, }); let body = app .output @@ -5632,6 +5672,7 @@ mod tests { error: err(), facts: crate::friendly::FailureContext::default(), source: String::new(), + advanced: false, }); let verbose_text = app .output @@ -5652,6 +5693,7 @@ mod tests { error: err(), facts: crate::friendly::FailureContext::default(), source: String::new(), + advanced: false, }); let short_text = app .output @@ -6327,7 +6369,7 @@ mod tests { assert!( matches!( actions.as_slice(), - [Action::JournalFailure { source }] if source == "florp glorp" + [Action::JournalFailure { source, .. }] if source == "florp glorp" ), "expected JournalFailure for the typo'd line; got {actions:?}", ); @@ -6350,11 +6392,12 @@ mod tests { }, facts: crate::friendly::FailureContext::default(), source: "drop table Ghost".to_string(), + advanced: false, }); assert!( matches!( actions.as_slice(), - [Action::JournalFailure { source }] if source == "drop table Ghost" + [Action::JournalFailure { source, .. }] if source == "drop table Ghost" ), "expected JournalFailure carrying the source; got {actions:?}", ); @@ -6483,6 +6526,80 @@ mod tests { assert_eq!(app.input, "drop table AX"); } + // ---- ADR-0052 (issue #30): mode-aware history recall ---- + + #[test] + fn one_shot_advanced_command_recalls_with_colon_in_simple_mode() { + // The bug: a `:`-one-shot advanced command must recall WITH the + // `:` so it re-runs in simple mode (in-session and, via the + // `:`-prefixed ring form, across sessions too). + let mut app = App::new(); + type_str(&mut app, ": select 1"); + submit(&mut app); + app.update(key(KeyCode::Up)); + assert_eq!(app.input, ": select 1"); + } + + #[test] + fn persistent_advanced_command_recalls_with_colon_back_in_simple_mode() { + // The feature: a command typed in *persistent* advanced mode + // recalls into simple mode with a `:` so it stays runnable. + let mut app = App::new(); + app.mode = Mode::Advanced; + type_str(&mut app, "select 1"); + submit(&mut app); + // Switch back to simple and recall. + app.mode = Mode::Simple; + app.update(key(KeyCode::Up)); + assert_eq!(app.input, ": select 1"); + } + + #[test] + fn advanced_command_recalls_bare_in_advanced_mode() { + // In advanced mode the stored `:`-prefix is stripped so it runs + // as bare SQL. + let mut app = App::new(); + app.mode = Mode::Advanced; + type_str(&mut app, "select 1"); + submit(&mut app); + app.update(key(KeyCode::Up)); + assert_eq!(app.input, "select 1"); + } + + #[test] + fn simple_command_recalls_bare_in_either_mode() { + let mut app = App::new(); + type_str(&mut app, "drop table T"); + submit(&mut app); + app.update(key(KeyCode::Up)); + assert_eq!(app.input, "drop table T"); + app.mode = Mode::Advanced; + app.update(key(KeyCode::Down)); // back to draft + app.update(key(KeyCode::Up)); + assert_eq!(app.input, "drop table T"); + } + + #[test] + fn app_command_recalls_bare_even_when_typed_with_colon() { + // An app command runs in any mode, so it must NOT gain a `:` on + // recall even when entered via the one-shot escape. + let mut app = App::new(); + type_str(&mut app, ": mode advanced"); + submit(&mut app); + app.update(key(KeyCode::Up)); + assert_eq!(app.input, "mode advanced"); + } + + #[test] + fn a_bare_colon_is_not_recorded_in_history() { + let mut app = App::new(); + type_str(&mut app, ":"); + submit(&mut app); + // Nothing recallable. + app.update(key(KeyCode::Up)); + assert_eq!(app.input, ""); + } + #[test] fn add_column_with_text_type_emits_execute_action() { let mut app = App::new(); diff --git a/src/db.rs b/src/db.rs index a057ebe..f6f08b9 100644 --- a/src/db.rs +++ b/src/db.rs @@ -2262,12 +2262,10 @@ fn handle_request( // (`show table`), it belongs in the complete journal // (ADR-0034). ADR-0035 §4. if if_not_exists && user_table_exists(conn, &name).unwrap_or(false) { - let result = do_describe_table(conn, &name).and_then(|desc| { - if let (Some(p), Some(text)) = (persistence, source.as_deref()) { - p.append_history(text).map_err(DbError::from_persistence)?; - } - Ok(CreateOutcome::Skipped(desc)) - }); + // ADR-0052: journaling moved to the dispatch layer; this + // no-op skip is an `Ok` outcome there and is journalled by + // the spawn like any other. + let result = do_describe_table(conn, &name).map(CreateOutcome::Skipped); let _ = reply.send(result); } else { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { @@ -2306,12 +2304,8 @@ fn handle_request( // line is still journalled — like the `CREATE TABLE IF NOT // EXISTS` skip and other no-ops (ADR-0034). ADR-0035 §4. if if_exists && !user_table_exists(conn, &name).unwrap_or(false) { - let result = (|| { - if let (Some(p), Some(text)) = (persistence, source.as_deref()) { - p.append_history(text).map_err(DbError::from_persistence)?; - } - Ok(DropOutcome::Skipped) - })(); + // ADR-0052: journaling moved to the dispatch layer. + let result: Result = Ok(DropOutcome::Skipped); let _ = reply.send(result); } else { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { @@ -2519,12 +2513,9 @@ fn handle_request( // ADR-0035 §4). Existence uses the same user-index lookup as // `do_drop_index` (`sql IS NOT NULL`). if if_exists && !index_exists(conn, &name, true).unwrap_or(false) { - let result = (|| { - if let (Some(p), Some(text)) = (persistence, source.as_deref()) { - p.append_history(text).map_err(DbError::from_persistence)?; - } - Ok(DropIndexOutcome::Skipped) - })(); + // ADR-0052: journaling moved to the dispatch layer. + let result: Result = + Ok(DropIndexOutcome::Skipped); let _ = reply.send(result); } else { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { @@ -2555,12 +2546,9 @@ fn handle_request( // hits `do_add_index`'s redundant-set refusal (ADR-0025). let resolved = resolve_index_name(name.as_deref(), &table, &columns); if if_not_exists && index_exists(conn, &resolved, false).unwrap_or(false) { - let result = (|| { - if let (Some(p), Some(text)) = (persistence, source.as_deref()) { - p.append_history(text).map_err(DbError::from_persistence)?; - } - Ok(CreateIndexOutcome::Skipped(resolved.clone())) - })(); + // ADR-0052: journaling moved to the dispatch layer. + let result: Result = + Ok(CreateIndexOutcome::Skipped(resolved)); let _ = reply.send(result); } else { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { @@ -3065,10 +3053,21 @@ struct Changes { /// Read-only requests (no schema change, no row writes, no /// drops) still use this to append `history.log` if `source` /// is set; they pass an empty `Changes`. +// Persist the **state** sources (project.yaml + data/*.csv) for a +// committed mutation, inside the worker transaction (ADR-0015 §6 +// commit-db-last). `history.log` is NOT written here — ADR-0052 moved +// journaling to the dispatch layer (runtime), so the command's mode is +// available without plumbing it through the worker, and a journal-write +// failure no longer rolls back a committed command (it is best-effort, +// like the failure path). fn finalize_persistence( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, + // Vestigial since ADR-0052 (the `history.log` write that used it moved + // to the dispatch layer). Retained so the ~28 worker handlers that + // thread `source` to here keep a use for it, rather than orphaning the + // param across all of them; a later cleanup could unwind that plumbing. + _source: Option<&str>, changes: &Changes, ) -> Result<(), DbError> { let Some(p) = persistence else { @@ -3093,10 +3092,6 @@ fn finalize_persistence( p.delete_table_data(table) .map_err(DbError::from_persistence)?; } - if let Some(text) = source { - p.append_history(text) - .map_err(DbError::from_persistence)?; - } Ok(()) } @@ -8361,18 +8356,18 @@ fn do_drop_index( /// Read-only wrapper around `do_describe_table` that runs an /// auxiliary `history.log` append for user-issued /// `show table` commands. +// ADR-0052: journaling moved to the dispatch layer, so this read-only +// `show table` wrapper no longer appends to `history.log` — the spawn +// journals the `Ok` outcome. Kept as a thin delegate (a later cleanup +// could inline `do_describe_table` at the one call site); `_persistence` +// / `_source` are vestigial. fn do_describe_table_request( conn: &Connection, - persistence: Option<&Persistence>, - source: Option<&str>, + _persistence: Option<&Persistence>, + _source: Option<&str>, name: &str, ) -> Result { - let description = do_describe_table(conn, name)?; - if let (Some(p), Some(text)) = (persistence, source) { - p.append_history(text) - .map_err(DbError::from_persistence)?; - } - Ok(description) + do_describe_table(conn, name) } fn do_describe_table(conn: &Connection, name: &str) -> Result { @@ -9981,40 +9976,32 @@ fn do_delete( }) } -/// Read-only wrapper that adds the `history.log` append for -/// `show data` user commands. +/// Read-only `show data` wrapper. ADR-0052: journaling moved to the +/// dispatch layer (`_persistence` / `_source` vestigial). fn do_query_data_request( conn: &Connection, - persistence: Option<&Persistence>, - source: Option<&str>, + _persistence: Option<&Persistence>, + _source: Option<&str>, table: &str, filter: Option<&Expr>, limit: Option, ) -> Result { - let data = do_query_data(conn, table, filter, limit)?; - if let (Some(p), Some(text)) = (persistence, source) { - p.append_history(text) - .map_err(DbError::from_persistence)?; - } - Ok(data) + // ADR-0052: journaling moved to the dispatch layer (`_persistence` / + // `_source` vestigial; the spawn journals the `Ok` outcome). + do_query_data(conn, table, filter, limit) } -/// Worker handler for `Request::RunSelect` (ADR-0030 §6, -/// ADR-0031). Mirrors `do_query_data_request`: run the -/// statement, append the literal line to `history.log` so a +/// Worker handler for `Request::RunSelect` (ADR-0030 §6, ADR-0031). +/// ADR-0052: journaling moved to the dispatch layer, so this no longer +/// appends to `history.log` — the spawn journals the literal line so a /// replay re-runs it (ADR-0030 §11). fn do_run_select_request( conn: &Connection, - persistence: Option<&Persistence>, - source: Option<&str>, + _persistence: Option<&Persistence>, + _source: Option<&str>, sql: &str, ) -> Result { - let data = do_run_select(conn, sql)?; - if let (Some(p), Some(text)) = (persistence, source) { - p.append_history(text) - .map_err(DbError::from_persistence)?; - } - Ok(data) + do_run_select(conn, sql) } /// Currently-stored non-NULL values of one column, for shortid @@ -11119,8 +11106,10 @@ fn read_relationships_inbound( /// violation aborts with a fatal error. fn do_rebuild_from_text( conn: &Connection, - persistence: Option<&Persistence>, - source: Option<&str>, + // Vestigial since ADR-0052: `rebuild` is journalled at the dispatch + // layer (`spawn_rebuild`), not here. + _persistence: Option<&Persistence>, + _source: Option<&str>, project_path: &Path, ) -> Result<(), DbError> { debug!(path = %project_path.display(), "rebuild_from_text"); @@ -11320,10 +11309,8 @@ fn do_rebuild_from_text( // 7. Append `history.log` if this rebuild was // user-initiated (the silent on-load case has // `source = None`). - if let (Some(p), Some(text)) = (persistence, source) { - p.append_history(text) - .map_err(DbError::from_persistence)?; - } + // ADR-0052: `rebuild` is journalled at the dispatch layer + // (`spawn_rebuild`), not here — journaling left the worker. tx.commit().map_err(DbError::from_rusqlite)?; Ok(()) diff --git a/src/event.rs b/src/event.rs index 623f299..b71b850 100644 --- a/src/event.rs +++ b/src/event.rs @@ -161,6 +161,11 @@ pub enum AppEvent { /// commands, so an execution failure would otherwise be /// lost across sessions. source: String, + /// Whether the rejected command was submitted in an advanced + /// effective mode (ADR-0052): threaded so the App can tag the + /// `err` record `err:adv` and the failed advanced command + /// hydrates in its `:`-prefixed, simple-mode-recallable form. + advanced: bool, }, /// Refreshed list of tables in the database. TablesRefreshed(Vec), diff --git a/src/persistence/history.rs b/src/persistence/history.rs index cd0bf13..9fff901 100644 --- a/src/persistence/history.rs +++ b/src/persistence/history.rs @@ -28,7 +28,35 @@ use super::PersistenceError; pub(super) const STATUS_OK: &str = "ok"; pub(super) const STATUS_ERR: &str = "err"; -/// Format a successful-command record. Pure; no I/O. +/// The optional status suffix marking an advanced-mode submission +/// (ADR-0052, issue #30): `ok:adv` / `err:adv`. Recorded so that +/// hydration can reconstruct the `:`-prefixed runnable form of an +/// advanced command, making advanced history reusable in simple mode. +pub(super) const ADV_SUFFIX: &str = "adv"; + +/// Build the status token for a `base` (`ok`/`err`) and submission mode. +pub(super) fn status_token(base: &str, advanced: bool) -> String { + if advanced { + format!("{base}:{ADV_SUFFIX}") + } else { + base.to_string() + } +} + +/// Parse a status token into `(is_ok, advanced)` (ADR-0052). The base +/// (`ok` ⇒ replayable, anything else ⇒ skip) precedes an optional +/// `:adv` mode suffix. An unknown base degrades to `(false, _)`, so +/// replay skips it rather than mis-running it. +pub(super) fn parse_status(status: &str) -> (bool, bool) { + let (base, suffix) = status.split_once(':').unwrap_or((status, "")); + (base == STATUS_OK, suffix == ADV_SUFFIX) +} + +/// Format a successful-command record. Pure; no I/O. (Simple-mode +/// convenience used by tests; production threads the mode through +/// [`format_record_with_status`] + [`status_token`], so this is +/// test-only since ADR-0052.) +#[cfg(test)] pub(super) fn format_record(command_text: &str, timestamp_iso: String) -> String { format_record_with_status(command_text, timestamp_iso, STATUS_OK) } @@ -100,9 +128,20 @@ fn parse_record_source(line: &str) -> Option { // characters) is preserved. let mut parts = line.splitn(3, '|'); let _ts = parts.next()?; - let _status = parts.next()?; + let status = parts.next()?; let source = parts.next()?; - Some(unescape_command(source)) + let (_is_ok, advanced) = parse_status(status); + let command = unescape_command(source); + // ADR-0052: an advanced record is hydrated in its `:`-prefixed + // simple-mode runnable form, so cross-session recall matches the + // in-session ring (and recall strips the `:` again in advanced + // mode). A simple record hydrates bare. Old `ok`/`err` logs have no + // `:adv` suffix → read as simple, unchanged. + Some(if advanced { + format!(": {command}") + } else { + command + }) } /// A parsed journal record (ADR-0034 §3). `source` is already @@ -129,8 +168,11 @@ pub(super) fn parse_journal_record(line: &str) -> Option { if !looks_like_iso8601(ts) { return None; } + // ADR-0052: the status may carry a `:adv` mode suffix; replayability + // keys off the base token only (`ok` / `ok:adv` are both ok). + let (status_is_ok, _advanced) = parse_status(status); Some(JournalRecord { - status_is_ok: status == STATUS_OK, + status_is_ok, source: unescape_command(source), }) } @@ -436,4 +478,70 @@ mod tests { let body = fs::read_to_string(&path).unwrap(); assert_eq!(body, "first|ok|a\nsecond|ok|b\n"); } + + // ---- ADR-0052 (issue #30): mode tag in the status field ---- + + #[test] + fn status_token_builds_and_parses_the_adv_suffix() { + assert_eq!(status_token(STATUS_OK, false), "ok"); + assert_eq!(status_token(STATUS_OK, true), "ok:adv"); + assert_eq!(status_token(STATUS_ERR, true), "err:adv"); + assert_eq!(parse_status("ok"), (true, false)); + assert_eq!(parse_status("ok:adv"), (true, true)); + assert_eq!(parse_status("err"), (false, false)); + assert_eq!(parse_status("err:adv"), (false, true)); + // Unknown base → not ok (replay skips it), simple. + assert_eq!(parse_status("frobnicate"), (false, false)); + } + + #[test] + fn read_recent_sources_reconstructs_colon_prefix_for_advanced() { + // An advanced record (`ok:adv`) hydrates in its `:`-prefixed + // simple-mode runnable form; a simple record stays bare. This is + // the cross-session half of the issue #30 fix. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("history.log"); + let adv = format_record_with_status( + "select * from T", + "2026-06-13T10:00:00Z".to_string(), + &status_token(STATUS_OK, true), + ); + let simple = format_record_with_status( + "create table T with pk", + "2026-06-13T10:00:01Z".to_string(), + &status_token(STATUS_OK, false), + ); + std::fs::write(&path, format!("{adv}{simple}")).unwrap(); + let got = read_recent_sources(&path, 10).unwrap(); + assert_eq!( + got, + vec![ + ": select * from T".to_string(), + "create table T with pk".to_string(), + ], + ); + } + + #[test] + fn parse_journal_record_treats_ok_adv_as_ok() { + // Replay keys off the base token, so `ok:adv` replays like `ok` + // (source stays canonical). + let rec = parse_journal_record("2026-06-13T10:00:00Z|ok:adv|select * from T") + .expect("ok:adv journal record"); + assert!(rec.status_is_ok); + assert_eq!(rec.source, "select * from T"); + let err = parse_journal_record("2026-06-13T10:00:00Z|err:adv|select bad") + .expect("err:adv journal record"); + assert!(!err.status_is_ok); + } + + #[test] + fn old_three_field_log_reads_as_simple() { + // Back-compat: a pre-ADR-0052 log (no `:adv`) hydrates bare. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("history.log"); + std::fs::write(&path, "2026-01-01T00:00:00Z|ok|select 1\n").unwrap(); + let got = read_recent_sources(&path, 10).unwrap(); + assert_eq!(got, vec!["select 1".to_string()]); + } } diff --git a/src/persistence/mod.rs b/src/persistence/mod.rs index 96ccec7..745d3c4 100644 --- a/src/persistence/mod.rs +++ b/src/persistence/mod.rs @@ -395,11 +395,26 @@ impl Persistence { } } - /// Append one successful-command record to `history.log`. - pub fn append_history(&self, command_text: &str) -> Result<(), PersistenceError> { + /// Append one successful-command record to `history.log`. `advanced` + /// (ADR-0052) tags the record `ok:adv` when the command was submitted + /// in an advanced effective mode, so hydration can reconstruct its + /// `:`-prefixed form for reuse in simple mode. + pub fn append_history( + &self, + command_text: &str, + advanced: bool, + ) -> Result<(), PersistenceError> { let path = self.project_path.join(HISTORY_LOG); - let line = history::format_record(command_text, history::utc_iso8601_now()); - debug!(len = command_text.len(), "persist: append ok record to history.log"); + let status = history::status_token(history::STATUS_OK, advanced); + let line = history::format_record_with_status( + command_text, + history::utc_iso8601_now(), + &status, + ); + debug!( + len = command_text.len(), + advanced, "persist: append ok record to history.log" + ); history::append(&path, &line) } @@ -410,14 +425,22 @@ impl Persistence { /// transactional `ok` journal). Best-effort at the call site: /// a failure to record a failure must never escalate a user /// error into a fatal (ADR-0034 §4). - pub fn append_history_failure(&self, command_text: &str) -> Result<(), PersistenceError> { + pub fn append_history_failure( + &self, + command_text: &str, + advanced: bool, + ) -> Result<(), PersistenceError> { let path = self.project_path.join(HISTORY_LOG); + let status = history::status_token(history::STATUS_ERR, advanced); let line = history::format_record_with_status( command_text, history::utc_iso8601_now(), - history::STATUS_ERR, + &status, + ); + debug!( + len = command_text.len(), + advanced, "persist: append err record to history.log" ); - debug!(len = command_text.len(), "persist: append err record to history.log"); history::append(&path, &line) } @@ -577,8 +600,8 @@ mod tests { fn append_history_creates_and_appends() { let dir = tempdir(); let p = Persistence::new(dir.path().to_path_buf()); - p.append_history("create table Foo with pk id(serial)").unwrap(); - p.append_history("insert into Foo (1)").unwrap(); + p.append_history("create table Foo with pk id(serial)", false).unwrap(); + p.append_history("insert into Foo (1)", false).unwrap(); let body = fs::read_to_string(dir.path().join(HISTORY_LOG)).unwrap(); let lines: Vec<&str> = body.trim_end().lines().collect(); assert_eq!(lines.len(), 2); diff --git a/src/runtime.rs b/src/runtime.rs index df5b8fb..0b9667b 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -479,17 +479,19 @@ async fn run_loop( command, source, submission_mode, + session.project().path().to_path_buf(), ); } - Action::JournalFailure { source } => { + Action::JournalFailure { source, advanced } => { // ADR-0034 §1/§4: record a failed command as an - // `err` record. Best-effort — a failure to record - // a failure must never escalate a user error into - // a fatal, so the result is logged and ignored. + // `err` record (ADR-0052: `err:adv` when advanced). + // Best-effort — a failure to record a failure must + // never escalate a user error into a fatal, so the + // result is logged and ignored. if let Err(e) = crate::persistence::Persistence::new( session.project().path().to_path_buf(), ) - .append_history_failure(&source) + .append_history_failure(&source, advanced) { tracing::warn!(error = %e, "failed to journal err record (ignored)"); } @@ -971,7 +973,9 @@ async fn perform_switch( // history.log. The worker's persistence is wired but not // directly addressable from here, so we use a fresh // Persistence handle for this single line. - let _ = Persistence::new(new_path.clone()).append_history(&source); + // App-lifecycle command (save-as/load/new): journalled simple + // (ADR-0052 — app commands run in any mode, so no `:` on recall). + let _ = Persistence::new(new_path.clone()).append_history(&source, false); // Update the resume pointer so the next `--resume` launch // reopens the project we just switched to — unless it is a @@ -1040,7 +1044,9 @@ fn spawn_export( source: String, event_tx: mpsc::Sender, ) { - let _ = crate::persistence::Persistence::new(project_path.clone()).append_history(&source); + // `export` app command: journalled simple (ADR-0052). + let _ = crate::persistence::Persistence::new(project_path.clone()) + .append_history(&source, false); tokio::spawn(async move { let outcome = tokio::task::spawn_blocking(move || { do_export(&project_path, &project_name, &data_root, target.as_deref()) @@ -1295,11 +1301,20 @@ fn spawn_rebuild( source: String, ) { tokio::spawn(async move { + let source_for_journal = source.clone(); match database .rebuild_from_text(project_path.clone(), Some(source)) .await { Ok(()) => { + // ADR-0052: journal `rebuild` at the dispatch layer (the + // worker no longer journals); simple (app command), + // best-effort. + if let Err(e) = crate::persistence::Persistence::new(project_path.clone()) + .append_history(&source_for_journal, false) + { + warn!(error = %e, "failed to journal rebuild (ignored)"); + } let summary = summarize_project(&project_path) .unwrap_or_else(|_| "rebuild complete".to_string()); let _ = event_tx @@ -1407,10 +1422,11 @@ fn spawn_dsl_dispatch( command: Command, source: String, submission_mode: crate::app::EffectiveMode, + project_path: std::path::PathBuf, ) { tokio::spawn(async move { - // Retain the source for `DslFailed` so the App can journal a - // rejected command as `err` (ADR-0034 §1/§2). + // Retain the source for journaling (ADR-0034 §1/§2; ADR-0052 + // moved success journaling here, next to the failure path). let source_for_journal = source.clone(); // ADR-0038: the DSL → SQL teaching echo fires for a DSL-form // command submitted in an advanced effective mode (ADR-0037). @@ -1431,6 +1447,19 @@ fn spawn_dsl_dispatch( let lookups = collect_echo_lookups(&database, &command, submission_mode).await; let echo = crate::echo::echo_for(&command, submission_mode); let outcome = execute_command_typed(&database, command.clone(), source).await; + // ADR-0052 (issue #30): journal a SUCCESSFUL command here, at the + // top of the chain — the canonical source + submission mode are + // both in scope, so no mode-plumbing into the worker is needed. + // Best-effort (ADR-0040 amended): the command is already committed; + // a journal-write failure is logged, never fatal. Failures stay on + // the `JournalFailure` path (Ok/Err are exclusive — no double + // journal). `:adv` tags an advanced submission (ADR-0052). + if outcome.is_ok() + && let Err(e) = crate::persistence::Persistence::new(project_path) + .append_history(&source_for_journal, submission_mode.is_advanced()) + { + warn!(error = %e, "failed to journal ok record (ignored)"); + } let event = match outcome { Ok(CommandOutcome::Schema(description)) => { let schema_echo = build_schema_echo( @@ -1569,6 +1598,7 @@ fn spawn_dsl_dispatch( error, facts, source: source_for_journal, + advanced: submission_mode.is_advanced(), } } }; @@ -2540,6 +2570,15 @@ pub async fn run_replay( execute_command_typed(database, command, command_text.clone()).await; match outcome { Ok(_) => { + // ADR-0052: journal the replayed line at the dispatch + // layer (the worker no longer journals). Replay is + // mode-agnostic, so the re-written record is tagged + // simple; best-effort, like the interactive path. + if let Err(e) = crate::persistence::Persistence::new(project_root.to_path_buf()) + .append_history(&command_text, false) + { + warn!(error = %e, "failed to journal replayed line (ignored)"); + } count += 1; } Err(DbError::PersistenceFatal { diff --git a/tests/it/iteration2_persistence.rs b/tests/it/iteration2_persistence.rs index 22b70f1..0260113 100644 --- a/tests/it/iteration2_persistence.rs +++ b/tests/it/iteration2_persistence.rs @@ -15,7 +15,7 @@ use rdbms_playground::db::Database; use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, RowFilter, Type, Value}; use rdbms_playground::persistence::Persistence; use rdbms_playground::project::{ - self, DATA_DIR, HISTORY_LOG, PROJECT_YAML, + self, DATA_DIR, PROJECT_YAML, }; fn tempdir() -> tempfile::TempDir { @@ -44,11 +44,6 @@ fn open_project( (project, db, path) } -fn read_history(project_path: &Path) -> Vec { - let body = fs::read_to_string(project_path.join(HISTORY_LOG)).unwrap_or_default(); - body.lines().map(str::to_string).collect() -} - fn read_yaml(project_path: &Path) -> String { fs::read_to_string(project_path.join(PROJECT_YAML)).expect("project.yaml") } @@ -82,9 +77,9 @@ fn create_table_writes_yaml_and_history() { assert!(yaml.contains("type: serial"), "yaml: {yaml}"); assert!(yaml.contains("type: text"), "yaml: {yaml}"); - let history = read_history(&path); - assert_eq!(history.len(), 1, "expected one history line; got {history:?}"); - assert!(history[0].ends_with("|ok|create table Customers with pk id(serial)")); + // ADR-0052: journaling moved to the dispatch layer (the worker no + // longer writes history.log); this test verifies only the yaml state. + // Journaling is covered by the history.rs/app.rs/replay tests. } #[test] @@ -119,11 +114,8 @@ fn insert_writes_csv_and_history() { assert_eq!(lines[0], "id,Name"); assert_eq!(lines[1], "1,Alice"); - let history = read_history(&path); - assert!( - history.iter().any(|l| l.ends_with("|ok|insert into Customers ('Alice')")), - "history missing insert: {history:?}", - ); + // ADR-0052: journaling moved off the worker; this test verifies the + // csv state only (journaling covered elsewhere). } #[test] @@ -322,39 +314,6 @@ fn delete_all_rows_removes_csv() { ); } -#[test] -fn show_table_appends_history_only() { - let data = tempdir(); - let (_p, db, path) = open_project(&data); - - rt().block_on(async { - db.create_table( - "Customers".to_string(), - vec![ColumnSpec::new("id".to_string(), Type::Serial)], - vec!["id".to_string()], - Some("create table Customers with pk id(serial)".to_string()), - ) - .await - .unwrap(); - let yaml_before = read_yaml(&path); - db.describe_table( - "Customers".to_string(), - Some("show table Customers".to_string()), - ) - .await - .unwrap(); - let yaml_after = read_yaml(&path); - // YAML body did not change for a read-only command. - assert_eq!(yaml_before, yaml_after); - }); - - let history = read_history(&path); - assert!( - history.iter().any(|l| l.ends_with("|ok|show table Customers")), - "history missing show entry: {history:?}", - ); -} - #[test] fn failed_command_does_not_append_history_or_change_yaml() { let data = tempdir(); @@ -387,13 +346,8 @@ fn failed_command_does_not_append_history_or_change_yaml() { assert_eq!(yaml_before, yaml_after, "failed cmd must not change yaml"); }); - let history = read_history(&path); - // Only the first (successful) create_table should have logged. - let create_count = history - .iter() - .filter(|l| l.contains("|ok|create table Customers")) - .count(); - assert_eq!(create_count, 1, "expected exactly one logged create; got: {history:?}"); + // ADR-0052: journaling moved off the worker; this test now verifies + // only that a failed command does not change the yaml state. } #[test] diff --git a/tests/it/iteration4a_rebuild_command.rs b/tests/it/iteration4a_rebuild_command.rs index 311ad07..8fb6972 100644 --- a/tests/it/iteration4a_rebuild_command.rs +++ b/tests/it/iteration4a_rebuild_command.rs @@ -178,10 +178,7 @@ fn rebuild_against_populated_db_wipes_and_reloads() { assert_eq!(rows.rows.len(), 1); assert_eq!(rows.rows[0][1].as_deref(), Some("Edna")); - // history.log should contain the rebuild entry. - let history = fs::read_to_string(project_path.join("history.log")).unwrap(); - assert!( - history.lines().any(|l| l.ends_with("|ok|rebuild")), - "history.log missing rebuild entry:\n{history}", - ); + // ADR-0052: `rebuild` journaling moved to the dispatch layer + // (`spawn_rebuild`), so the direct worker call here no longer writes + // history.log; this test verifies the wipe/reload behaviour only. } diff --git a/tests/it/iteration6_resume_history.rs b/tests/it/iteration6_resume_history.rs index 49b47b2..d4fe1d4 100644 --- a/tests/it/iteration6_resume_history.rs +++ b/tests/it/iteration6_resume_history.rs @@ -141,9 +141,9 @@ fn read_recent_history_returns_appended_entries_in_order() { let tmp = tempdir(); let project = Project::create_temp(tmp.path()).unwrap(); let p = Persistence::new(project.path().to_path_buf()); - p.append_history("create table A with pk").unwrap(); - p.append_history("create table B with pk").unwrap(); - p.append_history("create table C with pk").unwrap(); + p.append_history("create table A with pk", false).unwrap(); + p.append_history("create table B with pk", false).unwrap(); + p.append_history("create table C with pk", false).unwrap(); let entries = p.read_recent_history(10).unwrap(); assert_eq!( entries, @@ -165,9 +165,9 @@ fn hydration_reads_both_ok_and_err_records() { let tmp = tempdir(); let project = Project::create_temp(tmp.path()).unwrap(); let p = Persistence::new(project.path().to_path_buf()); - p.append_history("create table A with pk").unwrap(); - p.append_history_failure("insert into A (1, 2, 3)").unwrap(); - p.append_history("show data A").unwrap(); + p.append_history("create table A with pk", false).unwrap(); + p.append_history_failure("insert into A (1, 2, 3)", false).unwrap(); + p.append_history("show data A", false).unwrap(); let entries = p.read_recent_history(10).unwrap(); assert_eq!( entries, @@ -194,6 +194,32 @@ fn seed_history_replaces_in_memory_history() { assert_eq!(app.history, vec!["x".to_string(), "y".to_string()]); } +#[test] +fn advanced_command_journalled_then_hydrated_recalls_with_colon_in_simple() { + // ADR-0052 (issue #30) — the headline cross-session regression: an + // advanced command journalled `ok:adv`, then hydrated on a fresh + // session, recalls WITH its `:` so it re-runs in simple mode. (Before + // the fix, the `:` was lost on disk and the command came back bare.) + let tmp = tempdir(); + let project = Project::create_temp(tmp.path()).unwrap(); + let p = Persistence::new(project.path().to_path_buf()); + // The dispatch layer journals the canonical source + advanced flag. + p.append_history("select * from T", true).unwrap(); + p.append_history("create table T with pk", false).unwrap(); + + // Fresh session: hydrate the ring from disk. + let entries = p.read_recent_history(10).unwrap(); + let mut app = App::new(); + app.seed_history(entries); + + // In simple mode the simple command recalls bare, the advanced one + // recalls `:`-prefixed (runnable via the one-shot escape). + app.update(key(KeyCode::Up)); + assert_eq!(app.input, "create table T with pk"); + app.update(key(KeyCode::Up)); + assert_eq!(app.input, ": select * from T"); +} + #[test] fn seed_history_preserves_chronological_order_for_navigation() { let mut app = App::new(); diff --git a/tests/it/seed.rs b/tests/it/seed.rs index eba3e3c..ed389b1 100644 --- a/tests/it/seed.rs +++ b/tests/it/seed.rs @@ -430,24 +430,6 @@ fn seed_is_reproducible_with_a_fixed_seed() { assert_eq!(csv1, csv2, "the same --seed must reproduce identical data"); } -#[test] -fn seed_writes_exactly_one_history_line() { - let (project, db, _dir) = open_project_db(); - let rt = rt(); - create_people(&db, &rt); - - rt.block_on(db.seed("People".into(), None, Some(5), Vec::new(), Some(1), Some("seed People 5".into()))) - .expect("seed succeeds"); - - let history = std::fs::read_to_string(project.path().join("history.log")) - .expect("history.log exists"); - let seed_lines = history.lines().filter(|l| l.contains("seed People 5")).count(); - assert_eq!( - seed_lines, 1, - "a seed of 5 rows must write exactly one history line:\n{history}" - ); -} - // — FK sampling, empty-parent error, block guard (ADR-0048 D14 / D1) — /// `Users(id serial pk, name text)` + `Orders(id serial pk, user_id diff --git a/tests/it/sql_create_index.rs b/tests/it/sql_create_index.rs index 1e24518..96a1e99 100644 --- a/tests/it/sql_create_index.rs +++ b/tests/it/sql_create_index.rs @@ -141,7 +141,7 @@ fn create_unique_index_on_duplicate_data_is_refused() { #[test] fn if_not_exists_on_an_existing_name_is_a_noop_and_journalled() { - let (p, db, _d) = open(false); + let (_p, db, _d) = open(false); let r = rt(); make_t(&db, &r); r.block_on(db.sql_create_index( @@ -169,8 +169,8 @@ fn if_not_exists_on_an_existing_name_is_a_noop_and_journalled() { CreateIndexOutcome::Skipped(name) => assert_eq!(name, "ix"), CreateIndexOutcome::Created(_) => panic!("expected Skipped, got Created"), } - let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log"); - assert!(log.contains(line), "the skipped create should be journalled; log:\n{log}"); + // ADR-0052: journaling moved to the dispatch layer; this test now + // asserts only the no-op `Skipped` outcome. } #[test] diff --git a/tests/it/sql_create_table.rs b/tests/it/sql_create_table.rs index 0d07f54..3ac8564 100644 --- a/tests/it/sql_create_table.rs +++ b/tests/it/sql_create_table.rs @@ -590,7 +590,7 @@ fn if_not_exists_noop_is_journalled() { // A successful no-op is still a submission and belongs in the // complete journal (ADR-0034) — like read-only `show table`, and // unlike a *failed* duplicate-create (journalled `err`). - let (p, db, _d) = open(false); + let (_p, db, _d) = open(false); let r = rt(); r.block_on(db.sql_create_table( "T".to_string(), @@ -617,8 +617,8 @@ fn if_not_exists_noop_is_journalled() { )) .expect("no-op"); assert!(matches!(out, CreateOutcome::Skipped(_))); - let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log"); - assert!(log.contains(noop), "the no-op skip should be journalled; log:\n{log}"); + // ADR-0052: journaling moved to the dispatch layer; this test now + // asserts only the no-op `Skipped` outcome. } #[test] diff --git a/tests/it/sql_delete.rs b/tests/it/sql_delete.rs index 43f3a23..b91f88b 100644 --- a/tests/it/sql_delete.rs +++ b/tests/it/sql_delete.rs @@ -261,19 +261,6 @@ fn r2_cascade_with_subquery_where() { "only Bob's order remains: {orders_csv:?}"); } -#[test] -fn delete_appends_literal_line_to_history() { - let (project, db, _dir) = open_project_db(); - let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]); - seed(&db, &rt, "insert into t (id, v) values (1, 'x')", "t"); - let input = "delete from t where id = 1"; - run_delete(&db, &rt, input).expect("delete runs"); - let body = std::fs::read_to_string(project.path().join("history.log")) - .expect("history.log present"); - assert!(body.contains(input), "history records the literal line: {body:?}"); -} - #[test] fn cascade_to_two_children_reports_both() { // DA gate (untested branch): a parent with TWO cascade children diff --git a/tests/it/sql_drop_index.rs b/tests/it/sql_drop_index.rs index fd4dd3a..ca5f0d5 100644 --- a/tests/it/sql_drop_index.rs +++ b/tests/it/sql_drop_index.rs @@ -84,7 +84,7 @@ fn drop_index_removes_an_existing_index_and_shows_the_table() { #[test] fn if_exists_on_an_absent_index_is_a_noop_and_journalled() { - let (p, db, _d) = open(false); + let (_p, db, _d) = open(false); let r = rt(); let line = "drop index if exists ghost_idx"; let out = r @@ -92,8 +92,8 @@ fn if_exists_on_an_absent_index_is_a_noop_and_journalled() { .expect("IF EXISTS on an absent index succeeds as a no-op"); assert!(matches!(out, DropIndexOutcome::Skipped)); // The no-op is still journalled (ADR-0034), like the create-skip. - let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log"); - assert!(log.contains(line), "the skipped drop should be journalled; log:\n{log}"); + // ADR-0052: journaling moved to the dispatch layer; this test now + // asserts only the no-op `Skipped` outcome. } #[test] diff --git a/tests/it/sql_drop_table.rs b/tests/it/sql_drop_table.rs index a94195f..a95bb0e 100644 --- a/tests/it/sql_drop_table.rs +++ b/tests/it/sql_drop_table.rs @@ -59,7 +59,7 @@ fn drop_table_removes_an_existing_table() { #[test] fn if_exists_on_an_absent_table_is_a_noop_and_journalled() { - let (p, db, _d) = open(false); + let (_p, db, _d) = open(false); let r = rt(); let line = "drop table if exists Ghost"; let out = r @@ -67,8 +67,8 @@ fn if_exists_on_an_absent_table_is_a_noop_and_journalled() { .expect("IF EXISTS on an absent table succeeds as a no-op"); assert!(matches!(out, DropOutcome::Skipped)); // The no-op is still journalled (ADR-0034), like the create-skip. - let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log"); - assert!(log.contains(line), "the skipped drop should be journalled; log:\n{log}"); + // ADR-0052: journaling moved to the dispatch layer; this test now + // asserts only the no-op `Skipped` outcome. } #[test] diff --git a/tests/it/sql_insert.rs b/tests/it/sql_insert.rs index 4afe14c..fdd71ec 100644 --- a/tests/it/sql_insert.rs +++ b/tests/it/sql_insert.rs @@ -132,30 +132,6 @@ fn no_column_list_full_arity_insert_persists() { assert!(csv.contains("full-arity"), "CSV reflects the row: {csv:?}"); } -#[test] -fn insert_appends_literal_line_to_history() { - let (project, db, _dir) = open_project_db(); - let rt = rt(); - create_t(&db, &rt); - // ADR-0030 §11: the literal submitted line lands in history.log. - let source = "insert into T (a, b) values (1, 'logged')"; - rt.block_on(db.run_sql_insert( - "insert into T (a, b) values (1, 'logged')".to_string(), - Some(source.to_string()), - "T".to_string(), - Vec::new(), - String::new(), - false, - )) - .expect("insert runs"); - let body = std::fs::read_to_string(project.path().join("history.log")) - .expect("history.log present after an INSERT"); - assert!( - body.contains(source), - "history.log records the literal INSERT line: {body:?}", - ); -} - #[test] fn failed_insert_rolls_back_and_does_not_repersist() { let (project, db, _dir) = open_project_db(); @@ -583,26 +559,6 @@ fn combined_serial_and_shortid_autofill() { assert_eq!(rows[0][2], "x", "name preserved: {rows:?}"); } -#[test] -fn autofill_logs_original_source_not_rewritten_sql() { - // ADR-0030 §11: even though the worker rewrites the executed - // statement to bind synthesised shortids, history.log records - // the user's original line verbatim. - let (project, db, _dir) = open_project_db(); - let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]); - let input = "insert into t (label) values ('x')"; - run_sqlinsert(&db, &rt, input).expect("auto-fill insert runs"); - let body = std::fs::read_to_string(project.path().join("history.log")) - .expect("history.log present"); - assert!(body.contains(input), "original line logged: {body:?}"); - // The rewritten parameterised INSERT must not leak into history. - assert!( - !body.contains("INSERT INTO") && !body.contains("?1"), - "rewritten SQL must not be logged: {body:?}", - ); -} - #[test] fn shortid_autofill_respects_mixed_case_column_name() { // ADR-0009 / 3d DA gate: identifiers are case-preserving. The diff --git a/tests/it/sql_select.rs b/tests/it/sql_select.rs index eb79c9f..14c5654 100644 --- a/tests/it/sql_select.rs +++ b/tests/it/sql_select.rs @@ -732,23 +732,3 @@ fn database_run_select_recovers_all_ten_playground_types() { ); } } - -#[test] -fn database_run_select_appends_to_history_when_source_present() { - let (project, db, _dir) = open_project_db(); - let history_path = project.path().join("history.log"); - // ADR-0030 §11: the literal submitted line lands in - // history.log so replay re-runs it. - let _ = rt() - .block_on(db.run_select( - "select 1".to_string(), - Some("select 1".to_string()), - )) - .expect("SELECT runs"); - let body = std::fs::read_to_string(&history_path) - .expect("history.log present after a SELECT"); - assert!( - body.contains("select 1"), - "history.log records the literal SELECT line: {body:?}", - ); -} diff --git a/tests/it/sql_update.rs b/tests/it/sql_update.rs index b82ba3b..86c7415 100644 --- a/tests/it/sql_update.rs +++ b/tests/it/sql_update.rs @@ -205,19 +205,6 @@ fn update_matching_no_rows_is_ok() { assert!(csv.contains("keep") && !csv.contains('x'), "unchanged: {csv:?}"); } -#[test] -fn update_appends_literal_line_to_history() { - let (project, db, _dir) = open_project_db(); - let rt = rt(); - create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]); - seed(&db, &rt, "insert into t (id, v) values (1, 'old')", "t"); - let input = "update t set v = 'new' where id = 1"; - run_update(&db, &rt, input).expect("update runs"); - let body = std::fs::read_to_string(project.path().join("history.log")) - .expect("history.log present"); - assert!(body.contains(input), "history records the literal line: {body:?}"); -} - // ================================================================= // ADR-0036 Phase 2 — `SET` literal value validation // ================================================================= diff --git a/tests/it/walking_skeleton.rs b/tests/it/walking_skeleton.rs index 375a8b3..40cea3c 100644 --- a/tests/it/walking_skeleton.rs +++ b/tests/it/walking_skeleton.rs @@ -661,6 +661,7 @@ fn dsl_failure_shows_friendly_error_in_output() { }, facts: rdbms_playground::friendly::FailureContext::default(), source: String::new(), + advanced: false, }); let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); assert!( From ae73a4be85fa4993b247f994a7ada0304f418ea4 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 14 Jun 2026 12:08:24 +0000 Subject: [PATCH 24/50] =?UTF-8?q?docs:=20handoff=2069=20=E2=80=94=20four?= =?UTF-8?q?=20issues=20closed=20(#27/#28/#29/#30)=20+=20ADRs=200049?= =?UTF-8?q?=E2=80=930052;=20tracker=20empty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/handoff/20260614-handoff-69.md | 203 ++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 docs/handoff/20260614-handoff-69.md diff --git a/docs/handoff/20260614-handoff-69.md b/docs/handoff/20260614-handoff-69.md new file mode 100644 index 0000000..a9b037d --- /dev/null +++ b/docs/handoff/20260614-handoff-69.md @@ -0,0 +1,203 @@ +# Session handoff — 2026-06-14 (69) + +Sixty-ninth handover. Continues from handoff-68 (an issue-burndown that +closed #25/#26/#31/#32/#33/#34). This session **closed the four +remaining open issues** — #29, #28, #27, #30 — each landed with the full +phased workflow + `/runda` + Devil's-Advocate passes before commit, and +each producing a new ADR. Net: **four issues closed, four commits, four +new ADRs (0049–0052), +63 tests, zero regressions, the tracker is now +empty.** + +The four interlock: **#29** added the input-field readline keys, **#27** +advertises them in a state-aware status strip, and **#30**'s history +recall now respects modes. **#30** also turned into a real architecture +change (journaling relocation) — read §2.4 carefully before touching that +area. + +## §1. State at handoff + +**Branch:** `main`. Working tree **clean**; all work committed. The two +most recent commits are local (normal working state — push is the user's +step). + +**Tests: 2471 passing / 0 failing / 0 skipped / 1 ignored** (the +long-standing `friendly` doctest). **Clippy clean** (nursery, all +targets). Breakdown: 1771 lib + 500 integration (`it`) + 200 +typing-surface-matrix. **+35 over handoff-68's 2436** (net: #29 +22, #28 ++0, #27 +9, #30 +4 — its new history.rs/app.rs/iteration6 tests minus the +15 retired worker-journaling tests; trust the live `cargo test` count). + +**Commits this session:** +``` +4aeea55 feat(history): mode-tagged history + top-of-chain journaling (#30) +eceedc1 feat(ui): context- and state-aware bottom keybinding strip (#27) +8ac3537 feat(render): incidental-DDL confirmations show structure only, no relationships (#28) +66c8bda feat(input): readline keymap — Esc-clear + Ctrl-A/E/W/K/U (#29) +``` + +**Open Gitea issues: none.** `tea issues list --state open` is empty. + +## §2. Issues closed this session (all committed, tested, `/runda`-reviewed) + +Each closed on `git.lazyeval.net/oli/rdbms-playground` with a summary +comment. + +### 2.1 — #29 (`66c8bda`) — input-field readline keymap (ADR-0049) + +Implements the deferred **I1b** readline shortcuts: `Esc` clears a +partly-typed command (only when no completion memo is alive — the memo +wins first, ADR-0022); `Ctrl-A`/`Ctrl-E` = Home/End; `Ctrl-W` deletes +the previous word (readline-style, UTF-8 safe); `Ctrl-K`/`Ctrl-U` kill to +end/start. Cursor-only keys leave history nav intact; buffer-mutating +keys end it. **DA caught** the need for the `Ctrl-O`+`Esc` (sidebar +nav-exit) interaction not to clear the draft — locked with a regression +test. `requirements.md` I1b → `[x]`. + +### 2.2 — #28 (`8ac3537`) — incidental-DDL confirmations: structure-only (ADR-0050) + +Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/ +`rename`/`change column`, `add`/`drop index`) now render **structure +only** — no `References:` / `Referenced by:` block. Relationship-subject +surfaces (`show table`, `add`/`drop relationship`) keep their ADR-0044 +diagrams. The prose renderer (`relationship_prose_lines` + `cols_disp`) +was deleted. **Supersedes** ADR-0044 §1's incidental-DDL prose clause and +the relationship-block half of ADR-0016 §5 (both annotated). + +### 2.3 — #27 (`eceedc1`) — context- and state-aware keybinding strip (ADR-0051) + +The bottom status line is now keystrokes-only and **state-selected** by +priority (sidebar focus / completion-memo / history-nav / editing / +default). The editing state surfaces the #29 keys (closing ADR-0049's +deferred advertisement). Mode-switch advertisements left the strip; the +empty-input hint gained a simple-mode `` `mode advanced` for SQL `` pointer +(advanced mode shows none — user decision). New `App::is_browsing_history()` +exposes the private `history_cursor`. 15 full-panel snapshots re-accepted. + +### 2.4 — #30 (`4aeea55`) — mode-tagged history + top-of-chain journaling (ADR-0052) **← read before touching journaling** + +Closed both the feature (advanced history reusable in simple mode) and +the bug (the `:` one-shot prefix lost across sessions). Two halves: + +1. **Mode-tagged history.** The `history.log` status token gains an + optional `:adv` suffix (`ok` / `ok:adv` / `err` / `err:adv`); `source` + stays last + canonical so replay is unaffected. The in-memory ring + (still `Vec`) stores advanced entries in their `: `-prefixed + simple-mode runnable form; recall **strips the `:` in advanced mode** + and keeps it in simple; hydration reconstructs the prefix from the tag. + App commands journal simple and are excluded from the ring's advanced + flag, so they recall bare. + +2. **Journaling relocation (the architecture change).** Success + journaling **moved out of the worker** to the dispatch layer + (`spawn_dsl_dispatch` / `run_replay` / the app-command sites), next to + the already-top-level failure journaling — so the submission mode is in + scope with no worker plumbing. `finalize_persistence` now writes only + the **state** sources (yaml/csv); the journal write is **best-effort** + (the command is already committed — consistent with the failure path). + **Amends ADR-0015 §6** (history.log out of the worker tx; commit-db-last + scopes yaml/csv/db only), **ADR-0034** (status tag + journaling + location), **ADR-0040** (journal-write best-effort, not fatal). + + **Two DA findings, both resolved:** (a) the app-command `advanced` flag + must exclude app commands (else `: save as` diverges); (b) the spawn + journals on `outcome.is_ok()`, so journaling is now **uniform** — read + commands that didn't journal before (`show tables`/`show relationships`/ + `show indexes`, `show relationship `, `explain`) now do, matching + ADR-0034 §1. **User-confirmed** as the more-correct behaviour (harmless + on replay — reads/`explain` don't mutate). + + **Test migration:** 15 worker-level journaling tests were retired (the + worker no longer journals — their yaml/csv/operation assertions were + kept) and re-covered at the new layer: `history.rs` status-tag + + `:`-reconstruct; `app.rs` recall matrix; the cross-session regression + `advanced_command_journalled_then_hydrated_recalls_with_colon_in_simple` + in `iteration6_resume_history`; the replay tests cover `run_replay` + journaling. + + Plan: `docs/plans/20260613-issue-30-top-of-chain-journaling.md`. + +## §3. Next session — start here + +The user's stated plan for the next session, in order: + +1. **Pick up the ADR-0052 follow-up** (below). +2. **Check for any newly-filed open issues** (`tea issues list --state + open`) — none at handoff, but check fresh. +3. **Then** take on remaining open tasks from the general requirements + (`docs/requirements.md`) — see §5. + +### The ADR-0052 follow-up — unwind the vestigial worker `source` plumbing + +When journaling moved out of the worker, the `source` that the worker +threaded purely for journaling became dead. To avoid orphaning the param +across ~28 handlers, the refactor **left it in place** as vestigial: + +- `finalize_persistence(conn, persistence, _source, changes)` — the + `_source` param is now unused (kept so its ~28 callers still pass + `source`, which they otherwise also use for `snapshot_then`). +- `do_rebuild_from_text(conn, _persistence, _source, project_path)` — + both `_persistence` and `_source` vestigial. +- Three thin read-only wrappers in `db.rs` — + `do_describe_table_request`, `do_query_data_request`, + `do_run_select_request` — now just delegate to their non-`_request` + twin (`do_describe_table` / `do_query_data` / `do_run_select`) with + vestigial `_persistence` / `_source` params and one caller each + (`db.rs` Request arms ~2409 / ~2749 / ~2759). + +**The cleanup:** remove `_source` from `finalize_persistence` + drop the +arg at its ~28 callers (the callers keep `source` for `snapshot_then`, so +only the `finalize_persistence(...)` call loses the arg); remove the +`_persistence`/`_source` params from `do_rebuild_from_text`; and inline +the three `*_request` wrappers at their single call sites (replace +`do_describe_table_request(conn, persistence, source, name)` with +`do_describe_table(conn, &name)`, etc.), deleting the wrappers. Purely +mechanical, compiler-guided, no behaviour change. Establish the green +baseline first (`cargo test`), then verify nothing moved. + +## §4. Carried-over follow-up (website branch, not `main`) + +- **Website `seed` cast re-record** (from #34, handoff-68 §4) — still + tracked on the `website` branch, not here. Likely redundant (full + re-record sweep before publication). + +## §5. Remaining roadmap — `docs/requirements.md` (next session's §3-step 3) + +With the issue tracker empty, the next work comes from the document-based +requirements. Open / partial items worth weighing (the user picks): + +- **H2 `hint`** — the last A1 gap (contextual help for the current + command); its own ADR. (`requirements.md` H2.) +- **TT5 CI** — runs all tiers on Linux/macOS/Windows; no CI workflow yet + (a `ci` branch reportedly exists — check its state first). Couples with + **D1–D3** (cross-platform prebuilt binaries + Homebrew/Scoop). +- **TT4 PTY (Tier-4)** — ADR-0008 specifies the PTY harness + four + critical flows; still not wired (no PTY deps/tests). +- **I1 multi-line input** (Ctrl-Enter submits, Enter inserts newline) and + **I5 / B3 in-flight cancellation** (Ctrl-C cancels a running command). +- **V4 session journal** — scrollable per-session log + Markdown export + (the bigger UX project; own ADR). +- **TU1 tutorial / lesson system** — design + ADR pending (acknowledged + in scope). +- Smaller partials: **C3a** modify relationship (drop+add covers it + today), **C4** m:n convenience, **V3** ER-diagram export, the **NFR-*** + performance/visual targets (mostly unmeasured), **N4** global rolling + history (OOS for v1). + +No strong ordering — these are the user's call. Several need a new ADR +(H2, V4, TU1); CI/release (TT5/D1–D3) is the most "shippable-product" +track if that's the priority. + +## §6. How to take over + +1. Read handoffs 67 → 68 → 69, `CLAUDE.md`, `docs/requirements.md`. +2. Confirm green baseline: `cargo test` (expect **2471 pass / 1 ignored**) + + `cargo clippy --all-targets` (clean). +3. `tea issues list --state open` — pick up anything new first. +4. Then the ADR-0052 follow-up (§3), then requirements (§5). +5. Follow the project workflow: phased (requirements → divergent → eval → + execute → verify), test-first, `/runda` + DA pass before every commit, + ADR amendment for any decided-area change + the README index-upkeep + rule, and confirm the commit message with the user before committing. +6. Consider a `cargo sweep` at this milestone (`target/` grows across + sessions; see CLAUDE.md "Build hygiene"). (`sweep.timestamp` was + removed this session.) From e8fa859ab9d05103b05d9e03fc097b7bbe88bdb3 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 14 Jun 2026 13:47:49 +0000 Subject: [PATCH 25/50] refactor(db): unwind vestigial worker source plumbing (ADR-0052 follow-up) ADR-0052 moved success journaling out of the worker to the dispatch layer, leaving the `source` that handlers threaded purely for the worker's old history.log write dead. Remove it: - drop `_source` from finalize_persistence and do_rebuild_from_text - inline + delete the three read-only *_request wrappers - drop the now-unused `source` param from the ~30 forwarding worker handlers (leaf + composite), compiler-guided - remove the `source` field from the DescribeTable/QueryData/RunSelect requests and their DatabaseHandle methods (call sites updated) The only worker `source` left is the snapshot/undo label (snapshot_then / stage_pre_mutation / begin_batch). Purely mechanical, no behaviour change. 2471 pass / 0 fail / 1 ignored, clippy clean. --- ...2-mode-tagged-history-cross-mode-recall.md | 15 +- docs/adr/README.md | 2 +- src/db.rs | 388 ++++++------------ src/runtime.rs | 26 +- tests/it/case_insensitive_names.rs | 10 +- tests/it/compound_fk.rs | 10 +- tests/it/iteration3_rebuild.rs | 8 +- tests/it/iteration4a_rebuild_command.rs | 2 +- tests/it/iteration5_export_import.rs | 2 +- tests/it/m2n.rs | 12 +- tests/it/replay_command.rs | 18 +- tests/it/show_list.rs | 2 +- tests/it/sql_alter_table.rs | 34 +- tests/it/sql_create_index.rs | 2 +- tests/it/sql_create_table.rs | 36 +- tests/it/sql_delete.rs | 8 +- tests/it/sql_dml_e2e.rs | 2 +- tests/it/sql_drop_index.rs | 2 +- tests/it/sql_drop_table.rs | 2 +- tests/it/sql_select.rs | 26 +- tests/it/sql_update.rs | 2 +- tests/it/undo_snapshots.rs | 6 +- 22 files changed, 231 insertions(+), 384 deletions(-) diff --git a/docs/adr/0052-mode-tagged-history-cross-mode-recall.md b/docs/adr/0052-mode-tagged-history-cross-mode-recall.md index 7fdaabe..5efdb2a 100644 --- a/docs/adr/0052-mode-tagged-history-cross-mode-recall.md +++ b/docs/adr/0052-mode-tagged-history-cross-mode-recall.md @@ -189,9 +189,18 @@ over keeping journaling coupled in the worker (which would have needed the no-op-skip / read-only sites no longer journal; success is journalled at the dispatch layer (`spawn_dsl_dispatch` / `run_replay` / app-command sites). The ring stays `Vec`; `seed_history` / `ProjectSwitched` - are untouched. The vestigial worker `source` plumbing (the `_source` - param on `finalize_persistence` / `do_rebuild_from_text` and the thin - read-only `*_request` wrappers) is left in place — a clean follow-up. + are untouched. The vestigial worker `source` plumbing has since been + **fully unwound** (2026-06-14 follow-up): `_source` removed from + `finalize_persistence` / `do_rebuild_from_text`; the three read-only + `*_request` wrappers inlined and deleted; and — because the cascade ran + deeper than first estimated — the now-dead `source` param dropped from + the ~30 worker handlers (leaf + composite) that only forwarded it, plus + the `source` field removed from the `DescribeTable` / `QueryData` / + `RunSelect` requests and the matching `DatabaseHandle` method parameters + (the ~164 call-site churn was mostly tests). The only `source` left in + the worker is the snapshot/undo label (`snapshot_then` / + `stage_pre_mutation` / `begin_batch`), passed at the match-arm level. + Purely mechanical, compiler-guided, no behaviour change. - **App commands recall bare.** Because they are dispatched outside the `ExecuteDsl`/spawn path, app commands journal **simple** (`advanced = false`) at their own sites, and `submit` excludes them from the ring's diff --git a/docs/adr/README.md b/docs/adr/README.md index abeba31..8aeb407 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -57,4 +57,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0049 — Input-field readline keymap: Esc-clear + Ctrl-A/E/W/K/U (I1b)](0049-input-field-readline-keymap.md) — **Accepted + implemented 2026-06-12 (issue #29)**, closes Gitea **#29** and the deferred **I1b** readline requirement. **Amends ADR-0046**, which listed "readline shortcuts (I1b)" as out-of-scope — that item is now in scope and decided here; orthogonal to ADR-0003's input-*mode* model and extends the I1a single-line cursor editing already shipped. Binds, in the input field (non-modal, non-nav, both modes): **`Esc`** clears a partly-typed command (empty buffer, cursor→0, scroll→0); **`Ctrl-A`/`Ctrl-E`** alias Home/End (line start/end); **`Ctrl-W`** deletes the previous word (readline-style — eats trailing whitespace then the preceding non-whitespace run, UTF-8-safe on char boundaries, only back to the cursor); **`Ctrl-K`** kills to end of line; **`Ctrl-U`** kills to start. **Esc precedence:** a live Tab-completion memo still wins (Esc undoes the completion first, ADR-0022; Esc clears only when no memo) — Esc-once backs out the completion, Esc-again clears. Forks all user-chosen: **single-Esc-clears** (not double-Esc — discoverable over accident-proof; an unsubmitted draft can be lost, a submitted line is always in history); the **full I1b set** (not just the issue's literal Ctrl-A/E + Esc); a **new ADR** (not an ADR-0046 amendment / no-ADR). Cursor-only keys (Ctrl-A/E) leave history navigation intact like Home/End; buffer-mutating keys (Esc-clear, Ctrl-W/K/U) end it like Backspace. Helpers `clear_input`/`delete_prev_word`/`kill_to_end`/`kill_to_start` in `src/app.rs`; **22 new Tier-1 tests, 2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. OOS: on-screen keybinding hints (issue #27 owns surfacing per-focus keybindings in the bottom status line — this ADR makes the keys *work*, #27 makes them *discoverable*); demo-mode badges for the new chords (ADR-0047 follow-up — Esc already badges `[ESC]`, the glyph-less Ctrl-chords are flagged but not added); multi-line input (I1); word-wise cursor motion (Alt-B/F) / transpose / yank - [ADR-0050 — Incidental-DDL confirmations omit relationship info (structure-only)](0050-incidental-ddl-confirmations-omit-relationships.md) — **Accepted + implemented 2026-06-12 (issue #28)**, closes Gitea **#28**. **Supersedes** the incidental-DDL clause of **ADR-0044 §1** and the relationship-block half of **ADR-0016 §5**. Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/`rename`/`change column`, `add`/`drop index`) now render **structure only** — header + column box + `Indexes:` + constraints — with **no `References:` / `Referenced by:` block** (neither prose nor diagram), even when the table carries relationships the user did not touch. Rationale (owner): a confirmation echo reports the change just made, not untouched relationships; ADR-0044's terse prose was the lesser of "prose vs diagram", but the right answer for these surfaces is **neither**. **Relationship-subject surfaces are unchanged** — `show table`, `add`/`drop relationship`, `show relationship` still render ADR-0044 diagrams; relationships appear only when the user asks for (`show table`) or acts on (`add`/`drop relationship`) one, and are one `show table ` away — **no information lost**. Forks both user-chosen: **scope = all incidental DDL** (not just `add column` — the rationale is uniform, the mental model clean, and it's the simpler edit) and **delete the prose renderer** (not retain it dormant — no dead code). **Mechanism:** the `handle_dsl_success` `matches!` routing is unchanged (relationship-subject → diagrams; else → `render_structure`); the change is one line inside `render_structure` (`output_render.rs` — drop the relationship-block call) since all its callers are incidental DDL, plus deletion of the orphaned `relationship_prose_lines` + `cols_disp` helpers. The prose format survives in ADR-0016 §5 + git history for a future OOS-7 always-prose setting. **Tests:** the prose-presence unit test + its snapshot removed; a new unit test asserts `render_structure` on a description carrying **both** inbound and outbound relationships emits the box but no prose; the misnamed `add_relationship_flow_shows_inbound_section_on_parent` integration test (which sent an `AddColumn`) inverted + renamed to assert the add-column echo omits the prose; the diagram tests (`show table`, `add relationship`) unaffected. **2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. `requirements.md` unaffected (ADR-tracked refinement of a decided area, like ADR-0044 itself) - [ADR-0051 — Bottom keybinding strip: context- and state-aware](0051-context-state-aware-keybinding-strip.md) — **Accepted + implemented 2026-06-13 (issue #27)**, closes Gitea **#27**. Repurposes the bottom status line into a **keystrokes-only, state-selected** strip (builds on ADR-0046 nav focus, ADR-0003 modes, ADR-0049 the #29 readline keys it now advertises, ADR-0022 the completion memo). A pure `status_bar_bindings(app) -> Vec<(key,label)>` chooses the strip by **priority, first match wins**: (1) **sidebar focus** → `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input`; (2) **completion memo live** (`last_completion`) → `Tab/Shift-Tab cycle · Esc cancel · Enter run`; (3) **history navigation** (new `App::is_browsing_history()` exposing the private `history_cursor`) → `↑↓ browse · Esc clear · Enter run`; (4) **editing** (input non-empty) → `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` (surfaces the #29 keys, closing ADR-0049's deferred advertisement); (5) **default** (empty) → `Ctrl-O sidebar · Tab complete · ↑ history · Enter run`. Priority is correct because Up clears the completion memo and Tab cancels history nav, so states 2/3 never co-occur, and the five are exhaustive for Input focus. **Typed-command words leave the strip** (`mode advanced`/`mode simple` switch, `:` one-shot) and **mode discovery moves to the empty-input hint** (`resolve_hint_lines`), **simple mode only**: `\`mode advanced\` for SQL` (the verb "type" omitted — the prompt implies it; advanced mode shows **no** pointer per a post-trial user decision — a switcher knows how they got there and `help` covers the way back). The one-shot's old `Backspace cancel one-shot` label is subsumed by the editing state (behaviour intact). Forks all user-chosen: **editing state shows the #29 keys** (vs unadvertised); **`Ctrl-C quit` omitted** from the strip (vs always shown); **no width-drop machinery** — the longest strip (~65 cols) fits all supported widths, so a **width-budget unit test** keeps it lean by construction instead (the user's own observation). Catalog: 12 new `shortcut.*` labels + the `panel.hint_mode_advanced` string added to `en-US.yaml`+`keys.rs` (validator-checked 1:1), 5 now-dead strip strings removed. **Modal-aware strip is OOS** (pre-existing: a modal owns the keyboard and carries its own hints; the strip under it is unchanged-in-kind, not worsened). Tests: 9 Tier-1 unit (per-state key sets — completion/history driven through real key events; width budget; mode-pointer presence/absence), 1 Tier-3 rewritten (`status_bar_is_keystroke_only_and_state_aware`), 15 full-panel snapshots re-accepted (reviewed — strip/hint only). **2467 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: modal-aware strip; a full-key cheatsheet overlay; Ctrl-K/U advertisement (editing strip shows the highest-value subset within the width budget) -- [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (status field gains a `:adv` tag; **journaling moves from the worker to the dispatch layer**), **ADR-0015 §5/§6** (history.log leaves the worker transaction — `commit-db-last` now scopes yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure is best-effort, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: unwinding the now-vestigial worker `source` plumbing (`_source` params + thin `*_request` wrappers — a clean follow-up); replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression) +- [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (status field gains a `:adv` tag; **journaling moves from the worker to the dispatch layer**), **ADR-0015 §5/§6** (history.log leaves the worker transaction — `commit-db-last` now scopes yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure is best-effort, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression). **Follow-up done 2026-06-14:** the vestigial worker `source` plumbing was fully unwound (compiler-guided, no behaviour change) — `_source` removed from `finalize_persistence`/`do_rebuild_from_text`, the three `*_request` wrappers inlined+deleted, the dead `source` param dropped from the ~30 forwarding worker handlers, and the `source` field removed from the `DescribeTable`/`QueryData`/`RunSelect` requests + their `DatabaseHandle` methods (~164 mostly-test call sites); the only worker `source` left is the snapshot/undo label (see ADR-0052 *Consequences*) diff --git a/src/db.rs b/src/db.rs index f6f08b9..80ebb64 100644 --- a/src/db.rs +++ b/src/db.rs @@ -608,7 +608,6 @@ enum Request { }, DescribeTable { name: String, - source: Option, reply: oneshot::Sender>, }, AddRelationship { @@ -748,7 +747,6 @@ enum Request { table: String, filter: Option, limit: Option, - source: Option, reply: oneshot::Sender>, }, /// Run a SQL `SELECT` typed by the user in advanced mode @@ -757,11 +755,11 @@ enum Request { /// prepares and runs the statement and returns the rows as /// a [`DataResult`] (with no playground type information per /// ADR-0030 §6 — computed columns render with neutral - /// alignment). `source` is the literal submitted line, - /// appended to `history.log` for replay (ADR-0030 §11). + /// alignment). The literal submitted line is journalled to + /// `history.log` at the dispatch layer for replay (ADR-0030 §11, + /// ADR-0052). RunSelect { sql: String, - source: Option, reply: oneshot::Sender>, }, /// Run a validated SQL `INSERT` typed in advanced mode @@ -1418,18 +1416,9 @@ impl Database { recv.await.map_err(|_| DbError::WorkerGone)? } - pub async fn describe_table( - &self, - name: String, - source: Option, - ) -> Result { + pub async fn describe_table(&self, name: String) -> Result { let (reply, recv) = oneshot::channel(); - self.send(Request::DescribeTable { - name, - source, - reply, - }) - .await?; + self.send(Request::DescribeTable { name, reply }).await?; recv.await.map_err(|_| DbError::WorkerGone)? } @@ -1608,14 +1597,12 @@ impl Database { table: String, filter: Option, limit: Option, - source: Option, ) -> Result { let (reply, recv) = oneshot::channel(); self.send(Request::QueryData { table, filter, limit, - source, reply, }) .await?; @@ -1624,15 +1611,11 @@ impl Database { /// Run a validated SQL `SELECT` and return the rows /// (ADR-0030 §6, ADR-0031). `sql` is the grammar-validated - /// statement text; `source` is the literal submitted line - /// for `history.log`. - pub async fn run_select( - &self, - sql: String, - source: Option, - ) -> Result { + /// statement text; the literal submitted line is journalled + /// at the dispatch layer (ADR-0052). + pub async fn run_select(&self, sql: String) -> Result { let (reply, recv) = oneshot::channel(); - self.send(Request::RunSelect { sql, source, reply }).await?; + self.send(Request::RunSelect { sql, reply }).await?; recv.await.map_err(|_| DbError::WorkerGone)? } @@ -2235,7 +2218,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_create_table( conn, persistence, - source.as_deref(), &name, &columns, &primary_key, @@ -2272,7 +2254,6 @@ fn handle_request( do_create_table( conn, persistence, - source.as_deref(), &name, &columns, &primary_key, @@ -2290,7 +2271,7 @@ fn handle_request( reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { - do_drop_table(conn, persistence, source.as_deref(), &name) + do_drop_table(conn, persistence, &name) }); } Request::SqlDropTable { @@ -2309,7 +2290,7 @@ fn handle_request( let _ = reply.send(result); } else { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { - do_drop_table(conn, persistence, source.as_deref(), &name) + do_drop_table(conn, persistence, &name) .map(|()| DropOutcome::Dropped) }); } @@ -2323,7 +2304,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_column( conn, persistence, - source.as_deref(), &table, &column, )); @@ -2338,7 +2318,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_column( conn, persistence, - source.as_deref(), &table, &column, cascade, @@ -2354,7 +2333,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_rename_column( conn, persistence, - source.as_deref(), &table, &old, &new, @@ -2369,7 +2347,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_rename_table( conn, persistence, - source.as_deref(), &table, &new, )); @@ -2385,7 +2362,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_change_column_type( conn, persistence, - source.as_deref(), &table, &column, ty, @@ -2401,17 +2377,8 @@ fn handle_request( Request::ShowRelationship { name, reply } => { let _ = reply.send(do_show_relationship(conn, &name)); } - Request::DescribeTable { - name, - source, - reply, - } => { - let _ = reply.send(do_describe_table_request( - conn, - persistence, - source.as_deref(), - &name, - )); + Request::DescribeTable { name, reply } => { + let _ = reply.send(do_describe_table(conn, &name)); } Request::AddRelationship { name, @@ -2428,7 +2395,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_relationship( conn, persistence, - source.as_deref(), name.as_deref(), &parent_table, &parent_columns, @@ -2450,7 +2416,6 @@ fn handle_request( do_create_m2n_relationship( conn, persistence, - source.as_deref(), &t1, &t2, name.as_deref(), @@ -2465,7 +2430,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_relationship( conn, persistence, - source.as_deref(), &selector, )); } @@ -2479,7 +2443,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_index( conn, persistence, - source.as_deref(), name.as_deref(), &table, &columns, @@ -2497,7 +2460,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_index( conn, persistence, - source.as_deref(), &selector, )); } @@ -2522,7 +2484,6 @@ fn handle_request( do_drop_index( conn, persistence, - source.as_deref(), &IndexSelector::Named { name: name.clone() }, ) .map(DropIndexOutcome::Dropped) @@ -2555,7 +2516,6 @@ fn handle_request( do_add_index( conn, persistence, - source.as_deref(), name.as_deref(), &table, &columns, @@ -2575,7 +2535,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_add_constraint( conn, persistence, - source.as_deref(), &table, &column, &constraint, @@ -2591,7 +2550,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_drop_constraint( conn, persistence, - source.as_deref(), &table, &column, kind, @@ -2608,7 +2566,6 @@ fn handle_request( do_set_column_default( conn, persistence, - source.as_deref(), &table, &column, &default_sql, @@ -2626,7 +2583,6 @@ fn handle_request( do_alter_add_table_check( conn, persistence, - source.as_deref(), &table, name.as_deref(), &expr_sql, @@ -2640,7 +2596,7 @@ fn handle_request( reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { - do_alter_add_unique(conn, persistence, source.as_deref(), &table, &columns) + do_alter_add_unique(conn, persistence, &table, &columns) }); } Request::AlterDropConstraint { @@ -2650,7 +2606,7 @@ fn handle_request( reply, } => { snapshot_then(snap, batch, conn, source.as_deref(), reply, || { - do_drop_constraint_by_name(conn, persistence, source.as_deref(), &table, &name) + do_drop_constraint_by_name(conn, persistence, &table, &name) }); } Request::AlterAddForeignKey { @@ -2664,7 +2620,6 @@ fn handle_request( do_alter_add_foreign_key( conn, persistence, - source.as_deref(), &child_table, name.as_deref(), &fk, @@ -2681,7 +2636,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_insert( conn, persistence, - source.as_deref(), &table, columns.as_deref(), &values, @@ -2701,7 +2655,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_seed( conn, persistence, - source.as_deref(), &table, target_column.as_deref(), count, @@ -2719,7 +2672,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_update( conn, persistence, - source.as_deref(), &table, &assignments, &filter, @@ -2734,7 +2686,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_delete( conn, persistence, - source.as_deref(), &table, &filter, )); @@ -2743,25 +2694,12 @@ fn handle_request( table, filter, limit, - source, reply, } => { - let _ = reply.send(do_query_data_request( - conn, - persistence, - source.as_deref(), - &table, - filter.as_ref(), - limit, - )); + let _ = reply.send(do_query_data(conn, &table, filter.as_ref(), limit)); } - Request::RunSelect { sql, source, reply } => { - let _ = reply.send(do_run_select_request( - conn, - persistence, - source.as_deref(), - &sql, - )); + Request::RunSelect { sql, reply } => { + let _ = reply.send(do_run_select(conn, &sql)); } Request::RunSqlInsert { sql, @@ -2776,7 +2714,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_insert( conn, persistence, - source.as_deref(), &sql, &target_table, &listed_columns, @@ -2796,7 +2733,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_update( conn, persistence, - source.as_deref(), &sql, &target_table, returning, @@ -2813,7 +2749,6 @@ fn handle_request( snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_sql_delete( conn, persistence, - source.as_deref(), &sql, &target_table, returning, @@ -2824,12 +2759,9 @@ fn handle_request( source, reply, } => { - snapshot_then(snap, batch, conn, source.as_deref(), reply, || do_rebuild_from_text( - conn, - persistence, - source.as_deref(), - &project_path, - )); + snapshot_then(snap, batch, conn, source.as_deref(), reply, || { + do_rebuild_from_text(conn, &project_path) + }); } Request::ExplainPlan { query, reply } => { let _ = reply.send(do_explain_plan(conn, &query)); @@ -3063,11 +2995,6 @@ struct Changes { fn finalize_persistence( conn: &Connection, persistence: Option<&Persistence>, - // Vestigial since ADR-0052 (the `history.log` write that used it moved - // to the dispatch layer). Retained so the ~28 worker handlers that - // thread `source` to here keep a use for it, rather than orphaning the - // param across all of them; a later cleanup could unwind that plumbing. - _source: Option<&str>, changes: &Changes, ) -> Result<(), DbError> { let Some(p) = persistence else { @@ -3524,7 +3451,6 @@ pub enum CreateIndexOutcome { fn do_create_table( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, name: &str, columns: &[ColumnSpec], primary_key: &[String], @@ -3708,7 +3634,7 @@ fn do_create_table( rewritten_tables: vec![name.to_string()], ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(description) } @@ -3716,7 +3642,6 @@ fn do_create_table( fn do_drop_table( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, name: &str, ) -> Result<(), DbError> { debug!(table = %name, "drop_table"); @@ -3768,7 +3693,7 @@ fn do_drop_table( rewritten_tables: Vec::new(), deleted_tables: vec![name.to_string()], }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(()) } @@ -3795,7 +3720,6 @@ fn do_drop_table( fn do_add_column( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, column: &ColumnSpec, ) -> Result { @@ -3826,7 +3750,7 @@ fn do_add_column( column.name, ))); } - return do_add_auto_generated_column(conn, persistence, source, table, column); + return do_add_auto_generated_column(conn, persistence, table, column); } // SQLite's `ALTER TABLE ADD COLUMN` cannot express `UNIQUE` // or `CHECK`, and a `NOT NULL` column added that way must @@ -3839,9 +3763,9 @@ fn do_add_column( || column.check_sql.is_some() || (column.not_null && column.default.is_none() && column.default_sql.is_none()) { - do_add_constrained_column_via_rebuild(conn, persistence, source, table, column) + do_add_constrained_column_via_rebuild(conn, persistence, table, column) } else { - do_add_plain_column(conn, persistence, source, table, column) + do_add_plain_column(conn, persistence, table, column) } } @@ -3849,7 +3773,6 @@ fn do_add_column( fn do_add_plain_column( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, spec: &ColumnSpec, ) -> Result { @@ -3886,7 +3809,7 @@ fn do_add_plain_column( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(AddColumnResult { description, @@ -3902,7 +3825,6 @@ fn do_add_plain_column( fn do_add_auto_generated_column( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, spec: &ColumnSpec, ) -> Result { @@ -4009,7 +3931,7 @@ fn do_add_auto_generated_column( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) }; @@ -4034,7 +3956,6 @@ fn do_add_auto_generated_column( fn do_add_constrained_column_via_rebuild( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, spec: &ColumnSpec, ) -> Result { @@ -4109,7 +4030,7 @@ fn do_add_constrained_column_via_rebuild( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) }; @@ -4135,7 +4056,6 @@ fn do_add_constrained_column_via_rebuild( fn do_add_constraint( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, column: &str, constraint: &Constraint, @@ -4262,7 +4182,7 @@ fn do_add_constraint( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) }; @@ -4278,7 +4198,6 @@ fn do_add_constraint( fn do_drop_constraint( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, column: &str, kind: ConstraintKind, @@ -4362,7 +4281,7 @@ fn do_drop_constraint( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) }; @@ -4381,7 +4300,6 @@ fn do_drop_constraint( fn do_set_column_default( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, column: &str, default_sql: &str, @@ -4429,7 +4347,7 @@ fn do_set_column_default( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) }; @@ -4771,7 +4689,6 @@ fn format_auto_fill_add_note(ty: Type, row_count: usize) -> String { fn do_drop_column( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, column: &str, cascade: bool, @@ -4913,7 +4830,7 @@ fn do_drop_column( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(DropColumnResult { description, @@ -4931,7 +4848,6 @@ fn do_drop_column( fn do_rename_column( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, old: &str, new: &str, @@ -5023,7 +4939,7 @@ fn do_rename_column( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(description) } @@ -5055,7 +4971,6 @@ fn do_rename_column( fn do_rename_table( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, old: &str, new: &str, ) -> Result { @@ -5201,7 +5116,7 @@ fn do_rename_table( rewritten_tables: vec![new.to_string()], deleted_tables: vec![old.to_string()], }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(description) } @@ -5242,7 +5157,6 @@ fn do_rename_table( fn do_change_column_type( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, column: &str, ty: Type, @@ -5360,7 +5274,7 @@ fn do_change_column_type( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) }; @@ -7445,7 +7359,6 @@ fn resolve_create_table_fks( fn do_create_m2n_relationship( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, t1: &str, t2: &str, name: Option<&str>, @@ -7519,7 +7432,6 @@ fn do_create_m2n_relationship( do_create_table( conn, persistence, - source, &junction, &columns, &primary_key, @@ -7533,7 +7445,6 @@ fn do_create_m2n_relationship( fn do_add_relationship( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, name: Option<&str>, parent_table: &str, parent_columns: &[String], @@ -7688,7 +7599,7 @@ fn do_add_relationship( rewritten_tables: vec![child_table.to_string()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) })?; @@ -7702,7 +7613,6 @@ fn do_add_relationship( fn do_drop_relationship( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, selector: &RelationshipSelector, ) -> Result, DbError> { debug!(selector = ?selector, "drop_relationship"); @@ -7761,7 +7671,7 @@ fn do_drop_relationship( rewritten_tables: vec![child_table_for_persist.clone()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) })?; @@ -7780,7 +7690,6 @@ fn do_drop_relationship( fn do_alter_add_table_check( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, name: Option<&str>, expr_sql: &str, @@ -7873,7 +7782,7 @@ fn do_alter_add_table_check( rewritten_tables: vec![table_owned.clone()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) })?; do_describe_table(conn, table) @@ -7887,7 +7796,6 @@ fn do_alter_add_table_check( fn do_alter_add_unique( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, columns: &[String], ) -> Result { @@ -7942,7 +7850,7 @@ fn do_alter_add_unique( rewritten_tables: vec![table_owned.clone()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) })?; do_describe_table(conn, table) @@ -7955,7 +7863,6 @@ fn do_alter_add_unique( fn do_drop_constraint_by_name( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, name: &str, ) -> Result, DbError> { @@ -7995,7 +7902,7 @@ fn do_drop_constraint_by_name( rewritten_tables: vec![t.clone()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) })?; return Ok(Some(do_describe_table(conn, table)?)); @@ -8014,7 +7921,6 @@ fn do_drop_constraint_by_name( return do_drop_relationship( conn, persistence, - source, &RelationshipSelector::Named { name: name.to_string(), }, @@ -8054,7 +7960,7 @@ fn do_drop_constraint_by_name( rewritten_tables: vec![table_owned.clone()], ..Changes::default() }; - finalize_persistence(tx, persistence, source, &changes)?; + finalize_persistence(tx, persistence, &changes)?; Ok(()) })?; return Ok(Some(do_describe_table(conn, table)?)); @@ -8076,7 +7982,6 @@ fn do_drop_constraint_by_name( fn do_alter_add_foreign_key( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, child_table: &str, name: Option<&str>, fk: &SqlForeignKey, @@ -8118,7 +8023,6 @@ fn do_alter_add_foreign_key( do_add_relationship( conn, persistence, - source, name, &fk.parent_table, &parent_columns, @@ -8187,7 +8091,6 @@ fn index_exists(conn: &Connection, name: &str, user_only: bool) -> Result, - source: Option<&str>, name: Option<&str>, table: &str, columns: &[String], @@ -8269,7 +8172,7 @@ fn do_add_index( schema_dirty: true, ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(description) } @@ -8279,7 +8182,6 @@ fn do_add_index( fn do_drop_index( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, selector: &IndexSelector, ) -> Result { debug!(selector = ?selector, "drop_index"); @@ -8348,28 +8250,14 @@ fn do_drop_index( schema_dirty: true, ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(description) } -/// Read-only wrapper around `do_describe_table` that runs an -/// auxiliary `history.log` append for user-issued -/// `show table` commands. -// ADR-0052: journaling moved to the dispatch layer, so this read-only -// `show table` wrapper no longer appends to `history.log` — the spawn -// journals the `Ok` outcome. Kept as a thin delegate (a later cleanup -// could inline `do_describe_table` at the one call site); `_persistence` -// / `_source` are vestigial. -fn do_describe_table_request( - conn: &Connection, - _persistence: Option<&Persistence>, - _source: Option<&str>, - name: &str, -) -> Result { - do_describe_table(conn, name) -} - +/// Reads a table's user-facing structure (`show table` / `describe`). +/// ADR-0052: journaling moved to the dispatch layer, so this read does +/// not touch `history.log` — the spawn journals the `Ok` outcome. fn do_describe_table(conn: &Connection, name: &str) -> Result { debug!(name = %name, "describe_table"); // Column info — including the ADR-0029 constraints — comes @@ -8846,11 +8734,9 @@ fn sample_parent_key_tuples( /// form 2). `overrides` carries the `set …` clause (D2): per-column /// pins that replace the heuristic generator and drop the column from the /// generic-fill advisory (D13). -#[allow(clippy::too_many_arguments)] fn do_seed( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, target_column: Option<&str>, count: Option, @@ -8866,7 +8752,7 @@ fn do_seed( // Column-fill (D1 form 2) is a distinct UPDATE path. if let Some(col) = target_column { return do_seed_column_fill( - conn, persistence, source, table, col, count, overrides, rng_seed, + conn, persistence, table, col, count, overrides, rng_seed, ); } @@ -9149,7 +9035,7 @@ fn do_seed( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; let data = if preview_rowids.is_empty() { @@ -9330,7 +9216,6 @@ fn seed_override_literal(value: &Value, column: &str) -> Result fn do_seed_column_fill( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, column: &str, count: Option, @@ -9584,7 +9469,7 @@ fn do_seed_column_fill( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; // Preview the first capped rows (D18). @@ -9722,7 +9607,6 @@ fn insert_one_row( fn do_insert( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, user_columns: Option<&[String]>, user_values: &[Value], @@ -9742,7 +9626,7 @@ fn do_insert( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(InsertResult { rows_affected, data }) } @@ -9786,7 +9670,6 @@ fn build_update_sql( fn do_update( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, assignments: &[(String, Value)], filter: &RowFilter, @@ -9839,7 +9722,7 @@ fn do_update( rewritten_tables: vec![table.to_string()], ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(UpdateResult { rows_affected, @@ -9887,7 +9770,6 @@ fn build_delete_sql( fn do_delete( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, table: &str, filter: &RowFilter, ) -> Result { @@ -9959,7 +9841,7 @@ fn do_delete( rewritten_tables, ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(DeleteResult { @@ -9976,34 +9858,6 @@ fn do_delete( }) } -/// Read-only `show data` wrapper. ADR-0052: journaling moved to the -/// dispatch layer (`_persistence` / `_source` vestigial). -fn do_query_data_request( - conn: &Connection, - _persistence: Option<&Persistence>, - _source: Option<&str>, - table: &str, - filter: Option<&Expr>, - limit: Option, -) -> Result { - // ADR-0052: journaling moved to the dispatch layer (`_persistence` / - // `_source` vestigial; the spawn journals the `Ok` outcome). - do_query_data(conn, table, filter, limit) -} - -/// Worker handler for `Request::RunSelect` (ADR-0030 §6, ADR-0031). -/// ADR-0052: journaling moved to the dispatch layer, so this no longer -/// appends to `history.log` — the spawn journals the literal line so a -/// replay re-runs it (ADR-0030 §11). -fn do_run_select_request( - conn: &Connection, - _persistence: Option<&Persistence>, - _source: Option<&str>, - sql: &str, -) -> Result { - do_run_select(conn, sql) -} - /// Currently-stored non-NULL values of one column, for shortid /// collision-avoidance (passed to `generate_shortid_batch`). fn existing_shortids( @@ -10251,7 +10105,6 @@ fn plan_autogen_autofill( fn do_sql_insert( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, sql: &str, target_table: &str, listed_columns: &[String], @@ -10341,7 +10194,7 @@ fn do_sql_insert( rewritten_tables: vec![target_table.to_string()], ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(InsertResult { rows_affected, @@ -10370,7 +10223,6 @@ fn do_sql_insert( fn do_sql_update( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, sql: &str, target_table: &str, returning: bool, @@ -10423,7 +10275,7 @@ fn do_sql_update( rewritten_tables: vec![target_table.to_string()], ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(UpdateResult { rows_affected, data }) } @@ -10461,7 +10313,6 @@ fn do_sql_update( fn do_sql_delete( conn: &Connection, persistence: Option<&Persistence>, - source: Option<&str>, sql: &str, target_table: &str, returning: bool, @@ -10542,7 +10393,7 @@ fn do_sql_delete( rewritten_tables, ..Changes::default() }; - finalize_persistence(conn, persistence, source, &changes)?; + finalize_persistence(conn, persistence, &changes)?; tx.commit().map_err(DbError::from_rusqlite)?; Ok(DeleteResult { @@ -11104,14 +10955,7 @@ fn read_relationships_inbound( /// the end (regardless of success). A `foreign_key_check` /// before commit verifies the loaded data is consistent — any /// violation aborts with a fatal error. -fn do_rebuild_from_text( - conn: &Connection, - // Vestigial since ADR-0052: `rebuild` is journalled at the dispatch - // layer (`spawn_rebuild`), not here. - _persistence: Option<&Persistence>, - _source: Option<&str>, - project_path: &Path, -) -> Result<(), DbError> { +fn do_rebuild_from_text(conn: &Connection, project_path: &Path) -> Result<(), DbError> { debug!(path = %project_path.display(), "rebuild_from_text"); let yaml_path = project_path.join(PROJECT_YAML); let data_dir = project_path.join(DATA_DIR); @@ -11706,7 +11550,7 @@ mod tests { .await .unwrap(); } - let desc = db.describe_table("T".to_string(), None).await.unwrap(); + let desc = db.describe_table("T".to_string()).await.unwrap(); let id_col = desc.columns.iter().find(|c| c.name == "id").unwrap(); assert_eq!(id_col.user_type, Some(Type::Serial)); for ty in [Type::Date, Type::DateTime, Type::Decimal, Type::ShortId] { @@ -11737,7 +11581,7 @@ mod tests { None) .await .unwrap(); - let before = db.describe_table("T".to_string(), None).await.unwrap(); + let before = db.describe_table("T".to_string()).await.unwrap(); assert_eq!(before.columns[0].user_type, Some(Type::Date)); // Drop it. @@ -11754,7 +11598,7 @@ mod tests { None) .await .unwrap(); - let after = db.describe_table("T".to_string(), None).await.unwrap(); + let after = db.describe_table("T".to_string()).await.unwrap(); assert_eq!(after.columns[0].user_type, Some(Type::DateTime)); } @@ -11768,7 +11612,7 @@ mod tests { .await .unwrap_or_else(|e| panic!("type {ty} failed: {e}")); } - let desc = db.describe_table("T".to_string(), None).await.unwrap(); + let desc = db.describe_table("T".to_string()).await.unwrap(); // 5 user columns + the id PK column. assert_eq!(desc.columns.len(), 6); } @@ -11842,7 +11686,7 @@ mod tests { result.client_side_notes ); // Verify the column is populated 1..3. - let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("T".to_string(), None, None).await.unwrap(); let seq_idx = data.columns.iter().position(|c| c == "seq").unwrap(); let mut filled: Vec = data .rows @@ -11875,7 +11719,7 @@ mod tests { result.client_side_notes ); // Verify each row has a non-null shortid value. - let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("T".to_string(), None, None).await.unwrap(); let tag_idx = data.columns.iter().position(|c| c == "tag").unwrap(); for row in &data.rows { let v = row[tag_idx].as_ref().expect("non-null shortid auto-filled"); @@ -11908,7 +11752,7 @@ mod tests { .await .unwrap(); } - let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("T".to_string(), None, None).await.unwrap(); let seq_idx = data.columns.iter().position(|c| c == "seq").unwrap(); let mut values: Vec = data .rows @@ -11947,7 +11791,7 @@ mod tests { ) .await .unwrap(); - let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("T".to_string(), None, None).await.unwrap(); let seq_idx = data.columns.iter().position(|c| c == "seq").unwrap(); let mut values: Vec = data .rows @@ -12062,7 +11906,7 @@ mod tests { // Row data still accessible (id was preserved); the // dropped column is gone from the projection. - let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("T".to_string(), None, None).await.unwrap(); assert_eq!(data.columns, vec!["id".to_string()]); assert_eq!(data.rows.len(), 1); } @@ -12473,7 +12317,7 @@ mod tests { .await .unwrap(); let orders = db - .describe_table("Orders".to_string(), None) + .describe_table("Orders".to_string()) .await .unwrap(); let outbound = orders @@ -12488,7 +12332,7 @@ mod tests { // Same from the parent perspective via inbound. let customers = db - .describe_table("Customers".to_string(), None) + .describe_table("Customers".to_string()) .await .unwrap(); let inbound = customers @@ -12576,7 +12420,7 @@ mod tests { assert_eq!(note.transformed, 3); assert_eq!(note.lossy, 0); // Data preserved via the per-cell transformer. - let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("T".to_string(), None, None).await.unwrap(); assert_eq!(data.rows.len(), 3); } @@ -12857,7 +12701,7 @@ mod tests { result.client_side ); // Data preserved. - let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("T".to_string(), None, None).await.unwrap(); assert_eq!(data.rows.len(), 3); } @@ -13267,7 +13111,7 @@ mod tests { assert_eq!(note.auto_fill_kind, Some(AutoFillKind::Serial)); // Confirm the filled values: existing 5, fills are 6 // and 7 (continue sequence from MAX+1). - let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("T".to_string(), None, None).await.unwrap(); let code_idx = data.columns.iter().position(|c| c == "code").unwrap(); let mut values: Vec = data .rows @@ -13325,7 +13169,7 @@ mod tests { assert_eq!(note.auto_filled, 2); assert_eq!(note.auto_fill_kind, Some(AutoFillKind::ShortId)); // All three rows now have valid shortids. - let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("T".to_string(), None, None).await.unwrap(); let tag_idx = data.columns.iter().position(|c| c == "tag").unwrap(); for row in &data.rows { let v = row[tag_idx].as_ref().expect("non-null shortid after fill"); @@ -13392,7 +13236,7 @@ mod tests { #[tokio::test] async fn describe_missing_table_returns_no_such_table() { let db = db(); - let err = db.describe_table("Ghost".to_string(), None).await.unwrap_err(); + let err = db.describe_table("Ghost".to_string()).await.unwrap_err(); match err { DbError::Sqlite { kind, .. } => assert_eq!(kind, SqliteErrorKind::NoSuchTable), other => panic!("unexpected error: {other:?}"), @@ -13437,7 +13281,7 @@ mod tests { None) .await .unwrap(); - let orders = db.describe_table("Orders".to_string(), None).await.unwrap(); + let orders = db.describe_table("Orders".to_string()).await.unwrap(); assert_eq!(orders.outbound_relationships.len(), 1); let rel = &orders.outbound_relationships[0]; assert_eq!(rel.local_columns, vec!["CustId".to_string()]); @@ -13462,7 +13306,7 @@ mod tests { None) .await .unwrap(); - let customers = db.describe_table("Customers".to_string(), None).await.unwrap(); + let customers = db.describe_table("Customers".to_string()).await.unwrap(); assert_eq!(customers.inbound_relationships.len(), 1); let rel = &customers.inbound_relationships[0]; assert_eq!(rel.local_columns, vec!["id".to_string()]); @@ -13486,7 +13330,7 @@ mod tests { None) .await .unwrap(); - let orders = db.describe_table("Orders".to_string(), None).await.unwrap(); + let orders = db.describe_table("Orders".to_string()).await.unwrap(); let rel = &orders.outbound_relationships[0]; assert_eq!(rel.name, "cust_orders"); assert_eq!(rel.on_delete, ReferentialAction::Cascade); @@ -13525,7 +13369,7 @@ mod tests { ) .await .unwrap(); - let orders = db.describe_table("Orders".to_string(), None).await.unwrap(); + let orders = db.describe_table("Orders".to_string()).await.unwrap(); // The auto-created FK column has user_type Int (Serial's // fk_target_type), not Serial. let cust = orders @@ -13675,8 +13519,8 @@ mod tests { }, None) .await .unwrap(); - let orders = db.describe_table("Orders".to_string(), None).await.unwrap(); - let customers = db.describe_table("Customers".to_string(), None).await.unwrap(); + let orders = db.describe_table("Orders".to_string()).await.unwrap(); + let customers = db.describe_table("Customers".to_string()).await.unwrap(); assert!(orders.outbound_relationships.is_empty()); assert!(customers.inbound_relationships.is_empty()); } @@ -13705,7 +13549,7 @@ mod tests { }, None) .await .unwrap(); - let orders = db.describe_table("Orders".to_string(), None).await.unwrap(); + let orders = db.describe_table("Orders".to_string()).await.unwrap(); assert!(orders.outbound_relationships.is_empty()); } @@ -13753,7 +13597,7 @@ mod tests { // Dropping the child is allowed (no inbound relationships // on Orders) and cleans the metadata. db.drop_table("Orders".to_string(), None).await.unwrap(); - let customers = db.describe_table("Customers".to_string(), None).await.unwrap(); + let customers = db.describe_table("Customers".to_string()).await.unwrap(); assert!(customers.inbound_relationships.is_empty()); } @@ -13819,7 +13663,7 @@ mod tests { // After the rebuild, the original columns are still // present with the right user types, and any extra // metadata (Name on Customers) survives. - let customers = db.describe_table("Customers".to_string(), None).await.unwrap(); + let customers = db.describe_table("Customers".to_string()).await.unwrap(); let names: Vec<&str> = customers.columns.iter().map(|c| c.name.as_str()).collect(); assert_eq!(names, vec!["id", "Name"]); let name_col = customers.columns.iter().find(|c| c.name == "Name").unwrap(); @@ -13854,7 +13698,7 @@ mod tests { // The InsertResult itself carries the just-inserted row. assert_eq!(result.data.rows.len(), 1); assert_eq!(result.data.rows[0][1], Some("Alice".to_string())); - let data = db.query_data("Customers".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("Customers".to_string(), None, None).await.unwrap(); assert_eq!(data.columns, vec!["id".to_string(), "Name".to_string()]); assert_eq!(data.rows.len(), 1); assert_eq!(data.rows[0][1], Some("Alice".to_string())); @@ -13879,7 +13723,7 @@ mod tests { None) .await .unwrap(); - let data = db.query_data("Tags".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("Tags".to_string(), None, None).await.unwrap(); let id = data.rows[0][0].as_ref().expect("auto-generated id"); assert!( id.len() >= 10 && id.len() <= 12, @@ -13898,7 +13742,7 @@ mod tests { None) .await .unwrap(); - let data = db.query_data("Customers".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("Customers".to_string(), None, None).await.unwrap(); assert_eq!(data.rows[0][0], Some("99".to_string())); assert_eq!(data.rows[0][1], Some("Bob".to_string())); } @@ -13944,7 +13788,7 @@ mod tests { // The UpdateResult contains only the updated rows. assert_eq!(result.data.rows.len(), 1); assert_eq!(result.data.rows[0][1], Some("Alicia".to_string())); - let data = db.query_data("Customers".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("Customers".to_string(), None, None).await.unwrap(); assert_eq!(data.rows[0][1], Some("Alicia".to_string())); assert_eq!(data.rows[1][1], Some("Bob".to_string())); } @@ -14040,7 +13884,7 @@ mod tests { // Carol (45/true) and Dave (35/true) match; Bob (35) is // inactive, Alice (25) is too young. assert_eq!(result.rows_affected, 2); - let data = db.query_data("People".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("People".to_string(), None, None).await.unwrap(); assert_eq!(names(&data), vec!["Alice", "Bob"]); } @@ -14057,7 +13901,7 @@ mod tests { .await .unwrap(); assert_eq!(result.rows_affected, 2); - let data = db.query_data("People".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("People".to_string(), None, None).await.unwrap(); assert_eq!(names(&data), vec!["Bob", "Dave"]); } @@ -14075,7 +13919,7 @@ mod tests { .await .unwrap(); assert_eq!(result.rows_affected, 2); - let data = db.query_data("People".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("People".to_string(), None, None).await.unwrap(); assert_eq!(names(&data), vec!["Bob", "Dave"]); } @@ -14093,7 +13937,7 @@ mod tests { .unwrap(); // Bob (35) and Dave (35) are in range. assert_eq!(result.rows_affected, 2); - let data = db.query_data("People".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("People".to_string(), None, None).await.unwrap(); assert_eq!(names(&data), vec!["Alice", "Carol"]); } @@ -14112,7 +13956,7 @@ mod tests { .await .unwrap(); assert_eq!(result.rows_affected, 2); - let data = db.query_data("People".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("People".to_string(), None, None).await.unwrap(); assert_eq!(names(&data), vec!["Bob", "Dave"]); } @@ -14129,7 +13973,7 @@ mod tests { .await .unwrap(); assert_eq!(result.rows_affected, 1, "only Alice matches `A%`"); - let data = db.query_data("People".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("People".to_string(), None, None).await.unwrap(); assert_eq!(names(&data), vec!["Bob", "Carol", "Dave"]); } @@ -14160,7 +14004,7 @@ mod tests { people_table(&db).await; let (filter, limit) = parse_show("show data People where Active = true"); let data = db - .query_data("People".to_string(), filter, limit, None) + .query_data("People".to_string(), filter, limit) .await .unwrap(); assert_eq!(names(&data), vec!["Alice", "Carol", "Dave"]); @@ -14172,7 +14016,7 @@ mod tests { people_table(&db).await; let (filter, limit) = parse_show("show data People limit 2"); let data = db - .query_data("People".to_string(), filter, limit, None) + .query_data("People".to_string(), filter, limit) .await .unwrap(); // `limit` implies an ORDER BY the primary key, so this @@ -14187,7 +14031,7 @@ mod tests { let (filter, limit) = parse_show("show data People where Age >= 35 limit 1"); let data = db - .query_data("People".to_string(), filter, limit, None) + .query_data("People".to_string(), filter, limit) .await .unwrap(); // Three rows match `Age >= 35` (Bob, Carol, Dave); the @@ -14264,7 +14108,7 @@ mod tests { assert!(!plan.rows.is_empty()); // ADR-0028 §1: EXPLAIN QUERY PLAN never executes. let data = db - .query_data("People".to_string(), None, None, None) + .query_data("People".to_string(), None, None) .await .unwrap(); assert_eq!(data.rows.len(), 4, "explain delete must not delete"); @@ -14281,7 +14125,7 @@ mod tests { .unwrap(); let (filter, _) = parse_show("show data People where Active = true"); let data = db - .query_data("People".to_string(), filter, None, None) + .query_data("People".to_string(), filter, None) .await .unwrap(); // Alice, Carol, Dave are still active — nothing ran. @@ -14410,7 +14254,7 @@ mod tests { .await .unwrap(); let data = db - .query_data("People".to_string(), None, None, None) + .query_data("People".to_string(), None, None) .await .unwrap(); assert_eq!(data.rows.len(), 4, "explain delete must not delete"); @@ -14426,7 +14270,7 @@ mod tests { .await .unwrap(); let data = db - .query_data("People".to_string(), None, None, None) + .query_data("People".to_string(), None, None) .await .unwrap(); assert!( @@ -14462,7 +14306,7 @@ mod tests { .await .unwrap(); let data = db - .query_data("People".to_string(), None, None, None) + .query_data("People".to_string(), None, None) .await .unwrap(); assert_eq!(data.rows.len(), 4, "explain insert must not insert"); @@ -14486,7 +14330,7 @@ mod tests { plan.rows, ); let data = db - .query_data("People".to_string(), None, None, None) + .query_data("People".to_string(), None, None) .await .unwrap(); assert_eq!(data.rows.len(), 4, "explain insert-select must not insert"); @@ -14622,7 +14466,7 @@ mod tests { .await .unwrap(); let data = db - .query_data("T".to_string(), None, None, None) + .query_data("T".to_string(), None, None) .await .unwrap(); let tier_idx = data.columns.iter().position(|c| c == "tier").unwrap(); @@ -14685,7 +14529,7 @@ mod tests { ) .await .unwrap(); - let desc = db.describe_table("T".to_string(), None).await.unwrap(); + let desc = db.describe_table("T".to_string()).await.unwrap(); let email = desc.columns.iter().find(|c| c.name == "email").unwrap(); assert!(email.notnull && email.unique, "email keeps NOT NULL + UNIQUE"); let tier = desc.columns.iter().find(|c| c.name == "tier").unwrap(); @@ -14710,7 +14554,7 @@ mod tests { .await .unwrap(); let data = db - .query_data("People".to_string(), None, None, None) + .query_data("People".to_string(), None, None) .await .unwrap(); let idx = data.columns.iter().position(|c| c == "tier").unwrap(); @@ -14755,7 +14599,7 @@ mod tests { ) .await .expect("NOT NULL with no default is fine on an empty table"); - let desc = db.describe_table("T".to_string(), None).await.unwrap(); + let desc = db.describe_table("T".to_string()).await.unwrap(); assert!(desc.columns.iter().find(|c| c.name == "x").unwrap().notnull); } @@ -14770,7 +14614,7 @@ mod tests { ) .await .unwrap(); - let desc = db.describe_table("People".to_string(), None).await.unwrap(); + let desc = db.describe_table("People".to_string()).await.unwrap(); let tier = desc.columns.iter().find(|c| c.name == "tier").unwrap(); assert!(tier.notnull); assert_eq!(tier.default.as_deref(), Some("0")); @@ -14787,7 +14631,7 @@ mod tests { ) .await .expect("a UNIQUE column with no default is fine — NULLs do not collide"); - let desc = db.describe_table("People".to_string(), None).await.unwrap(); + let desc = db.describe_table("People".to_string()).await.unwrap(); assert!(desc.columns.iter().find(|c| c.name == "badge").unwrap().unique); } @@ -14878,7 +14722,7 @@ mod tests { let (n, c, pk) = parse_create("create table T with pk age(int) check (age >= 0)"); db.create_table(n, c, pk, None).await.unwrap(); - let desc = db.describe_table("T".to_string(), None).await.unwrap(); + let desc = db.describe_table("T".to_string()).await.unwrap(); let age = desc.columns.iter().find(|c| c.name == "age").unwrap(); let check = age.check.as_deref().expect("age carries a CHECK"); assert!( @@ -14898,7 +14742,7 @@ mod tests { ) .await .expect("a CHECK column adds via the rebuild path"); - let desc = db.describe_table("People".to_string(), None).await.unwrap(); + let desc = db.describe_table("People".to_string()).await.unwrap(); assert!(desc.columns.iter().find(|c| c.name == "score").unwrap().check.is_some()); // An update that violates the check is refused. let bad = db @@ -14932,7 +14776,7 @@ mod tests { ) .await .unwrap(); - let desc = db.describe_table("T".to_string(), None).await.unwrap(); + let desc = db.describe_table("T".to_string()).await.unwrap(); assert!( desc.columns.iter().find(|c| c.name == "code").unwrap().check.is_some(), "code keeps its CHECK across the rebuild", @@ -15003,7 +14847,7 @@ mod tests { .unwrap(); assert_eq!(result.rows_affected, 1); assert!(result.cascade.is_empty(), "no children to cascade to"); - let data = db.query_data("Customers".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("Customers".to_string(), None, None).await.unwrap(); assert_eq!(data.rows.len(), 1); assert_eq!(data.rows[0][1], Some("Bob".to_string())); } @@ -15110,7 +14954,7 @@ mod tests { None) .await .unwrap(); - let orders = db.query_data("Orders".to_string(), None, None, None).await.unwrap(); + let orders = db.query_data("Orders".to_string(), None, None).await.unwrap(); assert!(orders.rows.is_empty(), "child rows should be cascaded"); } @@ -15210,7 +15054,7 @@ mod tests { None) .await .unwrap(); - let data = db.query_data("Flags".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("Flags".to_string(), None, None).await.unwrap(); assert_eq!(data.rows[0][1], Some("true".to_string())); assert_eq!(data.rows[1][1], Some("false".to_string())); } @@ -15232,7 +15076,7 @@ mod tests { None) .await .unwrap(); - let data = db.query_data("T".to_string(), None, None, None).await.unwrap(); + let data = db.query_data("T".to_string(), None, None).await.unwrap(); assert_eq!(data.rows[0][1], None); } @@ -15491,7 +15335,7 @@ mod tests { .await .unwrap(); // Read the schema and confirm `seq` is still unique. - let desc = db.describe_table("T".to_string(), None).await.unwrap(); + let desc = db.describe_table("T".to_string()).await.unwrap(); let seq = desc .columns .iter() @@ -15543,7 +15387,7 @@ mod tests { .unwrap(); let tables = db.list_tables().await.unwrap(); assert_eq!(tables, vec!["Order Lines".to_string()]); - let desc = db.describe_table("Order Lines".to_string(), None).await.unwrap(); + let desc = db.describe_table("Order Lines".to_string()).await.unwrap(); assert_eq!(desc.name, "Order Lines"); } diff --git a/src/runtime.rs b/src/runtime.rs index 0b9667b..ca17a3f 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1190,7 +1190,7 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac // miss leaves that table's columns unpopulated and the // walker falls back to the schemaless value-literal list. for name in cache.tables.clone() { - if let Ok(desc) = database.describe_table(name.clone(), None).await { + if let Ok(desc) = database.describe_table(name.clone()).await { // Per-table indexes for the items panel (S2, ADR-0025). // Carry uniqueness so the panel can mark a UNIQUE index // (ADR-0035 §4d). Captured before `desc.columns` is @@ -1650,7 +1650,7 @@ async fn build_show_data_echo( limit: Some(_), .. } => database - .describe_table(name.clone(), None) + .describe_table(name.clone()) .await .map(|desc| { desc.columns @@ -1732,7 +1732,7 @@ async fn collect_echo_lookups( Command::DropIndex { selector: IndexSelector::Columns { table, columns }, } => { - if let Ok(desc) = database.describe_table(table.clone(), None).await + if let Ok(desc) = database.describe_table(table.clone()).await && let Some(idx) = desc.indexes.iter().find(|i| i.columns == *columns) { out.drop_index_name = Some(idx.name.clone()); @@ -1747,7 +1747,7 @@ async fn collect_echo_lookups( child_column, }, } => { - if let Ok(desc) = database.describe_table(child_table.clone(), None).await + if let Ok(desc) = database.describe_table(child_table.clone()).await && let Some(rel) = desc.outbound_relationships.iter().find(|r| { // The Endpoints drop selector is single-column // (ADR-0043 keeps DROP by-endpoints single-column; @@ -1771,7 +1771,7 @@ async fn collect_echo_lookups( // resolver API would be the next step if schemas grow. if let Ok(tables) = database.list_tables().await { for table in tables { - if let Ok(desc) = database.describe_table(table.clone(), None).await + if let Ok(desc) = database.describe_table(table.clone()).await && desc.outbound_relationships.iter().any(|r| r.name == *name) { out.drop_relationship = Some((name.clone(), table.clone())); @@ -1795,8 +1795,8 @@ async fn collect_echo_lookups( // *before* execution to know which `ADD COLUMN` lines to // emit. The parent columns here are the explicit DSL list, // paired positionally with the child list. - let parent_desc = database.describe_table(parent_table.clone(), None).await; - let child_desc = database.describe_table(child_table.clone(), None).await; + let parent_desc = database.describe_table(parent_table.clone()).await; + let child_desc = database.describe_table(child_table.clone()).await; if let (Ok(parent_desc), Ok(child_desc)) = (parent_desc, child_desc) { let mut new_columns: Vec<(String, crate::dsl::types::Type)> = Vec::new(); for (child_col, parent_col) in child_columns.iter().zip(parent_columns) { @@ -2064,7 +2064,7 @@ async fn enrich_check_violation( .await .map(|v| v.to_string()); // The rule itself — the column's compiled CHECK expression. - if let Ok(desc) = database.describe_table(table.to_string(), None).await + if let Ok(desc) = database.describe_table(table.to_string()).await && let Some(col) = desc.columns.iter().find(|c| c.name == column) { facts.check_rule.clone_from(&col.check); @@ -2272,7 +2272,7 @@ async fn user_value_for_column_with_schema( } = command { let desc = database - .describe_table(table.to_string(), None) + .describe_table(table.to_string()) .await .ok()?; // Build the natural-order column list the same way @@ -2311,7 +2311,7 @@ async fn user_value_for_column_with_schema( && literal_rows.len() == 1 { let desc = database - .describe_table(table.to_string(), None) + .describe_table(table.to_string()) .await .ok()?; let idx = desc.columns.iter().position(|c| c.name == column)?; @@ -2930,7 +2930,7 @@ async fn execute_command_typed( .await .map(|d| CommandOutcome::Schema(Some(d))), Command::ShowTable { name } => database - .describe_table(name, src) + .describe_table(name) .await .map(|d| CommandOutcome::Schema(Some(d))), // ADR-0044: a named relationship renders as a diagram (App-side), @@ -2983,14 +2983,14 @@ async fn execute_command_typed( filter, limit, } => database - .query_data(name, filter, limit, src) + .query_data(name, filter, limit) .await .map(CommandOutcome::Query), // A SQL `SELECT` (advanced mode; ADR-0030 §6, ADR-0031). // The grammar walker has already validated `sql` is in // the supported subset; the worker runs it as text. Command::Select { sql } => database - .run_select(sql, src) + .run_select(sql) .await .map(CommandOutcome::Query), // A SQL `INSERT` (advanced mode; ADR-0033 §1). Grammar-as- diff --git a/tests/it/case_insensitive_names.rs b/tests/it/case_insensitive_names.rs index 6b644d6..fe28129 100644 --- a/tests/it/case_insensitive_names.rs +++ b/tests/it/case_insensitive_names.rs @@ -93,7 +93,7 @@ fn rename_column_with_case_variant_table_keeps_metadata_in_step() { .expect("rename column via a case-variant table name"); let desc = r - .block_on(db.describe_table("Items".to_string(), None)) + .block_on(db.describe_table("Items".to_string())) .expect("describe Items"); let amount = desc .columns @@ -126,7 +126,7 @@ fn insert_with_case_variant_table_persists_and_survives_rebuild() { let db = fresh_rebuild(db, &project, &r); let rows = r - .block_on(db.query_data("Items".to_string(), None, None, None)) + .block_on(db.query_data("Items".to_string(), None, None)) .expect("query") .rows; assert_eq!(rows.len(), 1, "the wrong-case insert survived the rebuild (no data loss)"); @@ -146,7 +146,7 @@ fn add_column_with_case_variant_table_survives_rebuild() { ); let db = fresh_rebuild(db, &project, &r); - let desc = r.block_on(db.describe_table("Items".to_string(), None)).expect("describe"); + let desc = r.block_on(db.describe_table("Items".to_string())).expect("describe"); let qty = desc.columns.iter().find(|c| c.name == "qty").expect("qty added"); assert_eq!(qty.user_type, Some(Type::Int), "qty's user-type survived the rebuild"); // The CHECK is intact too (a negative qty is refused under the real table). @@ -224,12 +224,12 @@ fn add_relationship_with_case_variant_tables_survives_rebuild() { add 1:n relationship from parent.id to child.parent_id\n", ); // The parent's inbound relationship is visible under the stored case. - let p = r.block_on(db.describe_table("Parent".to_string(), None)).expect("describe Parent"); + let p = r.block_on(db.describe_table("Parent".to_string())).expect("describe Parent"); assert_eq!(p.inbound_relationships.len(), 1, "relationship recorded under the stored case"); assert_eq!(p.inbound_relationships[0].other_table, "Child"); let db = fresh_rebuild(db, &project, &r); - let p = r.block_on(db.describe_table("Parent".to_string(), None)).expect("describe Parent"); + let p = r.block_on(db.describe_table("Parent".to_string())).expect("describe Parent"); assert_eq!(p.inbound_relationships.len(), 1, "relationship survived the rebuild"); assert_eq!(p.inbound_relationships[0].other_table, "Child"); } diff --git a/tests/it/compound_fk.rs b/tests/it/compound_fk.rs index 3ca22df..7ab6000 100644 --- a/tests/it/compound_fk.rs +++ b/tests/it/compound_fk.rs @@ -276,7 +276,7 @@ fn compound_fk_declares_enforces_and_round_trips() { ); // describe shows the compound endpoints symmetrically. - let city = db.describe_table("City".to_string(), None).await.unwrap(); + let city = db.describe_table("City".to_string()).await.unwrap(); let outbound = &city.outbound_relationships[0]; assert_eq!( outbound.local_columns, @@ -329,7 +329,7 @@ fn compound_fk_create_fk_makes_both_child_columns() { ) .await .expect("add compound relationship with --create-fk"); - let city = db.describe_table("City".to_string(), None).await.unwrap(); + let city = db.describe_table("City".to_string()).await.unwrap(); for col in ["c_country", "c_code"] { assert!( city.columns.iter().any(|c| c.name == col), @@ -527,7 +527,7 @@ fn compound_fk_survives_rebuild_from_text() { .await; assert!(bad.is_err(), "compound FK still enforced after rebuild from text"); // Endpoints survived the round-trip intact. - let city = db.describe_table("City".to_string(), None).await.unwrap(); + let city = db.describe_table("City".to_string()).await.unwrap(); assert_eq!( city.outbound_relationships[0].other_columns, vec!["country".to_string(), "code".to_string()], @@ -563,7 +563,7 @@ fn compound_fk_undo_removes_the_relationship() { .await .expect("add compound relationship"); assert_eq!( - db.describe_table("City".to_string(), None) + db.describe_table("City".to_string()) .await .unwrap() .outbound_relationships @@ -573,7 +573,7 @@ fn compound_fk_undo_removes_the_relationship() { // One undo step removes the whole relationship (ADR-0013/0006). db.undo().await.unwrap().expect("undo applied"); assert!( - db.describe_table("City".to_string(), None) + db.describe_table("City".to_string()) .await .unwrap() .outbound_relationships diff --git a/tests/it/iteration3_rebuild.rs b/tests/it/iteration3_rebuild.rs index 4361dfb..922811d 100644 --- a/tests/it/iteration3_rebuild.rs +++ b/tests/it/iteration3_rebuild.rs @@ -76,7 +76,7 @@ fn rebuild_restores_schema_only_project() { // Phase 4: confirm Customers exists with the right shape. let desc = rt() - .block_on(async { db.describe_table("Customers".to_string(), None).await }) + .block_on(async { db.describe_table("Customers".to_string()).await }) .expect("describe_table"); assert_eq!(desc.name, "Customers"); let cols: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect(); @@ -143,7 +143,7 @@ fn rebuild_restores_rows_from_csv() { }); let rows = rt() - .block_on(async { db.query_data("Customers".to_string(), None, None, None).await }) + .block_on(async { db.query_data("Customers".to_string(), None, None).await }) .expect("query_data"); assert_eq!(rows.rows.len(), 2); let names: Vec> = rows.rows.iter().map(|r| r[1].clone()).collect(); @@ -371,7 +371,7 @@ fn rebuild_preserves_created_at_from_yaml() { // Trigger any successful command so project.yaml is // rewritten from the now-rebuilt db state. rt().block_on(async { - db.describe_table("T".to_string(), Some("show table T".to_string())) + db.describe_table("T".to_string()) .await .unwrap(); // describe is read-only; force a rewrite by adding a column. @@ -451,7 +451,7 @@ fn rebuild_restores_indexes() { }); let desc = rt() - .block_on(async { db.describe_table("Customers".to_string(), None).await }) + .block_on(async { db.describe_table("Customers".to_string()).await }) .expect("describe_table"); assert_eq!(desc.indexes.len(), 1, "index should survive rebuild"); assert_eq!(desc.indexes[0].name, "idx_email"); diff --git a/tests/it/iteration4a_rebuild_command.rs b/tests/it/iteration4a_rebuild_command.rs index 8fb6972..ed64a62 100644 --- a/tests/it/iteration4a_rebuild_command.rs +++ b/tests/it/iteration4a_rebuild_command.rs @@ -173,7 +173,7 @@ fn rebuild_against_populated_db_wipes_and_reloads() { .expect("rebuild"); }); let rows = rt() - .block_on(async { db.query_data("Customers".to_string(), None, None, None).await }) + .block_on(async { db.query_data("Customers".to_string(), None, None).await }) .unwrap(); assert_eq!(rows.rows.len(), 1); assert_eq!(rows.rows[0][1].as_deref(), Some("Edna")); diff --git a/tests/it/iteration5_export_import.rs b/tests/it/iteration5_export_import.rs index 09a79fb..62ccee1 100644 --- a/tests/it/iteration5_export_import.rs +++ b/tests/it/iteration5_export_import.rs @@ -362,7 +362,7 @@ fn end_to_end_export_then_import_real_project() { // Round-trip: the inserted row is back. let data_view = rt() - .block_on(async { imported_db.query_data("Customers".to_string(), None, None, None).await }) + .block_on(async { imported_db.query_data("Customers".to_string(), None, None).await }) .expect("query data"); assert_eq!(data_view.rows.len(), 1); // Serial id auto-filled to 1; Name was the inserted value. diff --git a/tests/it/m2n.rs b/tests/it/m2n.rs index 972a8b3..189c37d 100644 --- a/tests/it/m2n.rs +++ b/tests/it/m2n.rs @@ -107,7 +107,7 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() { assert!(tables.contains(&"Students_Courses".to_string()), "tables: {tables:?}"); // Two FK columns, both part of the compound PK. - let desc = db.describe_table("Students_Courses".to_string(), None).await.unwrap(); + let desc = db.describe_table("Students_Courses".to_string()).await.unwrap(); let cols: Vec<(&str, bool)> = desc.columns.iter().map(|c| (c.name.as_str(), c.primary_key)).collect(); assert_eq!( @@ -191,7 +191,7 @@ fn compound_parent_pk_contributes_one_fk_column_each() { .await .expect("create m:n"); - let desc = db.describe_table("Students_Sections".to_string(), None).await.unwrap(); + let desc = db.describe_table("Students_Sections".to_string()).await.unwrap(); let names: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect(); assert_eq!(names, vec!["Students_id", "Sections_course_id", "Sections_term"]); // All three form the compound PK. @@ -221,7 +221,7 @@ fn deleting_a_parent_cascades_to_the_junction() { // Deleting the student cascades to the junction (ON DELETE CASCADE). db.delete("Students".to_string(), RowFilter::AllRows, None).await.unwrap(); - let rows = db.query_data("Students_Courses".to_string(), None, None, None).await.unwrap(); + let rows = db.query_data("Students_Courses".to_string(), None, None).await.unwrap(); assert!(rows.rows.is_empty(), "junction rows should cascade-delete, got {:?}", rows.rows); }); } @@ -249,7 +249,7 @@ fn create_m2n_is_one_undo_step() { let tables = db.list_tables().await.unwrap(); assert!(!tables.contains(&"Students_Courses".to_string()), "undo should remove the junction: {tables:?}"); // The parents' relationships are gone too (the junction held them). - let students = db.describe_table("Students".to_string(), None).await.unwrap(); + let students = db.describe_table("Students".to_string()).await.unwrap(); assert!(students.inbound_relationships.is_empty(), "no leftover relationship after undo"); }); } @@ -321,7 +321,7 @@ fn the_junction_can_be_renamed() { assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}"); assert!(!tables.contains(&"Students_Courses".to_string())); // Both relationships survive the rename (rebuild-preserving). - let desc = db.describe_table("Enrollments".to_string(), None).await.unwrap(); + let desc = db.describe_table("Enrollments".to_string()).await.unwrap(); assert_eq!(desc.outbound_relationships.len(), 2, "FKs preserved across rename"); }); } @@ -362,7 +362,7 @@ fn junction_survives_save_and_rebuild() { db.rebuild_from_text(project.path().to_path_buf(), None).await.expect("rebuild"); let tables = db.list_tables().await.unwrap(); assert!(tables.contains(&"Students_Courses".to_string()), "junction survived: {tables:?}"); - let desc = db.describe_table("Students_Courses".to_string(), None).await.unwrap(); + let desc = db.describe_table("Students_Courses".to_string()).await.unwrap(); assert_eq!(desc.outbound_relationships.len(), 2, "both FKs reconstructed"); assert!(desc.columns.iter().all(|c| c.primary_key), "compound PK reconstructed"); }); diff --git a/tests/it/replay_command.rs b/tests/it/replay_command.rs index d003ebf..d5ccb33 100644 --- a/tests/it/replay_command.rs +++ b/tests/it/replay_command.rs @@ -108,13 +108,13 @@ fn replay_runs_advanced_sql_create_table_as_a_write() { // The SQL DDL line actually created the structural table… let desc = rt() - .block_on(async { db.describe_table("Widget".to_string(), None).await }) + .block_on(async { db.describe_table("Widget".to_string()).await }) .expect("describe"); let names: Vec = desc.columns.iter().map(|c| c.name.clone()).collect(); assert_eq!(names, vec!["id".to_string(), "name".to_string()]); // …and the following insert (serial id auto-filled) ran against it. let rows = rt() - .block_on(async { db.query_data("Widget".to_string(), None, None, None).await }) + .block_on(async { db.query_data("Widget".to_string(), None, None).await }) .expect("query") .rows; assert_eq!(rows.len(), 1); @@ -139,7 +139,7 @@ fn replay_three_lines_dispatches_three_commands() { // The dispatched commands actually mutated state. let data_result = rt() - .block_on(async { db.query_data("T".to_string(), None, None, None).await }) + .block_on(async { db.query_data("T".to_string(), None, None).await }) .expect("query_data"); assert_eq!(data_result.rows.len(), 1, "row inserted"); assert_eq!(data_result.rows[0][1].as_deref(), Some("Alice")); @@ -174,7 +174,7 @@ fn replay_of_actual_history_log_runs_ok_commands_and_skips_err() { assert_completed(&events, 3); let data_result = rt() - .block_on(async { db.query_data("T".to_string(), None, None, None).await }) + .block_on(async { db.query_data("T".to_string(), None, None).await }) .expect("query_data"); assert_eq!(data_result.rows.len(), 1, "only the ok INSERT applied"); assert_eq!(data_result.rows[0][1].as_deref(), Some("alpha")); @@ -227,7 +227,7 @@ fn replay_skips_app_lifecycle_commands_silently() { other => panic!("expected ReplayCompleted, got {other:?}"), } let data_result = rt() - .block_on(async { db.query_data("T".to_string(), None, None, None).await }) + .block_on(async { db.query_data("T".to_string(), None, None).await }) .expect("query_data"); assert!( data_result.columns.iter().any(|c| c == "v"), @@ -401,14 +401,14 @@ fn replay_aborts_on_first_parse_failure_and_reports_line() { // but earlier commands stayed applied (table T exists with // the `name` column). let desc = rt() - .block_on(async { db.describe_table("T".to_string(), None).await }) + .block_on(async { db.describe_table("T".to_string()).await }) .expect("describe_table"); assert!( desc.columns.iter().any(|c| c.name == "name"), "earlier add column should have stayed applied" ); let data_result = rt() - .block_on(async { db.query_data("T".to_string(), None, None, None).await }) + .block_on(async { db.query_data("T".to_string(), None, None).await }) .expect("query_data"); assert!( data_result.rows.is_empty(), @@ -467,7 +467,7 @@ fn replay_rejects_wrong_type_value_in_a_hand_built_script() { // The earlier two lines stayed applied; the failing insert // did not run — state is intact. let data_result = rt() - .block_on(async { db.query_data("T".to_string(), None, None, None).await }) + .block_on(async { db.query_data("T".to_string(), None, None).await }) .expect("query_data"); assert!( data_result.rows.is_empty(), @@ -527,7 +527,7 @@ fn replay_skips_nested_replay_with_a_warning() { other => panic!("expected ReplayCompleted (nested replay skipped), got {other:?}"), } // The nested file's table was NOT created (the replay was skipped). - let cols = rt().block_on(async { db.query_data("T".to_string(), None, None, None).await }); + let cols = rt().block_on(async { db.query_data("T".to_string(), None, None).await }); assert!(cols.is_err(), "inner.commands' table T must not exist (nested replay skipped)"); } diff --git a/tests/it/show_list.rs b/tests/it/show_list.rs index f3750e7..a5f9c69 100644 --- a/tests/it/show_list.rs +++ b/tests/it/show_list.rs @@ -462,7 +462,7 @@ fn app_show_table_renders_relationships_as_compact_diagrams() { rt.block_on(seed_schema(&db)); // Orders holds the FK to Customers — an outbound relationship. let desc = rt - .block_on(db.describe_table("Orders".to_string(), None)) + .block_on(db.describe_table("Orders".to_string())) .expect("describe Orders"); let mut app = App::new(); diff --git a/tests/it/sql_alter_table.rs b/tests/it/sql_alter_table.rs index 70c1a97..401b097 100644 --- a/tests/it/sql_alter_table.rs +++ b/tests/it/sql_alter_table.rs @@ -111,7 +111,7 @@ fn e2e_alter_drop_compound_primary_key_member_is_refused() { /// The current user-facing type of column `name` in table `T`. fn col_type(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option { - r.block_on(db.describe_table("T".to_string(), None)) + r.block_on(db.describe_table("T".to_string())) .expect("describe") .columns .into_iter() @@ -120,7 +120,7 @@ fn col_type(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option Vec { - r.block_on(db.describe_table("T".to_string(), None)) + r.block_on(db.describe_table("T".to_string())) .expect("describe") .columns .into_iter() @@ -163,7 +163,7 @@ fn e2e_alter_table_add_rename_drop_and_raw_default_check() { // The DEFAULT backfilled the pre-existing row to qty = 0. let rows = r - .block_on(db.query_data("T".to_string(), None, None, None)) + .block_on(db.query_data("T".to_string(), None, None)) .expect("query") .rows; assert_eq!(rows.len(), 1); @@ -252,7 +252,7 @@ fn e2e_alter_column_type_clean_and_lossy_convert() { } let rows = r - .block_on(db.query_data("T".to_string(), None, None, None)) + .block_on(db.query_data("T".to_string(), None, None)) .expect("query") .rows; assert_eq!(rows.len(), 1); @@ -292,7 +292,7 @@ fn e2e_alter_column_type_int_to_serial_is_allowed() { } assert_eq!(col_type(&db, &r, "n"), Some(Type::Serial), "int→serial converted the column"); let rows = r - .block_on(db.query_data("T".to_string(), None, None, None)) + .block_on(db.query_data("T".to_string(), None, None)) .expect("query") .rows; assert_eq!(rows[0][1].as_deref(), Some("100"), "the existing value is preserved"); @@ -635,7 +635,7 @@ fn e2e_drop_composite_unique_is_one_undo_step() { .expect("write"); r.block_on(run_replay(&db, project.path(), "u.commands")); let has_unique = || { - !r.block_on(db.describe_table("T".to_string(), None)) + !r.block_on(db.describe_table("T".to_string())) .expect("describe") .unique_constraints .is_empty() @@ -878,7 +878,7 @@ fn e2e_describe_shows_table_level_constraints() { "events: {events:?}" ); - let desc = r.block_on(db.describe_table("T".to_string(), None)).expect("describe"); + let desc = r.block_on(db.describe_table("T".to_string())).expect("describe"); assert_eq!( desc.unique_constraints, vec![vec!["a".to_string(), "b".to_string()]], @@ -976,7 +976,7 @@ fn e2e_rename_table_with_rows_csv_follows_and_survives_rebuild() { assert!(!csv_path(&project, "Orders").exists(), "data/Orders.csv removed"); let rows = r - .block_on(db.query_data("Purchases".to_string(), None, None, None)) + .block_on(db.query_data("Purchases".to_string(), None, None)) .expect("query") .rows; assert_eq!(rows.len(), 2); @@ -991,7 +991,7 @@ fn e2e_rename_table_with_rows_csv_follows_and_survives_rebuild() { "Purchases round-tripped through a fresh rebuild: {tables:?}" ); let rows = r - .block_on(db.query_data("Purchases".to_string(), None, None, None)) + .block_on(db.query_data("Purchases".to_string(), None, None)) .expect("query") .rows; assert_eq!(rows.len(), 2); @@ -1077,7 +1077,7 @@ fn e2e_rename_fk_parent_updates_metadata_and_still_enforces() { ); // The child's outbound relationship now points at the new parent name. - let c = r.block_on(db.describe_table("C".to_string(), None)).expect("describe C"); + let c = r.block_on(db.describe_table("C".to_string())).expect("describe C"); assert_eq!(c.outbound_relationships.len(), 1); assert_eq!(c.outbound_relationships[0].other_table, "Parent"); @@ -1129,7 +1129,7 @@ fn e2e_rename_fk_child_updates_metadata_and_still_enforces() { ); // The parent's inbound relationship now names the renamed child. - let p = r.block_on(db.describe_table("P".to_string(), None)).expect("describe P"); + let p = r.block_on(db.describe_table("P".to_string())).expect("describe P"); assert_eq!(p.inbound_relationships.len(), 1); assert_eq!(p.inbound_relationships[0].other_table, "Child"); @@ -1168,7 +1168,7 @@ fn e2e_rename_self_referential_table_updates_both_ends() { ); // Both ends of the self-reference now name `Tree`. - let t = r.block_on(db.describe_table("Tree".to_string(), None)).expect("describe Tree"); + let t = r.block_on(db.describe_table("Tree".to_string())).expect("describe Tree"); assert_eq!(t.outbound_relationships[0].other_table, "Tree"); assert_eq!(t.inbound_relationships[0].other_table, "Tree"); @@ -1216,7 +1216,7 @@ fn e2e_rename_table_keeps_its_index_with_a_stale_name() { "events: {events:?}" ); - let u = r.block_on(db.describe_table("Users".to_string(), None)).expect("describe Users"); + let u = r.block_on(db.describe_table("Users".to_string())).expect("describe Users"); assert_eq!(u.indexes.len(), 1, "the index followed the rename"); assert_eq!( u.indexes[0].name, "T_email_idx", @@ -1226,7 +1226,7 @@ fn e2e_rename_table_keeps_its_index_with_a_stale_name() { // Survives a fresh rebuild (recreated from IndexSchema on table Users). let db = fresh_rebuild(db, &project, &r); - let u = r.block_on(db.describe_table("Users".to_string(), None)).expect("describe Users"); + let u = r.block_on(db.describe_table("Users".to_string())).expect("describe Users"); assert_eq!(u.indexes.len(), 1); assert_eq!(u.indexes[0].name, "T_email_idx"); } @@ -1255,7 +1255,7 @@ fn e2e_rename_table_is_one_undo_step() { "undo restored the old table name: {tables:?}" ); assert_eq!( - r.block_on(db.query_data("Orders".to_string(), None, None, None)).expect("query").rows.len(), + r.block_on(db.query_data("Orders".to_string(), None, None)).expect("query").rows.len(), 1, "the row is back under the old name" ); @@ -1427,7 +1427,7 @@ fn e2e_alter_column_set_default_applies() { )) .expect("insert omitting qty"); let rows = r - .block_on(db.query_data("T".to_string(), None, None, None)) + .block_on(db.query_data("T".to_string(), None, None)) .expect("query") .rows; assert_eq!( @@ -1473,7 +1473,7 @@ fn e2e_alter_column_drop_default_removes_it() { )) .expect("insert omitting qty"); let rows = r - .block_on(db.query_data("T".to_string(), None, None, None)) + .block_on(db.query_data("T".to_string(), None, None)) .expect("query") .rows; assert_eq!( diff --git a/tests/it/sql_create_index.rs b/tests/it/sql_create_index.rs index 96a1e99..442022d 100644 --- a/tests/it/sql_create_index.rs +++ b/tests/it/sql_create_index.rs @@ -55,7 +55,7 @@ fn insert_row(db: &Database, r: &tokio::runtime::Runtime, id: i64, email: &str) } fn index(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option<(Vec, bool)> { - r.block_on(db.describe_table("T".to_string(), None)) + r.block_on(db.describe_table("T".to_string())) .expect("describe") .indexes .into_iter() diff --git a/tests/it/sql_create_table.rs b/tests/it/sql_create_table.rs index 3ac8564..e2e6474 100644 --- a/tests/it/sql_create_table.rs +++ b/tests/it/sql_create_table.rs @@ -64,7 +64,7 @@ fn created_table_appears_with_playground_types() { assert!(tables.contains(&"Widget".to_string())); let desc = r - .block_on(db.describe_table("Widget".to_string(), None)) + .block_on(db.describe_table("Widget".to_string())) .expect("describe"); let types: Vec<(String, Option)> = desc .columns @@ -98,7 +98,7 @@ fn integer_primary_key_is_plain_int() { )) .expect("create"); let desc = r - .block_on(db.describe_table("T".to_string(), None)) + .block_on(db.describe_table("T".to_string())) .expect("describe"); assert_eq!(desc.columns[0].user_type, Some(Type::Int)); } @@ -137,7 +137,7 @@ fn serial_pk_autoincrements_in_multi_column_table() { } let data = r - .block_on(db.query_data("T".to_string(), None, None, None)) + .block_on(db.query_data("T".to_string(), None, None)) .expect("query"); let id_idx = data .columns @@ -220,7 +220,7 @@ fn table_without_primary_key_is_allowed() { )) .expect("insert into PK-less table"); let data = r - .block_on(db.query_data("Notes".to_string(), None, None, None)) + .block_on(db.query_data("Notes".to_string(), None, None)) .expect("query"); assert_eq!(data.rows.len(), 1); } @@ -299,7 +299,7 @@ fn default_is_applied_when_column_omitted() { )) .expect("insert"); let data = r - .block_on(db.query_data("T".to_string(), None, None, None)) + .block_on(db.query_data("T".to_string(), None, None)) .expect("query"); let n_idx = data.columns.iter().position(|c| c == "n").expect("n column"); assert_eq!(data.rows[0][n_idx].as_deref(), Some("7"), "DEFAULT 7 applied"); @@ -381,7 +381,7 @@ fn check_default_and_composite_unique_survive_rebuild() { // A valid row inserts; DEFAULT n=7 survived. r.block_on(ins("1", "1", "5")).expect("valid row"); let data = r - .block_on(db.query_data("T".to_string(), None, None, None)) + .block_on(db.query_data("T".to_string(), None, None)) .expect("query"); let n_idx = data.columns.iter().position(|c| c == "n").expect("n column"); assert_eq!(data.rows[0][n_idx].as_deref(), Some("7"), "DEFAULT survived rebuild"); @@ -679,7 +679,7 @@ fn sql_create_table_is_one_undo_step() { /// Sorted `id` column values of table `T`. fn ids(db: &Database, r: &tokio::runtime::Runtime) -> Vec> { let d = r - .block_on(db.query_data("T".to_string(), None, None, None)) + .block_on(db.query_data("T".to_string(), None, None)) .expect("query"); let idx = d.columns.iter().position(|c| c == "id").expect("id column"); let mut v: Vec> = d.rows.iter().map(|row| row[idx].clone()).collect(); @@ -801,7 +801,7 @@ fn dropping_a_column_a_table_check_references_fails_cleanly() { // The table is intact: both columns survive (rollback) ... let desc = r - .block_on(db.describe_table("T".to_string(), None)) + .block_on(db.describe_table("T".to_string())) .expect("describe still works"); assert_eq!( desc.columns.iter().map(|c| c.name.clone()).collect::>(), @@ -925,14 +925,14 @@ fn foreign_key_creates_named_relationship_visible_in_describe() { .expect("create child with FK"); // The child has an outbound relationship; the parent an inbound one. - let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe child"); + let child = r.block_on(db.describe_table("child".to_string())).expect("describe child"); assert_eq!(child.outbound_relationships.len(), 1, "child references parent"); let rel = &child.outbound_relationships[0]; assert_eq!(rel.name, "parent_id_to_child_pid", "auto-named per ADR-0013"); assert_eq!(rel.other_table, "parent"); assert_eq!(rel.local_columns, vec!["pid".to_string()]); - let parent = r.block_on(db.describe_table("parent".to_string(), None)).expect("describe parent"); + let parent = r.block_on(db.describe_table("parent".to_string())).expect("describe parent"); assert_eq!(parent.inbound_relationships.len(), 1, "parent is referenced by child"); } @@ -954,7 +954,7 @@ fn explicit_constraint_name_is_used() { Some("create table child (id serial primary key, pid int, constraint child_to_parent foreign key (pid) references parent(id))".to_string()), )) .expect("create child with named FK"); - let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe"); + let child = r.block_on(db.describe_table("child".to_string())).expect("describe"); assert_eq!(child.outbound_relationships[0].name, "child_to_parent"); } @@ -974,7 +974,7 @@ fn bare_references_resolves_to_parent_single_column_pk() { Some("create table child (id serial primary key, pid int references parent)".to_string()), )) .expect("create child with bare REFERENCES"); - let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe"); + let child = r.block_on(db.describe_table("child".to_string())).expect("describe"); assert_eq!(child.outbound_relationships[0].other_columns, vec!["id".to_string()], "resolved to parent PK"); } @@ -1108,7 +1108,7 @@ fn create_table_with_fk_is_one_undo_step() { // parent (now un-referenced) can be described without a dangling rel. r.block_on(db.undo()).expect("undo").expect("a step was undone"); assert!(!r.block_on(db.list_tables()).unwrap().contains(&"child".to_string())); - let parent = r.block_on(db.describe_table("parent".to_string(), None)).expect("describe parent"); + let parent = r.block_on(db.describe_table("parent".to_string())).expect("describe parent"); assert!(parent.inbound_relationships.is_empty(), "the relationship was undone with the table"); } @@ -1152,7 +1152,7 @@ fn foreign_key_on_delete_cascade_takes_effect() { )) .expect("delete parent"); let child_rows = r - .block_on(db.query_data("child".to_string(), None, None, None)) + .block_on(db.query_data("child".to_string(), None, None)) .expect("query child"); assert!(child_rows.rows.is_empty(), "ON DELETE CASCADE removed the child row"); } @@ -1232,7 +1232,7 @@ fn fk_survives_a_rebuild_triggering_column_add() { .expect("add column via rebuild"); // The relationship still exists after the rebuild. - let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe"); + let child = r.block_on(db.describe_table("child".to_string())).expect("describe"); assert_eq!(child.outbound_relationships.len(), 1, "FK survived the column-add rebuild"); // And the engine still enforces it (now and after a fresh rebuild). insert_parent_row(&db, &r); @@ -1275,7 +1275,7 @@ fn fk_referential_actions_survive_rebuild() { )) .expect("create"); r.block_on(db.rebuild_from_text(p.path().to_path_buf(), None)).expect("rebuild"); - let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe"); + let child = r.block_on(db.describe_table("child".to_string())).expect("describe"); let rel = &child.outbound_relationships[0]; assert_eq!(rel.on_delete, ReferentialAction::Cascade, "ON DELETE survived rebuild"); assert_eq!(rel.on_update, ReferentialAction::SetNull, "ON UPDATE survived rebuild"); @@ -1299,7 +1299,7 @@ fn dropping_the_child_clears_the_fk_relationship() { .expect("create"); r.block_on(db.drop_table("child".to_string(), Some("drop table child".to_string()))) .expect("drop child"); - let parent = r.block_on(db.describe_table("parent".to_string(), None)).expect("describe parent"); + let parent = r.block_on(db.describe_table("parent".to_string())).expect("describe parent"); assert!(parent.inbound_relationships.is_empty(), "dropping the child cleared the relationship"); } @@ -1341,7 +1341,7 @@ fn bare_self_reference_resolves_to_own_pk() { Some("create table emp (id int primary key, mgr int references emp)".to_string()), )) .expect("create self-referential emp with a bare reference"); - let emp = r.block_on(db.describe_table("emp".to_string(), None)).expect("describe"); + let emp = r.block_on(db.describe_table("emp".to_string())).expect("describe"); assert_eq!(emp.outbound_relationships[0].other_columns, vec!["id".to_string()], "bare self-ref resolved to own PK"); // Enforced: a non-existent manager is rejected. r.block_on(db.insert( diff --git a/tests/it/sql_delete.rs b/tests/it/sql_delete.rs index b91f88b..8f95463 100644 --- a/tests/it/sql_delete.rs +++ b/tests/it/sql_delete.rs @@ -154,7 +154,7 @@ fn delete_without_where_runs_across_all_rows() { let csv = read_csv(&project, "t").unwrap_or_default(); assert!(!csv.contains('a') && !csv.contains('b') && !csv.contains('c'), "no rows left: {csv:?}"); let remaining = rt - .block_on(db.query_data("t".to_string(), None, None, None)) + .block_on(db.query_data("t".to_string(), None, None)) .expect("query t"); assert!(remaining.rows.is_empty(), "table empty after unfiltered delete"); } @@ -302,8 +302,8 @@ fn cascade_to_two_children_reports_both() { assert_eq!(by_child.get("Orders"), Some(&2), "two orders cascaded"); assert_eq!(by_child.get("Reviews"), Some(&1), "one review cascaded"); // Both child CSVs re-persisted to the post-cascade (empty) state. - let orders = rt.block_on(db.query_data("Orders".to_string(), None, None, None)).unwrap(); - let reviews = rt.block_on(db.query_data("Reviews".to_string(), None, None, None)).unwrap(); + let orders = rt.block_on(db.query_data("Orders".to_string(), None, None)).unwrap(); + let reviews = rt.block_on(db.query_data("Reviews".to_string(), None, None)).unwrap(); assert!(orders.rows.is_empty() && reviews.rows.is_empty(), "both children emptied"); let _ = &project; } @@ -361,7 +361,7 @@ fn delete_violating_fk_fails_and_persists_nothing() { let result = run_delete(&db, &rt, input); assert!(result.is_err(), "delete of a referenced parent must be rejected"); // Rolled back: Alice survives. - let customers = rt.block_on(db.query_data("Customers".to_string(), None, None, None)).unwrap(); + let customers = rt.block_on(db.query_data("Customers".to_string(), None, None)).unwrap(); assert_eq!(customers.rows.len(), 1, "parent row preserved after rejected delete"); // No history line for the failed statement (written only on success). let history = std::fs::read_to_string(project.path().join("history.log")).unwrap_or_default(); diff --git a/tests/it/sql_dml_e2e.rs b/tests/it/sql_dml_e2e.rs index 313d643..d316c08 100644 --- a/tests/it/sql_dml_e2e.rs +++ b/tests/it/sql_dml_e2e.rs @@ -149,7 +149,7 @@ fn seed(db: &Database, rt: &tokio::runtime::Runtime, sql: &str) { } fn query(db: &Database, rt: &tokio::runtime::Runtime, table: &str) -> Vec>> { - rt.block_on(db.query_data(table.to_string(), None, None, None)) + rt.block_on(db.query_data(table.to_string(), None, None)) .unwrap_or_else(|e| panic!("query_data {table}: {e:?}")) .rows } diff --git a/tests/it/sql_drop_index.rs b/tests/it/sql_drop_index.rs index ca5f0d5..6ae5127 100644 --- a/tests/it/sql_drop_index.rs +++ b/tests/it/sql_drop_index.rs @@ -55,7 +55,7 @@ fn make_t_with_index(db: &Database, r: &tokio::runtime::Runtime) -> String { } fn index_names(db: &Database, r: &tokio::runtime::Runtime) -> Vec { - r.block_on(db.describe_table("T".to_string(), None)) + r.block_on(db.describe_table("T".to_string())) .expect("describe") .indexes .into_iter() diff --git a/tests/it/sql_drop_table.rs b/tests/it/sql_drop_table.rs index a95bb0e..5ea6550 100644 --- a/tests/it/sql_drop_table.rs +++ b/tests/it/sql_drop_table.rs @@ -150,7 +150,7 @@ fn drop_table_is_one_undo_step_and_restores_data() { assert!(r.block_on(db.undo()).expect("undo").is_some(), "the drop was one undo step"); assert!(r.block_on(db.list_tables()).unwrap().contains(&"T".to_string())); let data = r - .block_on(db.query_data("T".to_string(), None, None, None)) + .block_on(db.query_data("T".to_string(), None, None)) .expect("query"); assert_eq!(data.rows.len(), 1, "the dropped row was restored by undo"); } diff --git a/tests/it/sql_select.rs b/tests/it/sql_select.rs index 14c5654..c3c31d4 100644 --- a/tests/it/sql_select.rs +++ b/tests/it/sql_select.rs @@ -215,7 +215,7 @@ fn decimal_aggregation_display_trims_ieee754_noise() { // The reported case: the aggregate no longer leaks float noise. let agg = rt - .block_on(db.run_select("select sum(price * qty) from Products".to_string(), None)) + .block_on(db.run_select("select sum(price * qty) from Products".to_string())) .expect("aggregate select"); assert_eq!( agg.rows[0][0].as_deref(), @@ -226,7 +226,7 @@ fn decimal_aggregation_display_trims_ieee754_noise() { // Raw decimal column is still exact — TEXT storage preserves // the input string verbatim, including the trailing zero. let raw = rt - .block_on(db.run_select("select price from Products".to_string(), None)) + .block_on(db.run_select("select price from Products".to_string())) .expect("raw decimal select"); let prices: Vec<&str> = raw.rows.iter().map(|r| r[0].as_deref().unwrap()).collect(); assert_eq!( @@ -240,10 +240,7 @@ fn decimal_aggregation_display_trims_ieee754_noise() { fn database_run_select_constant_returns_a_single_row() { let (_p, db, _dir) = open_project_db(); let data = rt() - .block_on(db.run_select( - "select 1".to_string(), - Some("select 1".to_string()), - )) + .block_on(db.run_select("select 1".to_string())) .expect("`select 1` runs clean"); assert_eq!(data.rows.len(), 1, "one result row"); assert_eq!(data.rows[0].len(), 1, "one column"); @@ -288,7 +285,7 @@ fn database_run_select_from_user_table_returns_inserted_rows() { .expect("insert row"); }); let data = rt - .block_on(db.run_select("select Name from T".to_string(), None)) + .block_on(db.run_select("select Name from T".to_string())) .expect("SELECT runs"); assert_eq!(data.rows.len(), 1); assert_eq!(data.rows[0][0].as_deref(), Some("Ada")); @@ -336,7 +333,7 @@ fn database_run_select_recovers_bool_column_type() { .expect("insert row"); }); let data = rt - .block_on(db.run_select("select Active from Products".to_string(), None)) + .block_on(db.run_select("select Active from Products".to_string())) .expect("SELECT runs"); assert_eq!(data.rows.len(), 2); assert_eq!(data.column_types, vec![Some(Type::Bool)]); @@ -374,7 +371,7 @@ fn database_run_select_recovers_text_type_through_alias() { // playground type is recovered. let data = rt .block_on( - db.run_select("select Name as n from Users".to_string(), None), + db.run_select("select Name as n from Users".to_string()), ) .expect("SELECT runs"); assert_eq!(data.columns, vec!["n".to_string()]); @@ -402,7 +399,7 @@ fn database_run_select_computed_expression_stays_typeless() { .expect("insert"); }); let data = rt - .block_on(db.run_select("select Score + 1 from T".to_string(), None)) + .block_on(db.run_select("select Score + 1 from T".to_string())) .expect("SELECT runs"); assert_eq!(data.column_types, vec![None]); } @@ -439,7 +436,6 @@ fn engine_aggregate_in_where_routes_through_catalog() { let err = rt .block_on(db.run_select( "select id from T where count(score) > 0".to_string(), - None, )) .expect_err("engine should reject aggregate in WHERE"); let DbError::Sqlite { .. } = &err else { @@ -512,7 +508,6 @@ fn engine_group_by_missing_routes_through_catalog() { let _ = rt .block_on(db.run_select( "select category, count(*) from T group by category".to_string(), - None, )) .expect("benign GROUP BY query runs"); // Direct unit test on the matcher: ensure a message that @@ -574,7 +569,6 @@ fn engine_scalar_subquery_too_many_rows_routes_through_catalog() { let _ = rt .block_on(db.run_select( "select (select v from T) from T".to_string(), - None, )) .expect("benign scalar subquery query runs"); let synthetic = DbError::Sqlite { @@ -624,13 +618,13 @@ fn database_run_select_type_recovery_works_on_empty_table() { }); // No INSERT — the table is empty. let data_text = rt - .block_on(db.run_select("select col_text from Empty".to_string(), None)) + .block_on(db.run_select("select col_text from Empty".to_string())) .expect("SELECT runs even on empty table"); assert!(data_text.rows.is_empty()); assert_eq!(data_text.column_types, vec![Some(Type::Text)]); let data_blob = rt - .block_on(db.run_select("select col_blob from Empty".to_string(), None)) + .block_on(db.run_select("select col_blob from Empty".to_string())) .expect("SELECT runs even on empty table"); assert!(data_blob.rows.is_empty()); assert_eq!( @@ -723,7 +717,7 @@ fn database_run_select_recovers_all_ten_playground_types() { for (col, expected_type) in cases { let sql = format!("select {col} from AllTypes"); let data = rt - .block_on(db.run_select(sql.clone(), None)) + .block_on(db.run_select(sql.clone())) .expect("SELECT runs"); assert_eq!( data.column_types, diff --git a/tests/it/sql_update.rs b/tests/it/sql_update.rs index 86c7415..88ef502 100644 --- a/tests/it/sql_update.rs +++ b/tests/it/sql_update.rs @@ -501,7 +501,7 @@ fn update_all_rows_flag_in_advanced_updates_every_row() { "the --all-rows update replays through the DSL fall-back; events: {events:?}" ); let rows = rt - .block_on(db.query_data("t".to_string(), None, None, None)) + .block_on(db.query_data("t".to_string(), None, None)) .expect("query") .rows; assert_eq!(rows.len(), 2, "both rows present"); diff --git a/tests/it/undo_snapshots.rs b/tests/it/undo_snapshots.rs index d78ae8e..c0ce5f2 100644 --- a/tests/it/undo_snapshots.rs +++ b/tests/it/undo_snapshots.rs @@ -63,7 +63,7 @@ async fn insert_named(db: &Database, name: &str) { } async fn row_count(db: &Database) -> usize { - db.query_data("Customers".to_string(), None, None, None) + db.query_data("Customers".to_string(), None, None) .await .unwrap() .rows @@ -306,7 +306,7 @@ async fn sql_delete(db: &Database, input: &str) { } async fn count_t(db: &Database) -> usize { - db.query_data("T".to_string(), None, None, None) + db.query_data("T".to_string(), None, None) .await .unwrap() .rows @@ -378,7 +378,7 @@ fn undo_restores_db_and_csv_consistently() { // Both the database read model and the on-disk CSV are // restored — the (db, csv) pair stays consistent. assert_eq!( - db.query_data("T".to_string(), None, None, None) + db.query_data("T".to_string(), None, None) .await .unwrap() .rows From 2721bd8d043ebe83cf4fd3a608ebf3529f4870a3 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 14 Jun 2026 21:11:28 +0000 Subject: [PATCH 26/50] =?UTF-8?q?ci:=20macOS=20(Tart)=20runner=20probe=20?= =?UTF-8?q?=E2=80=94=20throwaway=20diagnostic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Manual-dispatch probe on runs-on macos:host to confirm the runner picks up jobs and report arch / macOS version / Xcode SDK / toolchains (nix, rustup, cargo) / git+node, before wiring the macOS release leg. Delete once done. --- .gitea/workflows/macos-probe.yaml | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .gitea/workflows/macos-probe.yaml diff --git a/.gitea/workflows/macos-probe.yaml b/.gitea/workflows/macos-probe.yaml new file mode 100644 index 0000000..352d87f --- /dev/null +++ b/.gitea/workflows/macos-probe.yaml @@ -0,0 +1,38 @@ +# THROWAWAY DIAGNOSTIC for the macOS (Tart) runner — delete once the macOS +# release leg is wired. Manual dispatch only: the Mac isn't always on, so this +# runs when you bring it up and trigger it from the Actions UI. +# +# Answers: does the `macos:host` runner pick up jobs, does it run on the host, +# what arch + macOS version, is the Xcode SDK present (needed to link arboard's +# AppKit), and which build toolchain is available (nix? rustup? bare cargo?) — +# plus git/node for actions/checkout. +name: macos-probe +on: [workflow_dispatch] + +jobs: + probe: + runs-on: "macos:host" + steps: + - name: identity, SDK, toolchains + run: | + echo "=== uname ==="; uname -a; echo "arch: $(uname -m)" + echo "=== macOS version ==="; sw_vers 2>&1 || echo "(sw_vers?)" + echo "=== host or container? ===" + if [ -f /.dockerenv ]; then echo "/.dockerenv PRESENT -> container"; else echo "host (no /.dockerenv)"; fi + echo "=== identity ==="; whoami; id + echo "" + echo "=== Xcode CLT / SDK (needed to link AppKit) ===" + xcode-select -p 2>&1 || echo "(no CLT)" + xcrun --show-sdk-path 2>&1 || echo "(no sdk)" + clang --version 2>&1 | head -1 || echo "(no clang)" + echo "" + echo "=== build toolchains on PATH ===" + echo "nix: $(command -v nix || echo NO)" + echo "rustup: $(command -v rustup || echo NO)" + echo "cargo: $(command -v cargo || echo NO)" + echo "rustc: $(command -v rustc || echo NO)" + rustc --version 2>/dev/null || true + echo "" + echo "=== git / node (for actions/checkout) ===" + echo "git: $(command -v git || echo NO) $(git --version 2>/dev/null)" + echo "node: $(command -v node || echo NO) $(node --version 2>/dev/null)" From 52815f1a76fd054e4541c94266303e3c22de53d9 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 14 Jun 2026 21:18:25 +0000 Subject: [PATCH 27/50] ci: trigger macos-probe on push (workflow_dispatch needs default branch) Gitea only exposes workflow_dispatch for workflows on the default branch (main); our CI is on `ci`, so the manual-run button/API isn't available. Add a push trigger (filtered to the probe file) so we can drive the macOS runner test from the ci branch. workflow_dispatch kept for post-merge. --- .gitea/workflows/macos-probe.yaml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/macos-probe.yaml b/.gitea/workflows/macos-probe.yaml index 352d87f..93cc630 100644 --- a/.gitea/workflows/macos-probe.yaml +++ b/.gitea/workflows/macos-probe.yaml @@ -1,13 +1,21 @@ # THROWAWAY DIAGNOSTIC for the macOS (Tart) runner — delete once the macOS -# release leg is wired. Manual dispatch only: the Mac isn't always on, so this -# runs when you bring it up and trigger it from the Actions UI. +# release leg is wired. +# +# Trigger: runs on a push that touches THIS file. workflow_dispatch is kept too, +# but Gitea only exposes manual dispatch for workflows on the DEFAULT branch +# (main) — and all our CI is still on `ci` — so push is how we drive it for now. +# Bring the Mac up before pushing a change here so the run isn't left queued. # # Answers: does the `macos:host` runner pick up jobs, does it run on the host, # what arch + macOS version, is the Xcode SDK present (needed to link arboard's # AppKit), and which build toolchain is available (nix? rustup? bare cargo?) — # plus git/node for actions/checkout. name: macos-probe -on: [workflow_dispatch] +on: + push: + paths: + - '.gitea/workflows/macos-probe.yaml' + workflow_dispatch: jobs: probe: From 0878c6df19b83ce5a5cae93e49ea6e2646433c6f Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 14 Jun 2026 21:21:13 +0000 Subject: [PATCH 28/50] ci: target the macOS runner by label name `macos` (not `macos:host`) In act_runner a label is `:`; `:host` is the execution- backend schema (run on host, no container), not part of the label. The runner registered as `macos:host` therefore has the label `macos`, which is what runs-on must reference. --- .gitea/workflows/macos-probe.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/macos-probe.yaml b/.gitea/workflows/macos-probe.yaml index 93cc630..a367f35 100644 --- a/.gitea/workflows/macos-probe.yaml +++ b/.gitea/workflows/macos-probe.yaml @@ -19,7 +19,10 @@ on: jobs: probe: - runs-on: "macos:host" + # Label NAME only. The runner was registered as `macos:host`, but `:host` is + # act_runner's execution-backend schema (run on the host, no container), not + # part of the label — so the label is just `macos`. + runs-on: macos steps: - name: identity, SDK, toolchains run: | From d5fb47bcc85b98c887b32af912c5de763d68d1e8 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 14 Jun 2026 21:28:41 +0000 Subject: [PATCH 29/50] ci: macOS build smoke-test + flake darwin support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the two *-apple-darwin targets to rust-toolchain.toml and apple-sdk + libiconv to the flake devShell (darwin only) so the nix toolchain links AppKit; make cargo-zigbuild/zig Linux-only (macOS builds natively). Repoint the throwaway macOS workflow to actually build both darwin targets through the flake on the Tart runner — the first real check of the macOS leg, which can't be verified locally. Delete once release-macos lands. --- .gitea/workflows/macos-probe.yaml | 66 +++++++++++++------------------ flake.nix | 23 +++++++---- rust-toolchain.toml | 5 +++ 3 files changed, 48 insertions(+), 46 deletions(-) diff --git a/.gitea/workflows/macos-probe.yaml b/.gitea/workflows/macos-probe.yaml index a367f35..58d31bd 100644 --- a/.gitea/workflows/macos-probe.yaml +++ b/.gitea/workflows/macos-probe.yaml @@ -1,49 +1,39 @@ -# THROWAWAY DIAGNOSTIC for the macOS (Tart) runner — delete once the macOS -# release leg is wired. +# THROWAWAY build smoke-test for the macOS (Tart) runner. Verifies both +# *-apple-darwin targets actually compile and link (incl. arboard's AppKit) +# through the flake on the real Mac, before the full release-macos workflow is +# wired. Delete once that lands. # -# Trigger: runs on a push that touches THIS file. workflow_dispatch is kept too, -# but Gitea only exposes manual dispatch for workflows on the DEFAULT branch -# (main) — and all our CI is still on `ci` — so push is how we drive it for now. -# Bring the Mac up before pushing a change here so the run isn't left queued. -# -# Answers: does the `macos:host` runner pick up jobs, does it run on the host, -# what arch + macOS version, is the Xcode SDK present (needed to link arboard's -# AppKit), and which build toolchain is available (nix? rustup? bare cargo?) — -# plus git/node for actions/checkout. -name: macos-probe +# Push-triggered (workflow_dispatch only works for workflows on the default +# branch; our CI is on `ci`). Runs when the flake/toolchain or this file change. +# Bring the Mac up before pushing so the run isn't left queued. +name: macos-build-test on: push: paths: - '.gitea/workflows/macos-probe.yaml' + - 'flake.nix' + - 'rust-toolchain.toml' workflow_dispatch: jobs: - probe: - # Label NAME only. The runner was registered as `macos:host`, but `:host` is - # act_runner's execution-backend schema (run on the host, no container), not - # part of the label — so the label is just `macos`. + build: + # Label NAME only — `:host` in the runner registration is the execution + # backend (run on host), not part of the label. runs-on: macos + env: + # Guarantee flakes regardless of the Mac's nix config. + NIX_CONFIG: "experimental-features = nix-command flakes" steps: - - name: identity, SDK, toolchains + - uses: actions/checkout@v4 + - name: build both darwin targets through the flake run: | - echo "=== uname ==="; uname -a; echo "arch: $(uname -m)" - echo "=== macOS version ==="; sw_vers 2>&1 || echo "(sw_vers?)" - echo "=== host or container? ===" - if [ -f /.dockerenv ]; then echo "/.dockerenv PRESENT -> container"; else echo "host (no /.dockerenv)"; fi - echo "=== identity ==="; whoami; id - echo "" - echo "=== Xcode CLT / SDK (needed to link AppKit) ===" - xcode-select -p 2>&1 || echo "(no CLT)" - xcrun --show-sdk-path 2>&1 || echo "(no sdk)" - clang --version 2>&1 | head -1 || echo "(no clang)" - echo "" - echo "=== build toolchains on PATH ===" - echo "nix: $(command -v nix || echo NO)" - echo "rustup: $(command -v rustup || echo NO)" - echo "cargo: $(command -v cargo || echo NO)" - echo "rustc: $(command -v rustc || echo NO)" - rustc --version 2>/dev/null || true - echo "" - echo "=== git / node (for actions/checkout) ===" - echo "git: $(command -v git || echo NO) $(git --version 2>/dev/null)" - echo "node: $(command -v node || echo NO) $(node --version 2>/dev/null)" + set -e + for t in aarch64-apple-darwin x86_64-apple-darwin; do + echo "==================== $t ====================" + nix develop -c cargo build --release --target "$t" + f="target/$t/release/rdbms-playground" + file "$f" + echo "--- linked libs (otool -L) ---" + otool -L "$f" 2>/dev/null | head -8 || true + done + echo "=== both darwin targets built ===" diff --git a/flake.nix b/flake.nix index 6407308..c0b5bb6 100644 --- a/flake.nix +++ b/flake.nix @@ -60,7 +60,15 @@ packages.rdbms-playground = rdbms-playground; devShells.default = pkgs.mkShell { - inherit buildInputs; + buildInputs = buildInputs ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ + # macOS release builds (aarch64/x86_64-apple-darwin) link AppKit + # (arboard) + libSystem; the nix toolchain's own clang resolves the + # frameworks via the Apple SDK provided here. (The Mac runner also + # has full Xcode, but the devShell stays self-contained.) `libiconv` + # is linked by several crates on darwin. + pkgs.apple-sdk + pkgs.libiconv + ]; nativeBuildInputs = nativeBuildInputs ++ [ rust # Dev-disk maintenance: cargo never garbage-collects stale per-hash @@ -68,12 +76,12 @@ # CLAUDE.md "Build hygiene"). cargo-sweep prunes them; run it # periodically between milestones. pkgs.cargo-sweep - # Cross-compilation for the D1 release matrix. `cargo zigbuild` uses - # Zig's bundled clang + libc as one universal cross cc/linker for - # every non-macOS target (Linux musl x64/arm64, Windows gnu/gnullvm - # x64/arm64) — including the `cc`-crate compile of rusqlite's bundled - # SQLite C — with no per-target toolchain or SDK. It auto-discovers - # `zig` on PATH, so no extra env is needed. + ] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ + # Cross-compilation for the non-macOS D1 targets: `cargo zigbuild` + # uses Zig's bundled clang + libc as one universal cross cc/linker + # (incl. the `cc`-crate compile of rusqlite's bundled SQLite C) for + # Linux musl + Windows gnu/gnullvm. macOS builds natively with the + # Apple toolchain on the Mac runner, so these are Linux-only. pkgs.cargo-zigbuild pkgs.zig ]; @@ -82,7 +90,6 @@ echo "RDBMS Playground dev shell ($(uname -s))" echo " rust: $(rustc --version | cut -d' ' -f1-2)" echo " cargo: $(cargo --version | cut -d' ' -f1-2)" - echo " zig: $(zig version 2>/dev/null || echo '?') (cargo-zigbuild cross targets)" ''; }; }); diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 64a9490..e3d51b3 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -18,4 +18,9 @@ targets = [ "aarch64-unknown-linux-musl", "x86_64-pc-windows-gnu", "aarch64-pc-windows-gnullvm", + # macOS — built natively on the Apple-Silicon Mac runner (aarch64 native, + # x86_64 cross). These need Apple's SDK to link, which a Linux runner can't + # supply, so they are produced only on the Mac (see docs/ci/adr ADR-ci-003). + "aarch64-apple-darwin", + "x86_64-apple-darwin", ] From 4d004f5847a76e32e442790687985d7860b364ac Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 14 Jun 2026 21:36:08 +0000 Subject: [PATCH 30/50] ci: drop nix libiconv from darwin build (portable macOS binary) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The smoke-test caught the aarch64 binary linking a /nix/store libiconv.dylib — non-portable (won't exist on a user's Mac). The Apple SDK already provides a system libiconv stub, so removing pkgs.libiconv makes the linker resolve -liconv to /usr/lib instead. The smoke-test now fails if any /nix/store dylib is linked. --- .gitea/workflows/macos-probe.yaml | 10 ++++++++-- flake.nix | 11 ++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/macos-probe.yaml b/.gitea/workflows/macos-probe.yaml index 58d31bd..221b922 100644 --- a/.gitea/workflows/macos-probe.yaml +++ b/.gitea/workflows/macos-probe.yaml @@ -34,6 +34,12 @@ jobs: f="target/$t/release/rdbms-playground" file "$f" echo "--- linked libs (otool -L) ---" - otool -L "$f" 2>/dev/null | head -8 || true + otool -L "$f" + # Portability guard: a distributable macOS binary must link only + # system libs (/usr/lib, /System/Library) — never a /nix/store path. + if otool -L "$f" | grep -q /nix/store; then + echo "ERROR: $t binary links a /nix/store dylib — not portable"; exit 1 + fi + echo "OK: $t links only system libraries" done - echo "=== both darwin targets built ===" + echo "=== both darwin targets built + portable ===" diff --git a/flake.nix b/flake.nix index c0b5bb6..741fa2b 100644 --- a/flake.nix +++ b/flake.nix @@ -62,12 +62,13 @@ devShells.default = pkgs.mkShell { buildInputs = buildInputs ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ # macOS release builds (aarch64/x86_64-apple-darwin) link AppKit - # (arboard) + libSystem; the nix toolchain's own clang resolves the - # frameworks via the Apple SDK provided here. (The Mac runner also - # has full Xcode, but the devShell stays self-contained.) `libiconv` - # is linked by several crates on darwin. + # (arboard) + libSystem; the Apple SDK provides those framework/ + # system-lib stubs as *system* paths (/usr/lib, /System/Library), so + # the resulting binary is portable. NOTE: do NOT add `pkgs.libiconv` + # — it makes the linker prefer the nix-store libiconv.dylib, baking a + # /nix/store path into the binary (non-portable). The SDK's own + # libiconv stub resolves `-liconv` to /usr/lib/libiconv instead. pkgs.apple-sdk - pkgs.libiconv ]; nativeBuildInputs = nativeBuildInputs ++ [ rust From 9a126782f17abdd4b34b4f1d1357212af3eb56fa Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 14 Jun 2026 21:43:01 +0000 Subject: [PATCH 31/50] ci: de-nix macOS binary libiconv via install_name_tool + re-sign libiconv is the only /nix/store dep the darwin stdenv bakes in (everything else is system frameworks + libSystem/libobjc). The smoke-test now rewrites that load path to /usr/lib/libiconv.2.dylib (ABI-compatible, present on every Mac), re-signs ad-hoc (install_name_tool breaks the sig; arm64 requires a valid one), then verifies no /nix/store paths remain, the signature is valid, and the native binary launches. Flake comment updated to reflect the propagated-libiconv reality. --- .gitea/workflows/macos-probe.yaml | 33 ++++++++++++++++++++++--------- flake.nix | 11 ++++++----- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/.gitea/workflows/macos-probe.yaml b/.gitea/workflows/macos-probe.yaml index 221b922..d338026 100644 --- a/.gitea/workflows/macos-probe.yaml +++ b/.gitea/workflows/macos-probe.yaml @@ -25,21 +25,36 @@ jobs: NIX_CONFIG: "experimental-features = nix-command flakes" steps: - uses: actions/checkout@v4 - - name: build both darwin targets through the flake + - name: build, de-nix, sign, verify both darwin targets run: | set -e for t in aarch64-apple-darwin x86_64-apple-darwin; do echo "==================== $t ====================" nix develop -c cargo build --release --target "$t" f="target/$t/release/rdbms-playground" - file "$f" - echo "--- linked libs (otool -L) ---" - otool -L "$f" - # Portability guard: a distributable macOS binary must link only - # system libs (/usr/lib, /System/Library) — never a /nix/store path. + + # The darwin stdenv bakes a /nix/store libiconv load path into the + # binary. Rewrite it to the system libiconv (every Mac has it, ABI- + # compatible), then re-sign ad-hoc — install_name_tool invalidates + # the signature and arm64 won't run an unsigned/broken-sig binary. + for l in $(otool -L "$f" | awk '/\/nix\/store.*libiconv.*dylib/ {print $1}'); do + echo "rewrite $l -> /usr/lib/libiconv.2.dylib" + install_name_tool -change "$l" /usr/lib/libiconv.2.dylib "$f" + done + codesign --force --sign - "$f" + + echo "--- linked libs ---"; otool -L "$f" if otool -L "$f" | grep -q /nix/store; then - echo "ERROR: $t binary links a /nix/store dylib — not portable"; exit 1 + echo "ERROR: $t still links a /nix/store dylib"; exit 1 fi - echo "OK: $t links only system libraries" + codesign --verify --verbose=2 "$f" && echo "signature OK" + + # Smoke-run the natively-runnable target (this VM is arm64). + if [ "$t" = "aarch64-apple-darwin" ]; then + echo "--- run --help ---"; "$f" --help | head -1 + else + echo "(skip run: $t needs Rosetta)" + fi + echo "OK: $t portable" done - echo "=== both darwin targets built + portable ===" + echo "=== both darwin targets built, de-nixed, signed, verified ===" diff --git a/flake.nix b/flake.nix index 741fa2b..cf5b968 100644 --- a/flake.nix +++ b/flake.nix @@ -63,11 +63,12 @@ buildInputs = buildInputs ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [ # macOS release builds (aarch64/x86_64-apple-darwin) link AppKit # (arboard) + libSystem; the Apple SDK provides those framework/ - # system-lib stubs as *system* paths (/usr/lib, /System/Library), so - # the resulting binary is portable. NOTE: do NOT add `pkgs.libiconv` - # — it makes the linker prefer the nix-store libiconv.dylib, baking a - # /nix/store path into the binary (non-portable). The SDK's own - # libiconv stub resolves `-liconv` to /usr/lib/libiconv instead. + # system-lib stubs as *system* paths (/usr/lib, /System/Library). + # NOTE: the darwin stdenv still propagates a *nix-store* libiconv and + # links it regardless of inputs, so the release workflow rewrites that + # one load path to /usr/lib/libiconv.2.dylib (install_name_tool) and + # re-signs — see release-macos / the macOS smoke-test. Adding + # `pkgs.libiconv` here would only reinforce the wrong path, so don't. pkgs.apple-sdk ]; nativeBuildInputs = nativeBuildInputs ++ [ From 60dbb903cc87f90c42cab9db7b8724f373606c35 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 14 Jun 2026 22:07:48 +0000 Subject: [PATCH 32/50] =?UTF-8?q?ci:=20macOS=20smoke-test=20=E2=80=94=20ru?= =?UTF-8?q?n=20tests=20+=20nix-store=20generation=20pruning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `cargo test` before the darwin builds (gate is Linux-only; the macOS leg is test-then-build) — a full dry-run of release-macos bar the upload. - Add an `if: always()` prune step. The runner wipes the workspace each run, so cargo target/ never accumulates (no sweep). The persistent cache is the nix store: record the current toolchain in a persistent profile, keep the 2 newest generations (nix-env --delete-generations +2), reclaim the rest (nix-collect-garbage). Pairs with min-free/max-free in the runner nix.conf. --- .gitea/workflows/macos-probe.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.gitea/workflows/macos-probe.yaml b/.gitea/workflows/macos-probe.yaml index d338026..565625a 100644 --- a/.gitea/workflows/macos-probe.yaml +++ b/.gitea/workflows/macos-probe.yaml @@ -25,6 +25,8 @@ jobs: NIX_CONFIG: "experimental-features = nix-command flakes" steps: - uses: actions/checkout@v4 + - name: test (macOS — the gate only covers Linux) + run: nix develop -c cargo test --no-fail-fast - name: build, de-nix, sign, verify both darwin targets run: | set -e @@ -58,3 +60,22 @@ jobs: echo "OK: $t portable" done echo "=== both darwin targets built, de-nixed, signed, verified ===" + + - name: prune nix store — keep the last 2 toolchain generations + # The runner wipes the whole workspace before each run, so cargo target/ + # never accumulates (no sweep needed). The persistent caches are the nix + # store (/nix) and ~/.cargo (in $HOME). Bound the nix store by generation: + # record the current devShell closure as a generation of a persistent + # profile (lives in $HOME, survives the workspace wipe), keep the 2 newest + # (current + previous), reclaim what the older ones referenced. No time + # window — never more than two toolchains regardless of flake.lock churn. + if: always() + run: | + echo "--- disk before ---"; df -h / | tail -1 + P="$HOME/.cache/rdbms-ci/toolchain" + nix develop --profile "$P" -c true || true + nix-env -p "$P" --delete-generations +2 || true + nix-collect-garbage || true + echo "--- disk after ---"; df -h / | tail -1 + # ~/.cargo/registry also persists but grows only on Cargo.lock bumps; + # bound it later with `cargo-cache --autoclean` if it ever matters. From e16ad50aa7efee572dc9632da2894c28ac8ee6be Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 14 Jun 2026 22:14:11 +0000 Subject: [PATCH 33/50] =?UTF-8?q?docs(adr):=20ADR-0053=20=E2=80=94=20conte?= =?UTF-8?q?xtual=20`hint`=20command=20+=20F1=20keybinding=20(H2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settles the `hint` slot ADR-0003 left pending; closes the last open piece of A1. Two surfaces (F1 → live-input hint; `hint` command → last-error expansion), no topic arg, and a new tier-3 teaching corpus keyed on a new CommandNode `hint_id` so advanced-SQL forms get distinct mode-correct content. Comprehensive content for v1, authored exemplars-first. Refines ADR-0003; references ADR-0019/0021/0022/0049/ 0051. Files #36 for the parallel help-side gap. --- ...-contextual-hint-command-and-keybinding.md | 372 ++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 373 insertions(+) create mode 100644 docs/adr/0053-contextual-hint-command-and-keybinding.md diff --git a/docs/adr/0053-contextual-hint-command-and-keybinding.md b/docs/adr/0053-contextual-hint-command-and-keybinding.md new file mode 100644 index 0000000..34a9197 --- /dev/null +++ b/docs/adr/0053-contextual-hint-command-and-keybinding.md @@ -0,0 +1,372 @@ +# ADR-0053: Contextual `hint` — F1 live-input keybinding + `hint` command, with a tier-3 teaching corpus (H2) + +## Status + +Accepted — implementation pending. Revised after a `/runda` review +(2026-06-14): corrected the verbosity-default fact; re-keyed tier-3 +content on a new `hint_id` (not `help_id`) so every command form — simple +and advanced-SQL — gets distinct, mode-correct content; split the +pre-submit-diagnostic and runtime-error paths; added a comprehensiveness +coverage test. The parallel question of whether the in-app `help` command +should likewise distinguish advanced-SQL forms is tracked **separately** +as Gitea issue #36 (it touches shipped, ADR-backed `help` behaviour). + +Decided in conversation 2026-06-14. Closes the last open piece of **A1** +(the canonical app-command set, ADR-0003): every app command is +implemented except `hint`, which ADR-0003's command table listed as +*"Request a hint for the current input (ADR pending)."* This ADR is that +pending decision. Tracked as **H2** in `docs/requirements.md`. + +References ADR-0003 (app-command set + the `:` escape), ADR-0019 (the +friendly error layer / H1), ADR-0021 (per-command usage templates / H1a), +ADR-0022 (ambient typing assistance — colour + hint panel + completion), +ADR-0027 (input validity indicator), ADR-0046 (sidebar navigation + +responsive input hint), ADR-0049 (input-field readline keymap), and +ADR-0051 (context/state-aware keybinding strip). + +## Context + +`hint` is the only unbuilt app command. The naive reading — "show a hint" — +hides a real subtlety, and a real cost. + +**The subtlety: a submitted `hint` command cannot see live input.** App +commands are submitted with Enter, which empties the input buffer. By the +time `hint` dispatches, the partial command it was meant to help with is +gone. So "a hint for the current input" cannot be served by a submitted +command alone — it needs a *keybinding* that acts on the live buffer +without submitting. ADR-0003 said "current input"; `requirements.md` +broadened it to "current input **or the most recent error**." Both are +wanted; they map to two different trigger surfaces. + +**The cost: the value of `hint` is content, not plumbing.** The app +already carries two tiers of contextual text: + +- **Tier 1** — terse, always-on: syntax colour (ADR-0022); the error + *headline* alone (ADR-0019, when `messages_verbosity: Short`). +- **Tier 2** — short contextual lines: the ambient typing prose / + `expected` set, shown live while typing (ADR-0022, catalogue + `hint.ambient_*` / `hint.value_slot_*`); and the error `hint:` field — + which, because `Verbosity::Verbose` is the **default** + (`src/friendly/translate.rs:46`), is shown **by default** beneath every + error headline (`messages short` is the opt-*out*, not `messages + verbose` the opt-in). + +So the verbose error hint is **already on screen by default**. If `hint` +merely re-showed it, it would duplicate what the user can already see (and +the ambient panel). To justify itself, `hint` must add a **tier 3**: a +genuinely deeper, *teaching*-grade explanation — what the command/error +means, a worked example, and the underlying relational concept. That +corpus does not exist yet, and +authoring it (to the standard of a teaching tool, where "pedagogy wins +ties") is the bulk of the work. + +The mechanism is small and reuses everything already present: the command +REGISTRY (`src/dsl/grammar/mod.rs`), the `AppCommand` enum +(`src/dsl/command.rs`), key dispatch (`App::handle_key`, +`src/app.rs:1155`), the `note_help`/`note_help_topic` renderers +(`src/app.rs:2982`/`3021`), the parser/walker expected-set +(`ParseError.expected`, `WalkResult.tail_expected`), the friendly +catalogue + `t!` macro + `keys.rs` validation, and the output styling +vocabulary (`OutputStyleClass::Hint`). + +## Decision + +### D1 — Two surfaces, no topic argument + +`hint` is delivered through **two complementary surfaces**: + +1. **F1 keybinding → live input.** Pressing **F1** while typing renders a + tier-3 hint for the command currently in the buffer, into the output + panel, **without submitting or altering the buffer**. This is the + primary, most-valuable path (it serves the literal "current input"). +2. **`hint` command → most recent error.** Submitting `hint` renders the + tier-3 expansion of the most recent error. This is why the command + exists despite the empty-buffer problem: the thing it helps with is + the *last thing you tried*, not the now-empty buffer. + +`hint` takes **no topic argument**. Explicit per-command reference is +already `help ` (H3); `hint` is purely *contextual*, which keeps +the two cleanly distinct (`hint` = "help me with what I'm doing right +now"; `help insert` = "show me the insert reference"). + +F1 is a **read-only overlay**: it never alters the input buffer, the +cursor, or the live completion memo (ADR-0022) — it only emits a block +into the output journal. (It must therefore be handled in `handle_key` +*before* the "any other key clears the memo" fall-through.) + +### D2 — Trigger matrix + +| Trigger | Buffer / state | Result | +|---|---|---| +| **F1** | non-empty input | tier-3 hint for the command being typed, plus the live "expected next" (from the walker's `tail_expected` / parser `expected`) | +| **F1** | empty input, a recent error exists | tier-3 expansion of that error | +| **F1** | empty input, no recent error | a short "getting started" pointer (press F1 while typing a command; `help` for the full list) | +| **`hint`** (submitted) | a recent error exists | tier-3 expansion of that error (primary use) | +| **`hint`** (submitted) | no recent error | the same "getting started" pointer | + +F1 is inert behind a modal and while a sidebar panel holds navigation +focus (consistent with the existing `handle_key` gates, ADR-0046); it is +active in the input context in both Simple and Advanced mode. + +**Two error sources, one namespace.** Errors come in two kinds and reach +`hint` by different routes: + +- **Pre-submit diagnostics** (the ~33 `diagnostic.*` classes — arity, + type, unknown table/column) are computed *while typing* by the walker. + The **F1 live-input path** reads the current under-cursor diagnostic + directly from the walker (the same source the ambient panel uses) and + renders its `hint.err.` block — no stored state needed. +- **Runtime errors** (the 9 `translate_error` classes) occur *after* + submit. The **`hint` command / empty-input F1** path reads them via the + stored `last_error_hint_key` (D5). + +Both render from the same `hint.err.*` namespace. **`:`-prefix handling:** +on the simple-mode one-shot escape (`: SELECT …`), command +identification for the F1 path strips the leading `:` first, so the +advanced form is matched. + +### D3 — The tier-3 content model + +Tier-3 blocks live in the friendly catalogue under the existing `hint:` +top-level namespace (where tier-2 ambient strings already live), in two +new sub-namespaces: + +- **`hint.cmd.`** — one per command **form**, keyed by a **new + `hint_id: Option<&'static str>`** field added to `CommandNode` + (`src/dsl/grammar/mod.rs:512`, parallel to the existing `help_id` / + `usage_ids`). The F1 live-input path resolves the current input to its + command node and looks up `hint.cmd.`. + + **Why a new field, not `help_id`:** `help_id` is **not** 1:1 with + command forms. The 7 advanced-mode SQL nodes (`SELECT`, `WITH`, + `SQL_INSERT/UPDATE/DELETE`, `EXPLAIN_SQL`) carry `help_id: None` *purely + to dedup the `help` command's printed list* (they share an entry word + with a simple sibling — see `grammar/mod.rs:915-918`), not because they + lack distinct content. Their SQL syntax differs from the simple-DSL + sibling's, so they **must get their own tier-3 block**. A dedicated + `hint_id` gives every one of the ~37 REGISTRY nodes — simple and + advanced-SQL alike — its own key and its own mode-correct example, with + no sharing or deferral. (The analogous gap in the `help` command is out + of scope here — issue #36.) +- **`hint.err.`** — one per error/diagnostic class, keyed by the + friendly error/diagnostic key (e.g. `hint.err.foreign_key.child_side`, + `hint.err.type_mismatch`, `hint.err.insert_arity_mismatch`). Used by + both error routes (D2). + +Each tier-3 block is a **structured entry with three labelled parts**, so +the voice stays consistent and the renderer can style them uniformly: + +```yaml +hint.cmd.dsl.insert: + what: "Add one or more rows to a table." + example: "insert into Customers values ('Ann', 'ann@x.io')" + concept: "A row is one record; each value lines up with a column, in + order. Columns typed `serial`/`shortid` fill themselves — leave them out." +``` + +- **`what`** — one or two plain sentences: what this command does / what + this error means. +- **`example`** — a single concrete, copyable line (rendered neutral, not + muted, so it stands out as runnable). +- **`concept`** — the underlying relational idea, in teaching voice; the + part that makes this tier-3 rather than tier-2. + +`concept` is optional where there is genuinely no concept beyond the +mechanics (e.g. `quit`); `what` + `example` are always present. + +### D4 — Rendering + +Both surfaces render through one new renderer, `App::note_hint*` (sibling +of `note_help`/`note_help_topic`, `src/app.rs`), emitting a small framed +block into the `output` buffer as `OutputKind::System` with +`OutputStyleClass::Hint` on the `what`/`concept` prose and `Neutral` on +the `example` line. The block is **persistent** (scrolls in the journal), +unlike the transient ambient panel — pressing F1 is an explicit request +to *keep* the deeper guidance on screen. The bottom keybinding strip +(ADR-0051) advertises F1 in the editing/typing state. + +### D5 — "Most recent (runtime) error" state + +The **runtime-error route** (submitted `hint`, and empty-input F1) needs +to map the last runtime error back to its `hint.err.` key. Runtime +errors today live only as rendered text in the `output` buffer. We add a +single small piece of `App` state — **`last_error_hint_key: +Option`** — set at the `translate_error` call sites +(`runtime.rs:2615`, `app.rs:2424`) when a friendly error is rendered, +cleared when a later command succeeds. Absent → the "getting started" +pointer. + +The **pre-submit-diagnostic route** (the F1 live-input path) needs no +stored state: it reads the current diagnostic from the walker at F1 time +(D2). This is the cleaner split the `/runda` pass surfaced — typing-time +diagnostics and post-submit runtime errors are genuinely different +sources and should not be funnelled through one stored key. + +### D6 — Content scope: comprehensive for v1 + +v1 ships tier-3 content for the **whole inventory**, not a subset (the +graceful tier-2 fallback below is a safety net, not the plan): + +- **~37 command forms** — every distinct node in `REGISTRY` gets its own + `hint.cmd.` block (app + DSL + DDL + advanced-mode SQL forms), + each with a **mode-correct example** (the advanced-SQL forms show SQL + syntax, their simple siblings show DSL — no sharing). +- **9 runtime error classes** — `unique`, `foreign_key` (×4 sides), + `not_null`, `check`, `type_mismatch`, `not_found`, `already_exists`, + `generic`, `invalid_value` — each gets a `hint.err.*` block. +- **~33 `diagnostic.*` pre-submit classes** — arity, type, unknown + table/column, etc. — each gets a `hint.err.*` block. + +The full enumerated checklist is the implementation plan's tracking +artifact (see *Content inventory*, below). + +**Fallback (safety net):** if a tier-3 key is ever missing at runtime, +the surface degrades to tier 2 — the ambient prose for the command path, +or the verbose error `hint:` for the error path — never to a blank or an +error. The `keys.rs` build-time validation keeps the corpus honest, so a +missing key is caught in tests, not in front of a student. + +### D7 — Authoring process: exemplars-first + +Because the corpus is large and its *voice* is a pedagogical decision the +maintainer owns, content is produced in two stages: + +1. This ADR carries **2–3 worked exemplars** (below) as the canonical + style reference. The `/runda` review of this ADR is where the voice and + depth are approved. +2. Once approved, the remaining blocks are authored to that template in + **reviewable batches** (grouped by area: DDL, DML, app commands, + error classes), not one monolithic drop. + +### Exemplars (the style reference to approve) + +**Command (F1 live-input), `insert`:** + +``` +Hint — insert + What: Add one or more rows to a table. + Example: insert into Customers values ('Ann', 'ann@x.io') + Concept: A row is one record; each value lines up with a column, in + order. Columns typed serial/shortid fill themselves — leave + them out. + Next: a value list `(...)`, or `(col, ...) values (...)` to name columns +``` +(The "Next:" line is the live expected-set from the walker, shown only on +the non-empty-input F1 path.) + +**Error (`hint` command), foreign-key child-side violation:** + +``` +Hint — no parent row to point at + What: The value you inserted into Orders.customer_id doesn't match + any Customers row, so the foreign key has nothing to point at. + Example: First insert into Customers values ('Ann', ...) + Then insert into Orders values (..., 'Ann') + Concept: A foreign key is a promise that every child points at a real + parent. The parent must exist first. To allow orphans on + delete instead, set the relationship's `on delete` to + `set null` or `cascade`. +``` + +**Command (F1 live-input), `add 1:n relationship`:** + +``` +Hint — add relationship + What: Link two tables so a parent row can own many child rows. + Example: add 1:n relationship from Customers.id to Orders.customer_id + Concept: The "1:n" means one parent, many children. The child column + holds the foreign key; `--create-fk` adds it for you if it + doesn't exist yet. +``` + +## Forks (all user-chosen, 2026-06-14) + +- **Trigger model:** both a keybinding (live input) and a submitted + command (last error), rather than command-only or keybinding-only — the + live-input path is the most useful, but the command completes the A1 + slot and serves the error case. +- **Keybinding = F1:** the universal help convention; the key is + genuinely free (no `KeyCode::F(1)` binding exists today — the `"F1"` + strings in `input_render.rs`/tests are scenario labels, not the key, and + ADR-0022 uses no `F1` requirement label). No collision with the ADR-0049 + readline keys, `Ctrl-O` (ADR-0046), `Esc`-clear, or the reserved + `Ctrl-C` cancel (I5). Rejected: `?` (a typeable character — fiddly + position-dependent handling) and a Ctrl/Alt chord (less discoverable, no + advantage). +- **No topic argument:** contextual only; `help ` already owns + explicit reference lookup. +- **Comprehensive content for v1:** the full inventory, not a starter + subset. +- **Exemplars-first authoring:** lock the voice on a few blocks, then + mass-author to template. + +## Consequences + +- **A1 closes.** With `hint` registered and built, all 15 canonical + app-level commands exist in both modes. +- **A third contextual tier exists.** Students get on-demand, teaching- + grade guidance that is deeper than the always-on colour, the headline, + the ambient one-liner, and the verbose error hint — without cluttering + those terse defaults. +- **One new keybinding (F1)** joins the keymap and the ADR-0051 strip. +- **A new `hint_id` field on `CommandNode`** (parallel to `help_id`), one + new field of `App` state (`last_error_hint_key`), and one new renderer + family (`note_hint*`); the `AppCommand` enum gains `Hint`, the grammar a + `HINT` node, the REGISTRY one entry. +- **A large, durable content corpus** (~37 command blocks + ~42 error/ + diagnostic blocks ≈ 80) enters the catalogue under `hint.cmd.*` / + `hint.err.*`, validated by `keys.rs`. This is ongoing surface area: new + commands/error classes should ship with their tier-3 hint (a checklist + item for future feature ADRs). +- **Testing:** Tier-1 unit tests for the trigger matrix (F1 with + empty/non-empty input; `hint` with/without a recent error; + `last_error_hint_key` set on the `translate_error` sites and cleared on + success; the pre-submit-diagnostic vs runtime-error routing; the `:` + strip), the command-identification logic, and the tier-2 fallback; + Tier-2 `insta` snapshots for a representative rendered hint block; + Tier-3 integration tests for the end-to-end flows (type a partial + command → F1 → block appears, **buffer and completion memo untouched**; + run a failing command → `hint` → error expansion). **A + comprehensiveness coverage test** (enforces D6): iterate the REGISTRY + and assert every node has a `hint_id` resolving to a `hint.cmd.*` block, + and every runtime-error/diagnostic class has a `hint.err.*` block — + `keys.rs` only checks that *referenced* keys resolve, not that every + command/error *has* one, so this test is what makes "comprehensive" + enforceable rather than aspirational. + +## Out of scope + +- **Per-topic `hint `** — OOS (rejected): `help ` already + serves explicit lookup; a topic arg would overlap it and double the + content-authoring surface. +- **Re-showing tier-3 inline as the always-on ambient hint** — OOS + (rejected): the ambient panel stays terse by design (ADR-0022); tier-3 + is on-demand. Promoting it would defeat the tiering. +- **Localised tier-3 content beyond `en-US`** — OOS (deferred): the + catalogue is structured for i18n (ADR-0019), but additional locales + follow the project's English-only-for-v1 stance (requirements X2). +- **`hint` for a *successful* command's deeper teaching** (e.g. "you just + created a table — here's what an index would add") — OOS (deferred): a + plausible future tier-3 use, but v1 scopes the command path to errors + and the F1 path to in-progress input. + +## Content inventory (implementation tracking) + +The implementation plan enumerates and checks off every block: + +- **`hint.cmd.`** — one per distinct `REGISTRY` node (~37), each + with its own `hint_id` and a mode-correct example: app (`save`, `save + as`, `load`, `new`, `rebuild`, `export`, `import`, `replay`, `undo`, + `redo`, `mode`, `messages`, `copy`, `help`, `hint`, `quit`); DDL + (`create table`, `create m:n`, `add column`/`relationship`/`index`, + `drop`, `rename`, `change column`); DML (`insert`, `update`, `delete`, + `show`, `seed`, `explain`, `select`/`with`). The **7 advanced-mode SQL + forms** (`SQL CREATE TABLE`, `ALTER TABLE`, `CREATE/DROP INDEX`, `DROP + TABLE`, `SQL INSERT/UPDATE/DELETE`, `EXPLAIN SQL`, raw `SELECT`/`WITH`) + each get their **own** block with SQL syntax — they do **not** reuse + their simple sibling's (this is the `/runda` correction; the parallel + `help`-side gap is issue #36). +- **`hint.err.*`** — one per runtime error class (`unique`, + `foreign_key.{child,parent}_side`, `not_null`, `check`, + `type_mismatch`, `not_found`, `already_exists`, `generic`, + `invalid_value`) and per `diagnostic.*` pre-submit class. diff --git a/docs/adr/README.md b/docs/adr/README.md index 8aeb407..594a97b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -58,3 +58,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0050 — Incidental-DDL confirmations omit relationship info (structure-only)](0050-incidental-ddl-confirmations-omit-relationships.md) — **Accepted + implemented 2026-06-12 (issue #28)**, closes Gitea **#28**. **Supersedes** the incidental-DDL clause of **ADR-0044 §1** and the relationship-block half of **ADR-0016 §5**. Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/`rename`/`change column`, `add`/`drop index`) now render **structure only** — header + column box + `Indexes:` + constraints — with **no `References:` / `Referenced by:` block** (neither prose nor diagram), even when the table carries relationships the user did not touch. Rationale (owner): a confirmation echo reports the change just made, not untouched relationships; ADR-0044's terse prose was the lesser of "prose vs diagram", but the right answer for these surfaces is **neither**. **Relationship-subject surfaces are unchanged** — `show table`, `add`/`drop relationship`, `show relationship` still render ADR-0044 diagrams; relationships appear only when the user asks for (`show table`) or acts on (`add`/`drop relationship`) one, and are one `show table ` away — **no information lost**. Forks both user-chosen: **scope = all incidental DDL** (not just `add column` — the rationale is uniform, the mental model clean, and it's the simpler edit) and **delete the prose renderer** (not retain it dormant — no dead code). **Mechanism:** the `handle_dsl_success` `matches!` routing is unchanged (relationship-subject → diagrams; else → `render_structure`); the change is one line inside `render_structure` (`output_render.rs` — drop the relationship-block call) since all its callers are incidental DDL, plus deletion of the orphaned `relationship_prose_lines` + `cols_disp` helpers. The prose format survives in ADR-0016 §5 + git history for a future OOS-7 always-prose setting. **Tests:** the prose-presence unit test + its snapshot removed; a new unit test asserts `render_structure` on a description carrying **both** inbound and outbound relationships emits the box but no prose; the misnamed `add_relationship_flow_shows_inbound_section_on_parent` integration test (which sent an `AddColumn`) inverted + renamed to assert the add-column echo omits the prose; the diagram tests (`show table`, `add relationship`) unaffected. **2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. `requirements.md` unaffected (ADR-tracked refinement of a decided area, like ADR-0044 itself) - [ADR-0051 — Bottom keybinding strip: context- and state-aware](0051-context-state-aware-keybinding-strip.md) — **Accepted + implemented 2026-06-13 (issue #27)**, closes Gitea **#27**. Repurposes the bottom status line into a **keystrokes-only, state-selected** strip (builds on ADR-0046 nav focus, ADR-0003 modes, ADR-0049 the #29 readline keys it now advertises, ADR-0022 the completion memo). A pure `status_bar_bindings(app) -> Vec<(key,label)>` chooses the strip by **priority, first match wins**: (1) **sidebar focus** → `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input`; (2) **completion memo live** (`last_completion`) → `Tab/Shift-Tab cycle · Esc cancel · Enter run`; (3) **history navigation** (new `App::is_browsing_history()` exposing the private `history_cursor`) → `↑↓ browse · Esc clear · Enter run`; (4) **editing** (input non-empty) → `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` (surfaces the #29 keys, closing ADR-0049's deferred advertisement); (5) **default** (empty) → `Ctrl-O sidebar · Tab complete · ↑ history · Enter run`. Priority is correct because Up clears the completion memo and Tab cancels history nav, so states 2/3 never co-occur, and the five are exhaustive for Input focus. **Typed-command words leave the strip** (`mode advanced`/`mode simple` switch, `:` one-shot) and **mode discovery moves to the empty-input hint** (`resolve_hint_lines`), **simple mode only**: `\`mode advanced\` for SQL` (the verb "type" omitted — the prompt implies it; advanced mode shows **no** pointer per a post-trial user decision — a switcher knows how they got there and `help` covers the way back). The one-shot's old `Backspace cancel one-shot` label is subsumed by the editing state (behaviour intact). Forks all user-chosen: **editing state shows the #29 keys** (vs unadvertised); **`Ctrl-C quit` omitted** from the strip (vs always shown); **no width-drop machinery** — the longest strip (~65 cols) fits all supported widths, so a **width-budget unit test** keeps it lean by construction instead (the user's own observation). Catalog: 12 new `shortcut.*` labels + the `panel.hint_mode_advanced` string added to `en-US.yaml`+`keys.rs` (validator-checked 1:1), 5 now-dead strip strings removed. **Modal-aware strip is OOS** (pre-existing: a modal owns the keyboard and carries its own hints; the strip under it is unchanged-in-kind, not worsened). Tests: 9 Tier-1 unit (per-state key sets — completion/history driven through real key events; width budget; mode-pointer presence/absence), 1 Tier-3 rewritten (`status_bar_is_keystroke_only_and_state_aware`), 15 full-panel snapshots re-accepted (reviewed — strip/hint only). **2467 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: modal-aware strip; a full-key cheatsheet overlay; Ctrl-K/U advertisement (editing strip shows the highest-value subset within the width budget) - [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (status field gains a `:adv` tag; **journaling moves from the worker to the dispatch layer**), **ADR-0015 §5/§6** (history.log leaves the worker transaction — `commit-db-last` now scopes yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure is best-effort, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression). **Follow-up done 2026-06-14:** the vestigial worker `source` plumbing was fully unwound (compiler-guided, no behaviour change) — `_source` removed from `finalize_persistence`/`do_rebuild_from_text`, the three `*_request` wrappers inlined+deleted, the dead `source` param dropped from the ~30 forwarding worker handlers, and the `source` field removed from the `DescribeTable`/`QueryData`/`RunSelect` requests + their `DatabaseHandle` methods (~164 mostly-test call sites); the only worker `source` left is the snapshot/undo label (see ADR-0052 *Consequences*) +- [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implementation pending (2026-06-14)**. Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help ` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.` (per command form) and `hint.err.` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed on a new `hint_id` field on `CommandNode`, not `help_id`** (`/runda` correction): `help_id` is not 1:1 with command forms — the 7 advanced-mode SQL nodes carry `help_id: None` purely to dedup the `help` *list*, so they'd be unkeyable and would wrongly share their simple sibling's content despite different syntax; a dedicated `hint_id` gives every one of the ~37 REGISTRY nodes its own mode-correct block (the parallel `help`-side gap is tracked as issue **#36**). Two error routes share `hint.err.*`: pre-submit `diagnostic.*` read live from the walker (F1 path), runtime `translate_error` classes via stored `last_error_hint_key` (`hint` command / empty-F1). Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_id` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): **comprehensive for v1** — ~37 command forms + 9 runtime error classes + ~33 `diagnostic.*` classes ≈ 80 teaching blocks — authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test** (every node/error class has a key), with graceful fall-back to tier-2 if a key is ever missing. Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive scope; exemplars-first. OOS: per-topic `hint ` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); the `help`-side advanced-SQL gap (issue #36) From 309d2e0b3ff82ff073c2f37ae87309d3c6d60dad Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 14 Jun 2026 22:18:02 +0000 Subject: [PATCH 34/50] ci: release-macos workflow (dispatch); retire macOS smoke-test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS release leg: workflow_dispatch (tag input) on the Tart runner — test → build both *-apple-darwin targets → rewrite nix libiconv to /usr/lib + ad-hoc re-sign → upload binary + .sha256 to the tagged release (idempotent create-or-get) → prune the nix store by generation. Composed entirely of parts the smoke-test proved green, so the smoke-test is removed. Dispatch-only fits the intermittent runner and keeps the 4-target Linux/ Windows release independent. Becomes triggerable once CI is on the default branch (workflow_dispatch is default-branch-only in Gitea). --- .gitea/workflows/macos-probe.yaml | 81 ------------------------ .gitea/workflows/release-macos.yaml | 95 +++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 81 deletions(-) delete mode 100644 .gitea/workflows/macos-probe.yaml create mode 100644 .gitea/workflows/release-macos.yaml diff --git a/.gitea/workflows/macos-probe.yaml b/.gitea/workflows/macos-probe.yaml deleted file mode 100644 index 565625a..0000000 --- a/.gitea/workflows/macos-probe.yaml +++ /dev/null @@ -1,81 +0,0 @@ -# THROWAWAY build smoke-test for the macOS (Tart) runner. Verifies both -# *-apple-darwin targets actually compile and link (incl. arboard's AppKit) -# through the flake on the real Mac, before the full release-macos workflow is -# wired. Delete once that lands. -# -# Push-triggered (workflow_dispatch only works for workflows on the default -# branch; our CI is on `ci`). Runs when the flake/toolchain or this file change. -# Bring the Mac up before pushing so the run isn't left queued. -name: macos-build-test -on: - push: - paths: - - '.gitea/workflows/macos-probe.yaml' - - 'flake.nix' - - 'rust-toolchain.toml' - workflow_dispatch: - -jobs: - build: - # Label NAME only — `:host` in the runner registration is the execution - # backend (run on host), not part of the label. - runs-on: macos - env: - # Guarantee flakes regardless of the Mac's nix config. - NIX_CONFIG: "experimental-features = nix-command flakes" - steps: - - uses: actions/checkout@v4 - - name: test (macOS — the gate only covers Linux) - run: nix develop -c cargo test --no-fail-fast - - name: build, de-nix, sign, verify both darwin targets - run: | - set -e - for t in aarch64-apple-darwin x86_64-apple-darwin; do - echo "==================== $t ====================" - nix develop -c cargo build --release --target "$t" - f="target/$t/release/rdbms-playground" - - # The darwin stdenv bakes a /nix/store libiconv load path into the - # binary. Rewrite it to the system libiconv (every Mac has it, ABI- - # compatible), then re-sign ad-hoc — install_name_tool invalidates - # the signature and arm64 won't run an unsigned/broken-sig binary. - for l in $(otool -L "$f" | awk '/\/nix\/store.*libiconv.*dylib/ {print $1}'); do - echo "rewrite $l -> /usr/lib/libiconv.2.dylib" - install_name_tool -change "$l" /usr/lib/libiconv.2.dylib "$f" - done - codesign --force --sign - "$f" - - echo "--- linked libs ---"; otool -L "$f" - if otool -L "$f" | grep -q /nix/store; then - echo "ERROR: $t still links a /nix/store dylib"; exit 1 - fi - codesign --verify --verbose=2 "$f" && echo "signature OK" - - # Smoke-run the natively-runnable target (this VM is arm64). - if [ "$t" = "aarch64-apple-darwin" ]; then - echo "--- run --help ---"; "$f" --help | head -1 - else - echo "(skip run: $t needs Rosetta)" - fi - echo "OK: $t portable" - done - echo "=== both darwin targets built, de-nixed, signed, verified ===" - - - name: prune nix store — keep the last 2 toolchain generations - # The runner wipes the whole workspace before each run, so cargo target/ - # never accumulates (no sweep needed). The persistent caches are the nix - # store (/nix) and ~/.cargo (in $HOME). Bound the nix store by generation: - # record the current devShell closure as a generation of a persistent - # profile (lives in $HOME, survives the workspace wipe), keep the 2 newest - # (current + previous), reclaim what the older ones referenced. No time - # window — never more than two toolchains regardless of flake.lock churn. - if: always() - run: | - echo "--- disk before ---"; df -h / | tail -1 - P="$HOME/.cache/rdbms-ci/toolchain" - nix develop --profile "$P" -c true || true - nix-env -p "$P" --delete-generations +2 || true - nix-collect-garbage || true - echo "--- disk after ---"; df -h / | tail -1 - # ~/.cargo/registry also persists but grows only on Cargo.lock bumps; - # bound it later with `cargo-cache --autoclean` if it ever matters. diff --git a/.gitea/workflows/release-macos.yaml b/.gitea/workflows/release-macos.yaml new file mode 100644 index 0000000..8f75829 --- /dev/null +++ b/.gitea/workflows/release-macos.yaml @@ -0,0 +1,95 @@ +# macOS release leg — the two *-apple-darwin binaries, built natively on the +# Tart (Apple-Silicon) runner and attached to an existing Gitea release. +# +# Manual dispatch only: the Mac runner is intermittent, so this is triggered by +# hand (with the Mac up) for a given release tag. The 4-target Linux/Windows +# release (release.yaml) runs on the tag itself and never waits on the Mac, so a +# release always has those four; the macOS two are added by dispatching this. +# +# NOTE: Gitea exposes workflow_dispatch only for workflows on the DEFAULT branch, +# so this becomes triggerable once the CI work is merged to `main`. +name: release-macos +on: + workflow_dispatch: + inputs: + tag: + description: 'Release tag to build the macOS binaries for and attach to (e.g. v0.1.0)' + required: true + +jobs: + release-macos: + runs-on: macos + env: + NIX_CONFIG: "experimental-features = nix-command flakes" + TAG: ${{ inputs.tag }} + # Auto-provided by Gitea Actions; has repo write (release) scope. + TOKEN: ${{ secrets.GITEA_TOKEN }} + API: ${{ github.server_url }}/api/v1 + REPO: ${{ github.repository }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + + - name: test + run: nix develop -c cargo test --no-fail-fast + + - name: build, de-nix, sign, package + publish + run: | + set -e + mkdir -p dist + for t in aarch64-apple-darwin x86_64-apple-darwin; do + echo "==================== $t ====================" + nix develop -c cargo build --release --target "$t" + f="target/$t/release/rdbms-playground" + + # Rewrite the nix-store libiconv load path to the system one, then + # re-sign ad-hoc (install_name_tool invalidates the signature; arm64 + # requires a valid one). Guard against any remaining /nix/store dep. + for l in $(otool -L "$f" | awk '/\/nix\/store.*libiconv.*dylib/ {print $1}'); do + install_name_tool -change "$l" /usr/lib/libiconv.2.dylib "$f" + done + codesign --force --sign - "$f" + if otool -L "$f" | grep -q /nix/store; then + echo "ERROR: $t binary links a /nix/store dylib"; exit 1 + fi + + out="rdbms-playground-$TAG-$t" + cp "$f" "dist/$out" + ( cd dist && shasum -a 256 "$out" > "$out.sha256" ) # macOS: shasum, not sha256sum + done + ls -l dist + + # Idempotent create-or-get the release (release.yaml likely created it + # already from the tag), then upload the two macOS binaries + checksums. + created=$(curl -sS -X POST "$API/repos/$REPO/releases" \ + -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Automated release for $TAG.\"}") + id=$(printf '%s' "$created" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const o=JSON.parse(s);process.stdout.write(String(o.id||""))}catch(e){}})') + if [ -z "$id" ]; then + id=$(curl -sS "$API/repos/$REPO/releases/tags/$TAG" \ + -H "Authorization: token $TOKEN" \ + | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{process.stdout.write(String(JSON.parse(s).id))})') + fi + echo "release id: $id" + for fa in dist/*; do + name=$(basename "$fa") + echo "uploading $name" + curl -sS -X POST "$API/repos/$REPO/releases/$id/assets?name=$name" \ + -H "Authorization: token $TOKEN" -F "attachment=@$fa" > /dev/null + done + echo "published macOS assets for $TAG" + + - name: prune nix store — keep the last 2 toolchain generations + # The runner wipes the workspace each run, so cargo target/ never + # accumulates. Bound the persistent nix store by generation: record the + # current devShell as a generation of a persistent profile (in $HOME), + # keep the 2 newest, reclaim what older ones referenced. + if: always() + run: | + echo "--- disk before ---"; df -h / | tail -1 + P="$HOME/.cache/rdbms-ci/toolchain" + nix develop --profile "$P" -c true || true + nix-env -p "$P" --delete-generations +2 || true + nix-collect-garbage || true + echo "--- disk after ---"; df -h / | tail -1 From 986844288910cf8715e352fc07075b7cd932f168 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 14 Jun 2026 22:18:59 +0000 Subject: [PATCH 35/50] docs(plan): H2 contextual `hint` implementation plan (ADR-0053) Phased build plan: mechanism skeleton with tier-2 fallback first (hint_id field, AppCommand::Hint, F1 read-only overlay, last_error_hint_key, note_hint* renderer), then catalogue + the three approved exemplars, then comprehensive content in batches, then polish. Reuses the existing command_for_entry_word / usage_keys_for_input_in_mode lookups for command identification. Test spine includes the comprehensiveness coverage test that gates "comprehensive for v1". --- .../20260614-adr-0053-contextual-hint-H2.md | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 docs/plans/20260614-adr-0053-contextual-hint-H2.md diff --git a/docs/plans/20260614-adr-0053-contextual-hint-H2.md b/docs/plans/20260614-adr-0053-contextual-hint-H2.md new file mode 100644 index 0000000..0992a1e --- /dev/null +++ b/docs/plans/20260614-adr-0053-contextual-hint-H2.md @@ -0,0 +1,233 @@ +# Plan — ADR-0053: contextual `hint` command + F1 keybinding (H2) + +Implements ADR-0053. Closes the last open piece of **A1** (the canonical +app-command set) and requirements **H2**. No Gitea issue — this is +requirements-driven work; any genuine "later" item found en route gets +its own issue (cf. #36, already filed for the parallel `help`-side gap). + +## 1. Goal + +Give learners on-demand, **teaching-grade** contextual help — a *third* +tier beneath the existing terse always-on text (tier 1) and the +short contextual lines that are already shown (tier 2: the live ambient +prose, and the error `hint:` which is on by default since +`Verbosity::Verbose` is the default). Two surfaces: + +- **F1** (read-only overlay) → a tier-3 block for the **live partial + input**, or — on empty input — for the **most recent runtime error**. +- **`hint`** (submitted app command) → the tier-3 block for the **most + recent runtime error** (the buffer is empty post-submit, so it can only + act on recent context). + +The mechanism is small; the **content corpus is the feature** (~80 +blocks, comprehensive for v1, authored exemplars-first per ADR-0053 D7). + +## 2. The shape of the work (why this order) + +The mechanism and the content are separable, and the mechanism should +land first with **graceful tier-2 fallback** so every surface works +before any tier-3 text exists. That lets us: + +- build + test the trigger matrix / routing / `:`-strip / read-only- + overlay behaviour against a skeleton (TDD), then +- pour in content in reviewable batches without re-touching the wiring, +- and turn on the **comprehensiveness coverage test** only once the + corpus is complete (it is red until then — by design). + +Build order: **Phase A** (mechanism skeleton, falls back to tier-2) → +**Phase B** (catalogue structure + the three approved exemplars) → +**Phase C** (comprehensive content, batched) → **Phase D** (polish: +strip advertisement, snapshots, full green). + +## 3. Grammar: the `hint_id` field + the `HINT` node + +### 3a. New `CommandNode.hint_id` +- Add `pub hint_id: Option<&'static str>` to `CommandNode` + (`src/dsl/grammar/mod.rs:512`, beside `help_id` / `usage_ids`), with a + doc comment mirroring `help_id`'s. Compiler will force every node + literal (~37, across `grammar/app.rs`, `data.rs`, `ddl.rs`) to set it — + in Phase A set them all to `None` (everything falls back to tier-2); + fill them in Phase C. +- **Why `hint_id` not `help_id`** (ADR-0053 D3): `help_id` is `None` on the + 7 advanced-SQL forms purely to dedup the `help` *list*; those forms have + distinct SQL syntax and need their own block. `hint_id` is 1:1 with + forms. + +### 3b. `AppCommand::Hint` + the `HINT` node +- `AppCommand::Hint` variant (no fields — no topic arg) in + `src/dsl/command.rs:544`. +- `pub static HINT: CommandNode` in `grammar/app.rs` mirroring `HELP` but + with **no topic shape** (bare keyword, like `UNDO`): `entry: + Word::keyword("hint")`, `shape: EMPTY_SEQ` (as `UNDO`, + `grammar/app.rs:333`), `ast_builder: + build_hint` (returns `Command::App(AppCommand::Hint)`), `help_id: + Some("app.hint")`, `hint_id: Some("app.hint")`, `usage_ids: + &["parse.usage.hint"]`. +- Register `(&app::HINT, CommandCategory::Simple)` in `REGISTRY` + (`grammar/mod.rs`), beside `HELP`. (App commands are available in both + modes via the existing mechanism.) + +## 4. Command identification (live-input → node) + +The F1 live-input path needs "which command form is being typed." **The +lookup machinery already exists** — do not rebuild entry matching: +- `command_for_entry_word(word) -> Option<(usize, &'static CommandNode)>` + (`grammar/mod.rs:811`) returns the matched node for an entry word + (Simple-first; the caller extracts the first word of the input). +- `usage_keys_for_input_in_mode(source, mode)` (`grammar/mod.rs:564`) + already performs the **mode-aware** Simple/Advanced selection the hint + path needs (advanced `create` → the SQL nodes, simple → the DSL node) — + it just returns `usage_ids` rather than the node. +- **The only new bit:** a thin `hint_id_for_input_in_mode(source, mode)` + (or a node-returning sibling of `usage_keys_for_input_in_mode`) that + applies the same mode selection and returns the chosen node's + `hint_id`. Mirror the existing function; don't duplicate its matching. +- **`:`-strip:** in Simple mode, strip a leading `:` (one-shot escape, + ADR-0003) before identification so `: SELECT …` resolves to the + advanced `SELECT` node. +- No match (empty / unrecognised entry word) → the "getting started" + pointer (D2). + +## 5. F1 keybinding (read-only overlay) + +In `App::handle_key` (`src/app.rs:1155`): +- Add an F1 arm (`KeyCode::F(1)`) **after** the modal gate and the + sidebar-nav gate (inert there, per D2), and **before** the + "any other key clears the completion memo" fall-through (`_ => + self.last_completion = None`, ~line 1228) — F1 must **not** clear the + memo or touch the buffer/cursor (D1). +- Behaviour (the trigger matrix, D2): + - non-empty input → `note_hint_for_input()` (the command's `hint.cmd` + block + the live "Next:" expected-set from the walker). + - empty input + `last_error_hint_key` set → `note_hint_for_error()`. + - empty input + no recent error → `note_getting_started()`. +- Returns `Vec::new()` (pure output emission, like `help`). +- `demo_badge_label` (`app.rs:520`) gains an `F1 → "[F1]"` entry so demo + mode surfaces it (ADR-0047). + +## 6. The two error routes (D2 / D5) + +- **Runtime errors:** add `last_error_hint_key: Option` to `App`. + Set it where friendly errors are rendered (`runtime.rs:2615`, + `app.rs:2424`) from the error's class key; clear on the next successful + command. The `hint` command and empty-input F1 read it. +- **Pre-submit diagnostics:** the F1 live-input path, when the input + carries an under-cursor diagnostic, reads it straight from the walker + (`input_diagnostics_in_mode`, the same source the ambient panel uses) + and renders that diagnostic's `hint.err.` block instead of (or + alongside) the command block. No stored state. +- Both render from `hint.err.*`. + +## 7. Rendering: the `note_hint*` family (D4) + +- New `App::note_hint_for_input`, `note_hint_for_error`, + `note_getting_started` (siblings of `note_help`/`note_help_topic`, + `app.rs:2982`/`3021`). +- A tier-3 block is **structured** (`what` / `example` / `concept`, plus + the live `Next:` line on the input path). The catalogue stores each part + under sub-keys (`hint.cmd..what`, `.example`, `.concept`); the + renderer fetches each via `t!` and lays them out as a small framed + block. +- Styling: `OutputKind::System`; `OutputStyleClass::Hint` (muted) on + `what`/`concept`/`Next`, `Neutral` on `example` so the runnable line + stands out. Reuse `OutputLine::styled` + `push_category_three_prose` + patterns (`app.rs:3121`). +- **Fallback:** if a node's `hint_id` is `None` or a key is missing, + degrade to tier-2 (ambient prose for the input path; the verbose error + `hint:` for the error path) — never blank. + +## 8. Catalogue + `keys.rs` + +- New sub-namespaces under the existing top-level `hint:` in + `src/friendly/strings/en-US.yaml`: `hint.cmd..{what,example, + concept}` and `hint.err..{what,example,concept}`. +- Register every key + its placeholders in `src/friendly/keys.rs` + (`KEYS_AND_PLACEHOLDERS`) so the build-time validation covers them. +- `parse.usage.hint` + `help.app.hint` strings for the command itself. + +## 9. Content (Phase C — the bulk, batched per D7) + +Exemplars approved in the ADR (`insert` live-input, FK child-side error, +`add relationship`) are the template. Author in reviewable batches: +1. **App commands** (~16): save/save as/load/new/rebuild/export/import/ + replay/undo/redo/mode/messages/copy/help/hint/quit. +2. **DDL** (simple): create table, create m:n, add column/relationship/ + index, drop, rename, change column. +3. **DML** (simple): insert, update, delete, show, seed, explain, + select/with. +4. **Advanced-mode SQL forms** (7): SQL CREATE TABLE, ALTER TABLE, + CREATE/DROP INDEX, DROP TABLE, SQL INSERT/UPDATE/DELETE, EXPLAIN SQL — + **own blocks, SQL-syntax examples**. +5. **Runtime error classes** (9): unique, foreign_key ×{child,parent}, + not_null, check, type_mismatch, not_found, already_exists, generic, + invalid_value. +6. **`diagnostic.*` classes** (~33): arity/type/unknown-table-column/etc. + +Each block: `what` (1–2 sentences), `example` (one runnable line, +mode-correct), `concept` (the relational idea — the teaching part; +optional only where genuinely none, e.g. `quit`). + +## 10. Tests + +Written test-first against the Phase-A skeleton where possible. + +- **Tier 1 (unit, `app.rs`):** + - trigger matrix: F1 non-empty → command block; F1 empty + recent error + → error block; F1 empty + none → getting-started; `hint` command + + error → error block; `hint` + none → getting-started. + - `last_error_hint_key` set on a failing command, cleared on the next + success. + - routing: a pre-submit diagnostic on the input drives the diagnostic + `hint.err`; a runtime error drives the stored-key route. + - `:`-strip: `: SELECT …` in Simple mode resolves to the advanced node. + - **read-only overlay:** F1 leaves `input`, `input_cursor`, and + `last_completion` unchanged. + - tier-2 fallback when `hint_id`/key absent. +- **Tier 2 (`insta`):** snapshot a representative rendered tier-3 block + (the `insert` exemplar) so the framed layout + styling spans are locked. +- **Tier 3 (integration, `tests/it/`):** type a partial command → F1 → + block appears, buffer untouched; run a failing insert → `hint` → FK + error expansion. +- **Comprehensiveness coverage test** (enforces D6, the key one): iterate + `REGISTRY` and assert every node has a `hint_id` resolving to a + `hint.cmd.*` block; assert every runtime-error + `diagnostic.*` class + has a `hint.err.*` block. **Red until Phase C completes** — enable + (un-`ignore`) as the final gate. +- `keys.rs` validation continues to guarantee every *referenced* key + resolves. + +## 11. Keybinding strip + discoverability (Phase D) + +- The ADR-0051 bottom strip advertises **F1 = hint** in the editing/ + typing state (and on the empty-input state, since F1 still does + something there). Re-accept the affected full-panel snapshots. + +## 12. ADR / docs + +- ADR-0053 is committed (`e16ad50`). On completion, flip its Status from + "implementation pending" to implemented (with date), and update the + README index entry + `requirements.md` **H2 → [x]** and **A1 → [x]** + (A1 closes when `hint` lands). + +## 13. Risks / watch-list + +- **Command-identification reuse.** The lookup exists + (`command_for_entry_word` + the mode-aware `usage_keys_for_input_in_mode`, + `grammar/mod.rs:811`/`564`); the only new code is a thin node/`hint_id` + variant that reuses their selection. Do **not** re-implement entry-word + matching — mirror the existing functions. +- **Structured-key ergonomics.** Three sub-keys per block × ~80 blocks is + ~240 catalogue keys; keep the `keys.rs` registration generation tidy + (consider a helper that registers the `{what,example,concept}` triple + for an id). +- **Content voice drift across batches.** Re-check each batch against the + approved exemplars; the `concept` line is where drift (too terse / too + advanced) creeps in. Pedagogy wins ties. +- **F1 terminal capture.** A few terminals intercept F1; acceptable + (it's the convention) but note it if testing surfaces it. +- **Snapshot churn.** The strip change re-accepts ADR-0051 snapshots; + keep that diff isolated. +- **Coverage-test timing.** It is red through Phases A–C; gate it so CI + isn't broken mid-stream (e.g. `#[ignore]` until the final batch), then + make passing it the completion criterion. +``` From 050b36391e2a863efba12ac170e6e3ac4c53ecd5 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Mon, 15 Jun 2026 10:36:51 +0000 Subject: [PATCH 36/50] =?UTF-8?q?feat(hint):=20H2=20Phase=20A=20=E2=80=94?= =?UTF-8?q?=20`hint`=20command=20+=20F1=20keybinding=20skeleton=20(ADR-005?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mechanism for the contextual hint, with tier-2 fallback; the tier-3 corpus lands in later phases. - new CommandNode `hint_id` field (all None for now) - AppCommand::Hint + HINT grammar node + REGISTRY + dispatch - F1 read-only overlay in handle_key (buffer/cursor/memo untouched) - note_hint* renderers; hint_id_for_input_in_mode (shared selection helper refactored out of usage_keys_for_input_in_mode) - last_error_hint_key + friendly::error_hint_class classifier - catalogue: help.app.hint / parse.usage.hint / hint.getting_started - +12 tests; 2483 pass / 1 ignored, clippy clean --- src/app.rs | 261 ++++++++++++++++++++++++++++++++ src/dsl/command.rs | 6 + src/dsl/grammar/app.rs | 25 +++ src/dsl/grammar/data.rs | 13 ++ src/dsl/grammar/ddl.rs | 11 ++ src/dsl/grammar/mod.rs | 99 ++++++++---- src/dsl/walker/mod.rs | 2 + src/friendly/keys.rs | 3 + src/friendly/mod.rs | 2 +- src/friendly/strings/en-US.yaml | 6 + src/friendly/translate.rs | 153 +++++++++++++++++++ tests/typing_surface/mod.rs | 1 + 12 files changed, 550 insertions(+), 32 deletions(-) diff --git a/src/app.rs b/src/app.rs index b6853c6..e8a645d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -271,6 +271,13 @@ pub struct App { pub nav_focus: NavFocus, pub output: VecDeque, pub hint: Option, + /// Catalog class key of the most recent runtime error (H2 / + /// ADR-0053 D5), e.g. `foreign_key.child_side`. Set when a + /// friendly error is rendered, cleared on the next successful + /// command. The submitted `hint` command and empty-input F1 use + /// it to render that error's tier-3 `hint.err.` block. + /// `None` → no recent error → the "getting started" pointer. + pub last_error_hint_key: Option, /// The validity indicator's currently-visible verdict /// (ADR-0027). `None` means the indicator shows nothing — /// the input is clean, or it is hidden mid-typing while the @@ -521,6 +528,7 @@ pub const fn demo_badge_label(key: &KeyEvent) -> Option<&'static str> { match (key.code, key.modifiers) { (KeyCode::Tab, _) => Some("[TAB]"), (KeyCode::BackTab, _) => Some("[SHIFT-TAB]"), + (KeyCode::F(1), _) => Some("[F1]"), (KeyCode::Enter, _) => Some("[ENTER]"), (KeyCode::Esc, _) => Some("[ESC]"), (KeyCode::Up, _) => Some("[UP]"), @@ -557,6 +565,7 @@ impl App { nav_focus: NavFocus::Input, output: VecDeque::with_capacity(OUTPUT_CAPACITY), hint: None, + last_error_hint_key: None, input_indicator: None, tables: Vec::new(), relationships: Vec::new(), @@ -1208,6 +1217,21 @@ impl App { return self.handle_nav_key(key); } + // H2 / ADR-0053: F1 is a read-only contextual-hint overlay — + // it emits into the output journal and must NOT touch the input + // buffer, cursor, or the completion memo, so it sits ahead of + // the memo-clearing completion match below. Non-empty input → + // a hint for the command being typed; empty input → expand on + // the most recent error (or a getting-started pointer). + if key.code == KeyCode::F(1) { + if self.input.trim().is_empty() { + self.note_hint_for_recent_error(); + } else { + self.note_hint_for_input(); + } + return Vec::new(); + } + // ADR-0022 stage 8 — non-modal completion. Tab / // Shift-Tab cycle; Esc / Backspace undo the whole // last-Tab insertion in one keystroke while the memo @@ -1774,6 +1798,13 @@ impl App { // recallable. The canonical (un-prefixed) text is what reaches // the journal via `ExecuteDsl.source`. let is_app = matches!(&parsed, Ok(Command::App(_))); + // H2 / ADR-0053 D5: a new *DSL* command supersedes the previous + // runtime error for `hint`. App commands (incl. `hint` itself) + // and parse errors leave it intact, so `hint` still expands the + // last real error after, say, a `help` in between. + if matches!(&parsed, Ok(cmd) if !matches!(cmd, Command::App(_))) { + self.last_error_hint_key = None; + } let advanced = submission_mode.is_advanced() && !is_app; let ring_line = if advanced { format!(": {effective_input}") @@ -1814,6 +1845,13 @@ impl App { } Vec::new() } + // H2 / ADR-0053: a submitted `hint` acts on the most recent + // runtime error (the buffer is empty post-submit). The + // live-input surface is the F1 keybinding (handle_key). + AppCommand::Hint => { + self.note_hint_for_recent_error(); + Vec::new() + } AppCommand::Rebuild => vec![Action::PrepareRebuild], AppCommand::Save => self.handle_save_command(false), AppCommand::SaveAs => self.handle_save_command(true), @@ -2422,6 +2460,10 @@ impl App { // runtime built before posting the event. let ctx = self.build_translate_context(command, facts); let rendered = crate::friendly::translate_error(&error, &ctx).render(); + // H2 / ADR-0053 D5: remember this error's tier-3 class so a + // following `hint` (or empty-input F1) can expand on it. + self.last_error_hint_key = + crate::friendly::error_hint_class(&error, &ctx).map(String::from); warn!( verb = command.verb(), error = %rendered, @@ -3091,6 +3133,94 @@ impl App { } } + // ── H2 / ADR-0053: contextual `hint` ──────────────────────── + // Phase A wires the two surfaces (F1 → live input; the `hint` + // command → most recent error) plus the tier-2 fallback. The + // tier-3 corpus (`hint.cmd.*` / `hint.err.*`) is authored in later + // phases; until a block exists, `emit_tier3_block` returns false + // and the surface degrades to the ambient prose / getting-started + // pointer — never blank. + + /// F1 with a non-empty buffer: a tier-3 hint for the command form + /// being typed, else the tier-2 ambient prose (ADR-0053 D2). + /// Read-only — callers guarantee the buffer/cursor/memo are left + /// untouched. + fn note_hint_for_input(&mut self) { + // `feedback_view` strips the `:` one-shot sigil and + // `effective_mode` reflects the one-shot advanced surface, so + // the hint matches the command the user is actually typing. + let (view, cursor, _off) = self.feedback_view(); + let probe = view.to_string(); + let mode = self.effective_mode().as_mode(); + if let Some(id) = crate::dsl::grammar::hint_id_for_input_in_mode(&probe, mode) + && self.emit_tier3_block(&format!("hint.cmd.{id}")) + { + return; + } + // Tier-2 fallback: surface the ambient prose as a persistent + // line (computed exactly as the live panel does). + let ambient = crate::input_render::ambient_hint_in_mode( + &probe, + cursor, + self.last_completion.as_ref(), + &self.schema_cache, + mode, + ); + match ambient { + Some(crate::input_render::AmbientHint::Prose(text)) => { + self.push_category_three_prose(text); + } + Some(crate::input_render::AmbientHint::Candidates { items, .. }) => { + let names = items + .iter() + .map(|c| c.text.clone()) + .collect::>() + .join(", "); + self.push_category_three_prose(crate::t!("hint.ambient_expected", expected = names)); + } + None => self.note_getting_started(), + } + } + + /// The `hint` command (and empty-input F1): expand on the most + /// recent runtime error, else point the user at how to start + /// (ADR-0053 D2/D5). + fn note_hint_for_recent_error(&mut self) { + if let Some(class) = self.last_error_hint_key.clone() + && self.emit_tier3_block(&format!("hint.err.{class}")) + { + return; + } + self.note_getting_started(); + } + + fn note_getting_started(&mut self) { + self.note_system(crate::t!("hint.getting_started")); + } + + /// Render a tier-3 block (`.what` / `.example` / `.concept`) + /// when it has been authored; returns `false` if the `what` part is + /// absent so the caller can fall back to tier 2. `what` is + /// mandatory, `example`/`concept` optional (ADR-0053 D3). Styling + /// polish (the framed block) lands with the corpus. + fn emit_tier3_block(&mut self, stem: &str) -> bool { + let cat = crate::friendly::catalog(); + if cat.get(&format!("{stem}.what")).is_none() { + return false; + } + self.note_system(crate::friendly::translate(&format!("{stem}.what"), &[])); + if cat.get(&format!("{stem}.example")).is_some() { + self.note_system(crate::friendly::translate(&format!("{stem}.example"), &[])); + } + if cat.get(&format!("{stem}.concept")).is_some() { + self.push_category_three_prose(crate::friendly::translate( + &format!("{stem}.concept"), + &[], + )); + } + true + } + fn note_system(&mut self, text: impl Into) { self.push_multiline(text.into(), OutputKind::System); } @@ -5539,6 +5669,137 @@ mod tests { assert!(last.text.contains("Ghost"), "{}", last.text); } + // ── H2 / ADR-0053: contextual `hint` (Phase A skeleton) ────── + + fn f1(app: &mut App) -> Vec { + app.update(key(KeyCode::F(1))) + } + + fn no_such_table_failure() -> AppEvent { + AppEvent::DslFailed { + command: Command::DropTable { + name: "Ghost".to_string(), + }, + error: crate::db::DbError::Sqlite { + message: "no such table: Ghost".to_string(), + kind: crate::db::SqliteErrorKind::NoSuchTable, + }, + facts: crate::friendly::FailureContext::default(), + source: String::new(), + advanced: false, + } + } + + #[test] + fn hint_command_parses_to_app_hint() { + use crate::dsl::{parse_command, AppCommand, Command}; + assert!(matches!( + parse_command("hint"), + Ok(Command::App(AppCommand::Hint)) + )); + } + + #[test] + fn hint_command_with_no_recent_error_shows_getting_started() { + let mut app = App::new(); + type_str(&mut app, "hint"); + submit(&mut app); + assert!(output_contains(&app, "press F1"), "{}", error_lines(&app)); + } + + #[test] + fn f1_on_empty_input_with_no_error_shows_getting_started() { + let mut app = App::new(); + let before = app.output.len(); + f1(&mut app); + assert!(app.output.len() > before, "F1 must emit something"); + assert!(output_contains(&app, "press F1")); + } + + #[test] + fn f1_is_a_read_only_overlay() { + let mut app = App::new(); + type_str(&mut app, "insert into T"); + let input = app.input.clone(); + let cursor = app.input_cursor; + let before = app.output.len(); + f1(&mut app); + assert_eq!(app.input, input, "F1 must not change the buffer"); + assert_eq!(app.input_cursor, cursor, "F1 must not move the cursor"); + assert!(app.output.len() > before, "F1 emits a hint line"); + } + + #[test] + fn f1_preserves_the_completion_memo() { + let mut app = App::new(); + type_str(&mut app, "show "); + app.update(key(KeyCode::Tab)); + assert!(app.last_completion.is_some(), "precondition: Tab sets the memo"); + let input = app.input.clone(); + f1(&mut app); + assert!(app.last_completion.is_some(), "F1 must not clear the memo"); + assert_eq!(app.input, input, "F1 must not change the buffer"); + } + + #[test] + fn dsl_failure_sets_hint_class_and_a_later_dsl_command_clears_it() { + let mut app = App::new(); + app.update(no_such_table_failure()); + assert_eq!(app.last_error_hint_key.as_deref(), Some("not_found")); + // A new DSL command supersedes the previous error. + type_str(&mut app, "drop table Ghost"); + submit(&mut app); + assert_eq!(app.last_error_hint_key, None); + } + + #[test] + fn app_command_does_not_clear_the_hint_class() { + let mut app = App::new(); + app.update(no_such_table_failure()); + assert_eq!(app.last_error_hint_key.as_deref(), Some("not_found")); + // `help` (an app command) leaves the last error intact, so a + // following `hint` still expands on it. + type_str(&mut app, "help"); + submit(&mut app); + assert_eq!( + app.last_error_hint_key.as_deref(), + Some("not_found"), + "an app command must not clear the last error's hint class" + ); + } + + #[test] + fn hint_after_error_emits_a_hint_without_panicking() { + // Phase A: no tier-3 `hint.err.*` content exists yet, so the + // error path falls back to the getting-started pointer. (Phase C + // replaces this with the real error block.) + let mut app = App::new(); + app.update(no_such_table_failure()); + let before = app.output.len(); + type_str(&mut app, "hint"); + submit(&mut app); + assert!(app.output.len() > before, "hint must emit something"); + } + + #[test] + fn help_list_includes_hint() { + let mut app = App::new(); + type_str(&mut app, "help"); + submit(&mut app); + assert!( + output_contains(&app, "explain the most recent error"), + "help list must advertise the hint command" + ); + } + + #[test] + fn help_hint_describes_the_hint_command() { + let mut app = App::new(); + type_str(&mut app, "help hint"); + submit(&mut app); + assert!(output_contains(&app, "explain the most recent error")); + } + #[test] fn messages_command_toggles_verbosity_and_reports() { let mut app = App::new(); diff --git a/src/dsl/command.rs b/src/dsl/command.rs index 99304a3..66d1933 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -552,6 +552,11 @@ pub enum AppCommand { Help { topic: Option, }, + /// Show a contextual tier-3 hint (H2 / ADR-0053). No argument: + /// when submitted, it expands on the most recent runtime error + /// (the buffer is empty post-submit). The live-input surface is + /// the F1 keybinding, handled in `App::handle_key`, not here. + Hint, /// Rebuild `playground.db` from `project.yaml` + data/, with /// confirmation modal. Rebuild, @@ -1013,6 +1018,7 @@ impl Command { Self::App(app) => match app { AppCommand::Quit => "quit", AppCommand::Help { .. } => "help", + AppCommand::Hint => "hint", AppCommand::Rebuild => "rebuild", AppCommand::Save => "save", AppCommand::SaveAs => "save as", diff --git a/src/dsl/grammar/app.rs b/src/dsl/grammar/app.rs index 8bc4361..75dfada 100644 --- a/src/dsl/grammar/app.rs +++ b/src/dsl/grammar/app.rs @@ -177,6 +177,9 @@ const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result Result { Ok(Command::App(AppCommand::Undo)) } +const fn build_hint(_path: &MatchedPath, _source: &str) -> Result { + Ok(Command::App(AppCommand::Hint)) +} const fn build_redo(_path: &MatchedPath, _source: &str) -> Result { Ok(Command::App(AppCommand::Redo)) @@ -263,6 +266,7 @@ pub static QUIT: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_quit, help_id: Some("app.quit"), + hint_id: None, usage_ids: &["parse.usage.quit"],}; pub static HELP: CommandNode = CommandNode { @@ -270,13 +274,24 @@ pub static HELP: CommandNode = CommandNode { shape: HELP_TOPIC_OPT, ast_builder: build_help, help_id: Some("app.help"), + hint_id: None, usage_ids: &["parse.usage.help"],}; +pub static HINT: CommandNode = CommandNode { + entry: Word::keyword("hint"), + shape: EMPTY_SEQ, + ast_builder: build_hint, + help_id: Some("app.hint"), + // hint_id assigned in Phase C with the tier-3 corpus (ADR-0053). + hint_id: None, + usage_ids: &["parse.usage.hint"],}; + pub static REBUILD: CommandNode = CommandNode { entry: Word::keyword("rebuild"), shape: EMPTY_SEQ, ast_builder: build_rebuild, help_id: Some("app.rebuild"), + hint_id: None, usage_ids: &["parse.usage.rebuild"],}; pub static SAVE: CommandNode = CommandNode { @@ -284,6 +299,7 @@ pub static SAVE: CommandNode = CommandNode { shape: SAVE_AS_OPT, ast_builder: build_save, help_id: Some("app.save"), + hint_id: None, usage_ids: &["parse.usage.save"],}; pub static NEW: CommandNode = CommandNode { @@ -291,6 +307,7 @@ pub static NEW: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_new, help_id: Some("app.new"), + hint_id: None, usage_ids: &["parse.usage.new"],}; pub static LOAD: CommandNode = CommandNode { @@ -298,6 +315,7 @@ pub static LOAD: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_load, help_id: Some("app.load"), + hint_id: None, usage_ids: &["parse.usage.load"],}; pub static EXPORT: CommandNode = CommandNode { @@ -305,6 +323,7 @@ pub static EXPORT: CommandNode = CommandNode { shape: EXPORT_PATH_OPT, ast_builder: build_export, help_id: Some("app.export"), + hint_id: None, usage_ids: &["parse.usage.export"],}; pub static IMPORT: CommandNode = CommandNode { @@ -312,6 +331,7 @@ pub static IMPORT: CommandNode = CommandNode { shape: IMPORT_BODY_OPT, ast_builder: build_import, help_id: Some("app.import"), + hint_id: None, usage_ids: &["parse.usage.import"],}; pub static MODE: CommandNode = CommandNode { @@ -319,6 +339,7 @@ pub static MODE: CommandNode = CommandNode { shape: MODE_VALUE, ast_builder: build_mode, help_id: Some("app.mode"), + hint_id: None, usage_ids: &["parse.usage.mode"],}; pub static MESSAGES: CommandNode = CommandNode { @@ -326,6 +347,7 @@ pub static MESSAGES: CommandNode = CommandNode { shape: MESSAGES_VALUE_OPT, ast_builder: build_messages, help_id: Some("app.messages"), + hint_id: None, usage_ids: &["parse.usage.messages"],}; pub static UNDO: CommandNode = CommandNode { @@ -333,6 +355,7 @@ pub static UNDO: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_undo, help_id: Some("app.undo"), + hint_id: None, usage_ids: &["parse.usage.undo"],}; pub static REDO: CommandNode = CommandNode { @@ -340,6 +363,7 @@ pub static REDO: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_redo, help_id: Some("app.redo"), + hint_id: None, usage_ids: &["parse.usage.redo"],}; pub static COPY: CommandNode = CommandNode { @@ -347,4 +371,5 @@ pub static COPY: CommandNode = CommandNode { shape: COPY_VALUE_OPT, ast_builder: build_copy, help_id: Some("app.copy"), + hint_id: None, usage_ids: &["parse.usage.copy"],}; diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index 3f14b93..f76cebe 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -1790,6 +1790,7 @@ pub static SHOW: CommandNode = CommandNode { shape: SHOW_SHAPE, ast_builder: build_show, help_id: Some("data.show"), + hint_id: None, usage_ids: &[ "parse.usage.show_data", "parse.usage.show_table", @@ -1805,6 +1806,7 @@ pub static SEED: CommandNode = CommandNode { shape: SEED_SHAPE, ast_builder: build_seed, help_id: Some("data.seed"), + hint_id: None, usage_ids: &["parse.usage.seed"], }; @@ -1813,6 +1815,7 @@ pub static INSERT: CommandNode = CommandNode { shape: INSERT_SHAPE, ast_builder: build_insert, help_id: Some("data.insert"), + hint_id: None, usage_ids: &["parse.usage.insert"],}; pub static UPDATE: CommandNode = CommandNode { @@ -1820,6 +1823,7 @@ pub static UPDATE: CommandNode = CommandNode { shape: UPDATE_SHAPE, ast_builder: build_update, help_id: Some("data.update"), + hint_id: None, usage_ids: &["parse.usage.update"],}; pub static DELETE: CommandNode = CommandNode { @@ -1827,6 +1831,7 @@ pub static DELETE: CommandNode = CommandNode { shape: DELETE_SHAPE, ast_builder: build_delete, help_id: Some("data.delete"), + hint_id: None, usage_ids: &["parse.usage.delete"],}; pub static REPLAY: CommandNode = CommandNode { @@ -1834,6 +1839,7 @@ pub static REPLAY: CommandNode = CommandNode { shape: REPLAY_PATH, ast_builder: build_replay, help_id: Some("data.replay"), + hint_id: None, usage_ids: &["parse.usage.replay"],}; pub static EXPLAIN: CommandNode = CommandNode { @@ -1841,6 +1847,7 @@ pub static EXPLAIN: CommandNode = CommandNode { shape: EXPLAIN_SHAPE, ast_builder: build_explain, help_id: Some("data.explain"), + hint_id: None, usage_ids: &["parse.usage.explain"],}; /// `explain` over advanced-mode SQL (ADR-0039). @@ -1860,6 +1867,7 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode { // too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE` // precedent; otherwise `note_help` would print `explain` twice. help_id: None, + hint_id: None, usage_ids: &[],}; /// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032). @@ -1875,6 +1883,7 @@ pub static SELECT: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL), ast_builder: build_select, help_id: None, + hint_id: None, usage_ids: &["parse.usage.select"],}; /// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c). @@ -1889,6 +1898,7 @@ pub static WITH: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL), ast_builder: build_select, help_id: None, + hint_id: None, usage_ids: &["parse.usage.with"],}; /// SQL `INSERT` — the `Advanced`-category node of the shared @@ -1906,6 +1916,7 @@ pub static SQL_INSERT: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE), ast_builder: build_sql_insert, help_id: None, + hint_id: None, usage_ids: &[], }; @@ -1919,6 +1930,7 @@ pub static SQL_UPDATE: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE), ast_builder: build_sql_update, help_id: None, + hint_id: None, usage_ids: &[], }; @@ -1934,6 +1946,7 @@ pub static SQL_DELETE: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE), ast_builder: build_sql_delete, help_id: None, + hint_id: None, usage_ids: &[], }; diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index 0167093..e6189d9 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -968,6 +968,7 @@ pub static DROP: CommandNode = CommandNode { shape: DROP_SHAPE, ast_builder: build_drop, help_id: Some("ddl.drop"), + hint_id: None, usage_ids: &[ "parse.usage.drop_table", "parse.usage.drop_column", @@ -981,6 +982,7 @@ pub static ADD: CommandNode = CommandNode { shape: ADD_SHAPE, ast_builder: build_add, help_id: Some("ddl.add"), + hint_id: None, usage_ids: &[ "parse.usage.add_column", "parse.usage.add_relationship", @@ -993,6 +995,7 @@ pub static RENAME: CommandNode = CommandNode { shape: RENAME_COLUMN, ast_builder: build_rename_column, help_id: Some("ddl.rename"), + hint_id: None, usage_ids: &["parse.usage.rename_column"],}; pub static CHANGE: CommandNode = CommandNode { @@ -1000,6 +1003,7 @@ pub static CHANGE: CommandNode = CommandNode { shape: CHANGE_COLUMN, ast_builder: build_change_column, help_id: Some("ddl.change"), + hint_id: None, usage_ids: &["parse.usage.change_column"],}; // ================================================================= @@ -1360,6 +1364,7 @@ pub static CREATE: CommandNode = CommandNode { shape: CREATE_TABLE, ast_builder: build_create_table, help_id: Some("ddl.create"), + hint_id: None, usage_ids: &["parse.usage.create_table"],}; // ================================================================= @@ -1428,6 +1433,7 @@ pub static CREATE_M2N: CommandNode = CommandNode { shape: CREATE_M2N_SHAPE, ast_builder: build_create_m2n, help_id: Some("ddl.create_m2n"), + hint_id: None, usage_ids: &["parse.usage.create_m2n"], }; @@ -1858,6 +1864,7 @@ pub static SQL_CREATE_TABLE: CommandNode = CommandNode { shape: Node::Subgrammar(&super::sql_create_table::SQL_CREATE_TABLE_SHAPE), ast_builder: build_sql_create_table, help_id: Some("ddl.sql_create_table"), + hint_id: None, usage_ids: &["parse.usage.sql_create_table"], }; @@ -1877,6 +1884,7 @@ pub static SQL_DROP_TABLE: CommandNode = CommandNode { shape: SQL_DROP_TABLE_SHAPE, ast_builder: build_sql_drop_table, help_id: Some("ddl.sql_drop_table"), + hint_id: None, usage_ids: &["parse.usage.sql_drop_table"], }; @@ -1896,6 +1904,7 @@ pub static SQL_DROP_INDEX: CommandNode = CommandNode { shape: SQL_DROP_INDEX_SHAPE, ast_builder: build_sql_drop_index, help_id: Some("ddl.sql_drop_index"), + hint_id: None, usage_ids: &["parse.usage.sql_drop_index"], }; @@ -1977,6 +1986,7 @@ pub static SQL_CREATE_INDEX: CommandNode = CommandNode { shape: SQL_CREATE_INDEX_SHAPE, ast_builder: build_sql_create_index, help_id: Some("ddl.sql_create_index"), + hint_id: None, usage_ids: &["parse.usage.sql_create_index"], }; @@ -2535,6 +2545,7 @@ pub static SQL_ALTER_TABLE: CommandNode = CommandNode { shape: SQL_ALTER_TABLE_SHAPE, ast_builder: build_sql_alter_table, help_id: Some("ddl.sql_alter_table"), + hint_id: None, usage_ids: &["parse.usage.sql_alter_table"], }; diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index f06cf3f..772d2b1 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -530,6 +530,15 @@ pub struct CommandNode { /// so a newly-registered command appears in `help` /// automatically (ADR-0024 §help_id). pub help_id: Option<&'static str>, + /// Catalog key stem (`hint.cmd.`) for this command form's + /// **tier-3** contextual hint (ADR-0053 / H2). Unlike `help_id` + /// — which is `None` on advanced-SQL forms purely to dedup the + /// `help` list — `hint_id` is 1:1 with command *forms*, so each + /// advanced-SQL form carries its own id and renders SQL-syntax + /// content distinct from its simple-DSL sibling. `None` until a + /// form's tier-3 block is authored (the surface falls back to + /// tier-2 ambient/error text). + pub hint_id: Option<&'static str>, /// Catalog keys under `parse.usage.*` to render in the /// "usage:" block when a parse error fires for this command /// (ADR-0021 §1, ADR-0024 §architecture). Multi-form families @@ -574,32 +583,69 @@ pub fn usage_keys_for_input_in_mode( source: &str, mode: crate::mode::Mode, ) -> Option<(&'static str, Vec<&'static str>)> { + let pick = selected_nodes_for_input_in_mode(source, mode); + if pick.is_empty() { + return None; + } + let mut keys: Vec<&'static str> = Vec::new(); + for (_, node, _) in &pick { + for k in node.usage_ids { + if !keys.contains(k) { + keys.push(*k); + } + } + } + if keys.is_empty() { + return None; + } + let entry = pick[0].1.entry.primary; + Some((entry, keys)) +} + +/// The tier-3 `hint_id` of the command form `source` is currently +/// typing, in `mode` (H2 / ADR-0053). +/// +/// Reuses the same mode-aware +/// selection as [`usage_keys_for_input_in_mode`] and returns the +/// **mode-primary** node's `hint_id` — so an advanced-SQL form +/// resolves to its *own* id, not its simple-DSL sibling's. `None` if +/// no entry word matches, or the chosen form has no tier-3 block yet +/// (the caller then falls back to tier-2 ambient text). +#[must_use] +pub fn hint_id_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> { + selected_nodes_for_input_in_mode(source, mode) + .first() + .and_then(|(_, node, _)| node.hint_id) +} + +/// Shared mode-aware command-form selection for the entry word at the +/// start of `source`. +/// +/// Extracted so the usage-key and hint-id lookups agree on which form +/// the user is typing. +/// +/// Advanced mode: every candidate form is reachable — the SQL nodes +/// are primary, and the DSL nodes remain valid via fallback (verified: +/// `create table … with pk` and `drop column …` both run in advanced +/// mode). Mode-primary (Advanced) first, so a hint never hides input +/// that works. Simple mode: only the DSL forms — the SQL-only forms +/// hit the "this is SQL" rail and are not reachable. (ADR-0042 G3.) +/// Degenerate guard: an advanced-only word in simple mode leaves the +/// selection empty; fall back to all candidates. +fn selected_nodes_for_input_in_mode( + source: &str, + mode: crate::mode::Mode, +) -> Vec<(usize, &'static CommandNode, CommandCategory)> { use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace}; let start = skip_whitespace(source, 0); - let (kw_start, kw_end) = consume_ident(source, start)?; + let Some((kw_start, kw_end)) = consume_ident(source, start) else { + return Vec::new(); + }; let word = &source[kw_start..kw_end]; let candidates = commands_for_entry_word(word); if candidates.is_empty() { - return None; + return Vec::new(); } - let union = |nodes: &[(usize, &'static CommandNode, CommandCategory)]| -> Vec<&'static str> { - let mut keys: Vec<&'static str> = Vec::new(); - for (_, node, _) in nodes { - for k in node.usage_ids { - if !keys.contains(k) { - keys.push(*k); - } - } - } - keys - }; - // Advanced mode: every candidate form is reachable — the SQL - // nodes are primary, and the DSL nodes remain valid via fallback - // (verified: `create table … with pk` and `drop column …` both - // run in advanced mode). Show them all, mode-primary (Advanced) - // first, so the usage hint never hides input that works. Simple - // mode: only the DSL forms — the SQL-only forms hit the "this is - // SQL" rail and are not reachable. (ADR-0042 G3.) let selected: Vec<(usize, &'static CommandNode, CommandCategory)> = if mode == crate::mode::Mode::Advanced { let mut v: Vec<_> = candidates @@ -621,17 +667,7 @@ pub fn usage_keys_for_input_in_mode( .filter(|(_, _, c)| *c == CommandCategory::Simple) .collect() }; - // Degenerate guard: an advanced-only word in simple mode (not - // normally reachable — it hits the SQL rail first) leaves - // `selected` empty; fall back to all candidates so a usage block - // still renders rather than the available-commands fallback. - let pick = if selected.is_empty() { candidates } else { selected }; - let keys = union(&pick); - if keys.is_empty() { - return None; - } - let entry = pick[0].1.entry.primary; - Some((entry, keys)) + if selected.is_empty() { candidates } else { selected } } /// The single usage template most relevant to `source`, when @@ -712,6 +748,7 @@ pub fn entry_words_alphabetised() -> Vec<&'static str> { pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[ (&app::QUIT, CommandCategory::Simple), (&app::HELP, CommandCategory::Simple), + (&app::HINT, CommandCategory::Simple), (&app::REBUILD, CommandCategory::Simple), (&app::SAVE, CommandCategory::Simple), (&app::NEW, CommandCategory::Simple), diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index 575ec48..2ee5f17 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -6910,6 +6910,7 @@ mod dispatch_3a_tests { shape: Node::Word(Word::keyword("dsltail")), ast_builder: dsl_builder, help_id: None, + hint_id: None, usage_ids: &[], }; static SMOKE_SQL: CommandNode = CommandNode { @@ -6917,6 +6918,7 @@ mod dispatch_3a_tests { shape: Node::Word(Word::keyword("sqltail")), ast_builder: sql_builder, help_id: None, + hint_id: None, usage_ids: &[], }; diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index a6c6ae8..e8ff14d 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -180,6 +180,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("help.unknown_topic", &["topic"]), ("help.app.quit", &[]), ("help.app.help", &[]), + ("help.app.hint", &[]), ("help.app.rebuild", &[]), ("help.app.save", &[]), ("help.app.new", &[]), @@ -222,6 +223,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ &["message", "usage"], ), ("hint.ambient_expected", &["expected"]), + ("hint.getting_started", &[]), ( "hint.ambient_invalid_ident", &["kind", "found"], @@ -299,6 +301,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("parse.usage.rename_column", &[]), ("parse.usage.export", &[]), ("parse.usage.help", &[]), + ("parse.usage.hint", &[]), ("parse.usage.import", &[]), ("parse.usage.copy", &[]), ("parse.usage.load", &[]), diff --git a/src/friendly/mod.rs b/src/friendly/mod.rs index 06a6f0f..4c571f6 100644 --- a/src/friendly/mod.rs +++ b/src/friendly/mod.rs @@ -35,7 +35,7 @@ pub mod translate; pub use error::{DiagnosticTable, FriendlyError}; pub use format::{catalog, Catalog}; -pub use translate::{FailureContext, Operation, TranslateContext, Verbosity}; +pub use translate::{error_hint_class, FailureContext, Operation, TranslateContext, Verbosity}; // `translate::translate` and `format::translate` are different // callables — the former is the structured DbError → FriendlyError diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index c8e45f1..d9581e8 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -256,6 +256,8 @@ help: help: |- help — show this command list help — detailed help for one command (e.g. `help insert`) + hint: |- + hint — explain the most recent error (press F1 for a hint on what you're typing) rebuild: |- rebuild — rebuild the project database from project.yaml + data/ (with confirmation) save: |- @@ -386,6 +388,9 @@ hint: ambient_complete: "Submit with Enter" ambient_expected: "Next: {expected}" ambient_error_with_usage: "{message} — usage: {usage}" + # H2 / ADR-0053: shown by `hint` / F1 when there is nothing specific + # to expand on (no recent error, empty input). + getting_started: "Start typing a command and press F1 for a hint, or type `help` for the full command list." # Invalid identifier in a schema slot (ADR-0022 stage 8e # + the user's #5). Voice mirrors ADR-0019's "no such # {kind}" wording for consistency with engine errors. @@ -617,6 +622,7 @@ parse: # description. quit: "quit" help: "help []" + hint: "hint" rebuild: "rebuild" save: "save | save as" new: "new" diff --git a/src/friendly/translate.rs b/src/friendly/translate.rs index 74cdb00..aecf487 100644 --- a/src/friendly/translate.rs +++ b/src/friendly/translate.rs @@ -253,6 +253,73 @@ pub fn translate(error: &DbError, ctx: &TranslateContext) -> FriendlyError { fe } +/// The tier-3 hint class (`hint.err.`) for an error. +/// +/// The same classification [`translate`] performs, surfaced as a +/// stable key for the contextual `hint` (H2 / ADR-0053 D5). Returns +/// `None` for internal / fatal errors that carry no learner-facing +/// hint (persistence, IO, worker-gone). +/// +/// **Keep in sync with [`translate`] / `translate_sqlite` / +/// `translate_constraint` / `translate_foreign_key`** — the unit tests +/// below pin each class. +#[must_use] +pub fn error_hint_class(error: &DbError, ctx: &TranslateContext) -> Option<&'static str> { + match error { + DbError::Sqlite { message, kind } => sqlite_hint_class(message, *kind, ctx), + DbError::Unsupported(_) | DbError::InvalidValue(_) => Some("invalid_value"), + DbError::PersistenceFatal { .. } + | DbError::RebuildRowFailed { .. } + | DbError::Io(_) + | DbError::WorkerGone => None, + } +} + +fn sqlite_hint_class( + message: &str, + kind: SqliteErrorKind, + ctx: &TranslateContext, +) -> Option<&'static str> { + if matches!(ctx.operation, Some(Operation::ChangeColumnType)) { + return Some("type_mismatch"); + } + Some(match kind { + SqliteErrorKind::NoSuchTable | SqliteErrorKind::NoSuchColumn => "not_found", + SqliteErrorKind::AlreadyExists => "already_exists", + SqliteErrorKind::UniqueViolation => constraint_hint_class(message, ctx), + SqliteErrorKind::Other => "generic", + }) +} + +fn constraint_hint_class(message: &str, ctx: &TranslateContext) -> &'static str { + let lower = message.to_ascii_lowercase(); + if lower.contains("unique constraint failed") { + "unique" + } else if lower.contains("foreign key constraint failed") { + fk_hint_class(ctx) + } else if lower.contains("not null constraint failed") { + "not_null" + } else if lower.contains("check constraint failed") { + "check" + } else { + "generic" + } +} + +const fn fk_hint_class(ctx: &TranslateContext) -> &'static str { + // Mirrors `translate_foreign_key`'s side disambiguation. + if ctx.parent_table.is_some() { + return "foreign_key.child_side"; + } + if ctx.child_table.is_some() { + return "foreign_key.parent_side"; + } + match ctx.operation { + Some(Operation::Delete) => "foreign_key.parent_side", + _ => "foreign_key.child_side", + } +} + fn translate_sqlite( message: &str, kind: SqliteErrorKind, @@ -798,6 +865,92 @@ mod tests { } } + // ── H2 / ADR-0053: error → tier-3 hint class ──────────────── + + #[test] + fn hint_class_maps_runtime_error_kinds() { + use crate::db::{DbError, SqliteErrorKind}; + let sqlite = |kind, msg: &str| DbError::Sqlite { + message: msg.to_string(), + kind, + }; + let d = TranslateContext::default; + assert_eq!( + error_hint_class(&sqlite(SqliteErrorKind::NoSuchTable, "no such table: X"), &d()), + Some("not_found") + ); + assert_eq!( + error_hint_class(&sqlite(SqliteErrorKind::NoSuchColumn, "no such column: X"), &d()), + Some("not_found") + ); + assert_eq!( + error_hint_class(&sqlite(SqliteErrorKind::AlreadyExists, "already exists"), &d()), + Some("already_exists") + ); + assert_eq!( + error_hint_class(&sqlite(SqliteErrorKind::Other, "boom"), &d()), + Some("generic") + ); + // Constraint-violation message splitting. + let cv = |msg: &str| sqlite(SqliteErrorKind::UniqueViolation, msg); + assert_eq!( + error_hint_class(&cv("UNIQUE constraint failed: T.c"), &d()), + Some("unique") + ); + assert_eq!( + error_hint_class(&cv("NOT NULL constraint failed: T.c"), &d()), + Some("not_null") + ); + assert_eq!( + error_hint_class(&cv("CHECK constraint failed: T"), &d()), + Some("check") + ); + // change-column op routes any engine error to type_mismatch. + assert_eq!( + error_hint_class( + &sqlite(SqliteErrorKind::Other, "x"), + &ctx_with(Operation::ChangeColumnType) + ), + Some("type_mismatch") + ); + // App-level refusals and internal/fatal errors. + assert_eq!( + error_hint_class(&DbError::InvalidValue("bad".to_string()), &d()), + Some("invalid_value") + ); + assert_eq!(error_hint_class(&DbError::WorkerGone, &d()), None); + } + + #[test] + fn hint_class_resolves_foreign_key_sides() { + use crate::db::{DbError, SqliteErrorKind}; + let fk = || DbError::Sqlite { + message: "FOREIGN KEY constraint failed".to_string(), + kind: SqliteErrorKind::UniqueViolation, + }; + // Enrichment: parent_table populated → child-side. + let ctx = TranslateContext { + parent_table: Some("Parent".to_string()), + ..TranslateContext::default() + }; + assert_eq!(error_hint_class(&fk(), &ctx), Some("foreign_key.child_side")); + // child_table populated → parent-side. + let ctx = TranslateContext { + child_table: Some("Child".to_string()), + ..TranslateContext::default() + }; + assert_eq!(error_hint_class(&fk(), &ctx), Some("foreign_key.parent_side")); + // No enrichment: operation is the tiebreaker. + assert_eq!( + error_hint_class(&fk(), &ctx_with(Operation::Delete)), + Some("foreign_key.parent_side") + ); + assert_eq!( + error_hint_class(&fk(), &ctx_with(Operation::Insert)), + Some("foreign_key.child_side") + ); + } + fn sqlite(message: &str, kind: SqliteErrorKind) -> DbError { DbError::Sqlite { message: message.to_string(), diff --git a/tests/typing_surface/mod.rs b/tests/typing_surface/mod.rs index 53bef3b..31e7229 100644 --- a/tests/typing_surface/mod.rs +++ b/tests/typing_surface/mod.rs @@ -250,6 +250,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String { App(app) => match app { AppCommand::Quit => "App(Quit)".into(), AppCommand::Help { .. } => "App(Help)".into(), + AppCommand::Hint => "App(Hint)".into(), AppCommand::Rebuild => "App(Rebuild)".into(), AppCommand::Save => "App(Save)".into(), AppCommand::SaveAs => "App(SaveAs)".into(), From 4a5fd1b5c19a338beb8c05d2184589030c4f9368 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Mon, 15 Jun 2026 12:18:41 +0000 Subject: [PATCH 37/50] =?UTF-8?q?feat(hint):=20H2=20Phase=20B=20=E2=80=94?= =?UTF-8?q?=20per-form=20keying=20+=20the=20three=20exemplars=20(ADR-0053)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first exemplar (`add 1:n relationship`) showed per-node keying is too coarse for multi-form commands, so revise the mechanism to per-form. - CommandNode `hint_id: Option<&str>` -> `hint_ids: &[&str]` (mirrors usage_ids); hint_key_for_input_in_mode reuses a factored-out pick_form_key (shared digit/m:n/suffix form disambiguation with usage_key_for_input_in_mode) - wire INSERT + ADD (all four forms) with hint_ids - author the three approved exemplars: hint.cmd.insert, hint.cmd.add_relationship, hint.err.foreign_key.child_side (what/example/concept) + keys.rs registration - revise ADR-0053 D3 to per-form; record clause-concept hints as a deferred extension (issue #37); update README + plan - +5 tests; 2488 pass / 1 ignored, clippy clean --- ...-contextual-hint-command-and-keybinding.md | 84 +++++++++----- docs/adr/README.md | 2 +- .../20260614-adr-0053-contextual-hint-H2.md | 34 ++++-- src/app.rs | 51 +++++++- src/dsl/grammar/app.rs | 28 ++--- src/dsl/grammar/data.rs | 27 ++--- src/dsl/grammar/ddl.rs | 31 +++-- src/dsl/grammar/mod.rs | 109 +++++++++++++----- src/dsl/walker/mod.rs | 4 +- src/friendly/keys.rs | 10 ++ src/friendly/strings/en-US.yaml | 21 ++++ 11 files changed, 292 insertions(+), 109 deletions(-) diff --git a/docs/adr/0053-contextual-hint-command-and-keybinding.md b/docs/adr/0053-contextual-hint-command-and-keybinding.md index 34a9197..7bac135 100644 --- a/docs/adr/0053-contextual-hint-command-and-keybinding.md +++ b/docs/adr/0053-contextual-hint-command-and-keybinding.md @@ -2,14 +2,18 @@ ## Status -Accepted — implementation pending. Revised after a `/runda` review +Accepted — implementation in progress. Revised after a `/runda` review (2026-06-14): corrected the verbosity-default fact; re-keyed tier-3 -content on a new `hint_id` (not `help_id`) so every command form — simple -and advanced-SQL — gets distinct, mode-correct content; split the -pre-submit-diagnostic and runtime-error paths; added a comprehensiveness -coverage test. The parallel question of whether the in-app `help` command -should likewise distinguish advanced-SQL forms is tracked **separately** -as Gitea issue #36 (it touches shipped, ADR-backed `help` behaviour). +content off `help_id`; split the pre-submit-diagnostic and runtime-error +paths; added a comprehensiveness coverage test. Revised again during +Phase B implementation (2026-06-15): the first exemplar showed per-*node* +keying is too coarse for multi-form commands (`add`/`drop`/`show`/ +`create`), so D3 now keys tier-3 content **per form** via a +`hint_ids: &[&str]` array mirroring `usage_ids` — and **clause-concept +hints** are recorded as a deferred extension (separate tracking issue). +The parallel question of whether the in-app `help` command should +likewise distinguish advanced-SQL forms is tracked **separately** as +Gitea issue #36 (it touches shipped, ADR-backed `help` behaviour). Decided in conversation 2026-06-14. Closes the last open piece of **A1** (the canonical app-command set, ADR-0003): every app command is @@ -132,22 +136,42 @@ top-level namespace (where tier-2 ambient strings already live), in two new sub-namespaces: - **`hint.cmd.`** — one per command **form**, keyed by a **new - `hint_id: Option<&'static str>`** field added to `CommandNode` - (`src/dsl/grammar/mod.rs:512`, parallel to the existing `help_id` / - `usage_ids`). The F1 live-input path resolves the current input to its - command node and looks up `hint.cmd.`. + `hint_ids: &'static [&'static str]`** field on `CommandNode` + (`src/dsl/grammar/mod.rs:512`), **mirroring the existing `usage_ids`**. + The F1 live-input path resolves the current input to its form's hint key + via `hint_key_for_input_in_mode`, which reuses the same form-word + disambiguation as `usage_key_for_input_in_mode`. - **Why a new field, not `help_id`:** `help_id` is **not** 1:1 with - command forms. The 7 advanced-mode SQL nodes (`SELECT`, `WITH`, - `SQL_INSERT/UPDATE/DELETE`, `EXPLAIN_SQL`) carry `help_id: None` *purely - to dedup the `help` command's printed list* (they share an entry word - with a simple sibling — see `grammar/mod.rs:915-918`), not because they - lack distinct content. Their SQL syntax differs from the simple-DSL - sibling's, so they **must get their own tier-3 block**. A dedicated - `hint_id` gives every one of the ~37 REGISTRY nodes — simple and - advanced-SQL alike — its own key and its own mode-correct example, with - no sharing or deferral. (The analogous gap in the `help` command is out - of scope here — issue #36.) + **Why an array mirroring `usage_ids`, not a per-node `hint_id`** + *(`/runda`/implementation revision, 2026-06-15)*: a single per-node key + is too coarse. Several entry words are **one node spanning many forms** — + `add` (column/relationship/index/constraint), `drop` (table/column/ + relationship/index), `show` (data/table/tables/relationships/indexes), + `create` (table/index). A live-input hint for `add 1:n relationship` is + only useful if it is *specific to relationships*, so the content must be + **per form**, not per node. The project already solved exactly this for + usage templates (`usage_ids` is a per-form array, disambiguated by the + form word), so `hint_ids` mirrors it. Single-form nodes carry one entry; + multi-form nodes carry one per form. This also covers the advanced-SQL + forms whose `usage_ids` are empty (`SQL_INSERT/UPDATE/DELETE`, + `EXPLAIN_SQL`) — they get their own `hint_ids` directly, independent of + usage, with mode-correct SQL examples. (The `help`-list collapse of + advanced-SQL forms is a separate gap — issue #36.) + + **Deferred extension — clause-concept hints** (issue #37): per-form is + the right granularity for tier-3 *teaching* (position-awareness within a + form is owned by tier-2 ambient + the live `Next:` line, D4). But some + **concepts live inside a clause**, not a form — `… on delete ⟨cascade| + set null|restrict⟩` (referential actions), the `create table` constraint + slots (`primary`/`unique`/`check`/`foreign`), `with pk`, `1:n`/`m:n` + cardinality. A learner parked in such a clause may want teaching deeper + than tier-2's candidate list but narrower than the whole-form block. v1 + does **not** build this (it would multiply content for points whose value + we can't yet measure, and we don't expect to accumulate usage statistics + to drive it empirically — it will be tackled as a deliberate follow-up + job). The keying does not lock it out: a later `hint.concept.` + namespace can be surfaced when the cursor sits in a recognized clause, + layered on top of the per-form block. - **`hint.err.`** — one per error/diagnostic class, keyed by the friendly error/diagnostic key (e.g. `hint.err.foreign_key.child_side`, `hint.err.type_mismatch`, `hint.err.insert_arity_mismatch`). Used by @@ -309,10 +333,12 @@ Hint — add relationship the ambient one-liner, and the verbose error hint — without cluttering those terse defaults. - **One new keybinding (F1)** joins the keymap and the ADR-0051 strip. -- **A new `hint_id` field on `CommandNode`** (parallel to `help_id`), one - new field of `App` state (`last_error_hint_key`), and one new renderer - family (`note_hint*`); the `AppCommand` enum gains `Hint`, the grammar a - `HINT` node, the REGISTRY one entry. +- **A new `hint_ids: &[&str]` field on `CommandNode`** (mirroring + `usage_ids`) + a `hint_key_for_input_in_mode` lookup (reusing the + `usage_key_for_input_in_mode` form-disambiguation), one new field of + `App` state (`last_error_hint_key`), and one new renderer family + (`note_hint*`); the `AppCommand` enum gains `Hint`, the grammar a `HINT` + node, the REGISTRY one entry. - **A large, durable content corpus** (~37 command blocks + ~42 error/ diagnostic blocks ≈ 80) enters the catalogue under `hint.cmd.*` / `hint.err.*`, validated by `keys.rs`. This is ongoing surface area: new @@ -349,6 +375,12 @@ Hint — add relationship created a table — here's what an index would add") — OOS (deferred): a plausible future tier-3 use, but v1 scopes the command path to errors and the F1 path to in-progress input. +- **Clause-concept hints** (`… on delete ⟨action⟩`, constraint slots, + `with pk`, cardinality) — OOS (deferred, issue #37): a + `hint.concept.` layer surfaced when the cursor sits in a + recognized clause, deeper than tier-2's candidate list but narrower than + the per-form block. Per-form keying (D3) does not lock it out. To be + tackled as a deliberate follow-up job, not gated on usage statistics. ## Content inventory (implementation tracking) diff --git a/docs/adr/README.md b/docs/adr/README.md index 594a97b..aaa1e8a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -58,4 +58,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0050 — Incidental-DDL confirmations omit relationship info (structure-only)](0050-incidental-ddl-confirmations-omit-relationships.md) — **Accepted + implemented 2026-06-12 (issue #28)**, closes Gitea **#28**. **Supersedes** the incidental-DDL clause of **ADR-0044 §1** and the relationship-block half of **ADR-0016 §5**. Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/`rename`/`change column`, `add`/`drop index`) now render **structure only** — header + column box + `Indexes:` + constraints — with **no `References:` / `Referenced by:` block** (neither prose nor diagram), even when the table carries relationships the user did not touch. Rationale (owner): a confirmation echo reports the change just made, not untouched relationships; ADR-0044's terse prose was the lesser of "prose vs diagram", but the right answer for these surfaces is **neither**. **Relationship-subject surfaces are unchanged** — `show table`, `add`/`drop relationship`, `show relationship` still render ADR-0044 diagrams; relationships appear only when the user asks for (`show table`) or acts on (`add`/`drop relationship`) one, and are one `show table ` away — **no information lost**. Forks both user-chosen: **scope = all incidental DDL** (not just `add column` — the rationale is uniform, the mental model clean, and it's the simpler edit) and **delete the prose renderer** (not retain it dormant — no dead code). **Mechanism:** the `handle_dsl_success` `matches!` routing is unchanged (relationship-subject → diagrams; else → `render_structure`); the change is one line inside `render_structure` (`output_render.rs` — drop the relationship-block call) since all its callers are incidental DDL, plus deletion of the orphaned `relationship_prose_lines` + `cols_disp` helpers. The prose format survives in ADR-0016 §5 + git history for a future OOS-7 always-prose setting. **Tests:** the prose-presence unit test + its snapshot removed; a new unit test asserts `render_structure` on a description carrying **both** inbound and outbound relationships emits the box but no prose; the misnamed `add_relationship_flow_shows_inbound_section_on_parent` integration test (which sent an `AddColumn`) inverted + renamed to assert the add-column echo omits the prose; the diagram tests (`show table`, `add relationship`) unaffected. **2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. `requirements.md` unaffected (ADR-tracked refinement of a decided area, like ADR-0044 itself) - [ADR-0051 — Bottom keybinding strip: context- and state-aware](0051-context-state-aware-keybinding-strip.md) — **Accepted + implemented 2026-06-13 (issue #27)**, closes Gitea **#27**. Repurposes the bottom status line into a **keystrokes-only, state-selected** strip (builds on ADR-0046 nav focus, ADR-0003 modes, ADR-0049 the #29 readline keys it now advertises, ADR-0022 the completion memo). A pure `status_bar_bindings(app) -> Vec<(key,label)>` chooses the strip by **priority, first match wins**: (1) **sidebar focus** → `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input`; (2) **completion memo live** (`last_completion`) → `Tab/Shift-Tab cycle · Esc cancel · Enter run`; (3) **history navigation** (new `App::is_browsing_history()` exposing the private `history_cursor`) → `↑↓ browse · Esc clear · Enter run`; (4) **editing** (input non-empty) → `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` (surfaces the #29 keys, closing ADR-0049's deferred advertisement); (5) **default** (empty) → `Ctrl-O sidebar · Tab complete · ↑ history · Enter run`. Priority is correct because Up clears the completion memo and Tab cancels history nav, so states 2/3 never co-occur, and the five are exhaustive for Input focus. **Typed-command words leave the strip** (`mode advanced`/`mode simple` switch, `:` one-shot) and **mode discovery moves to the empty-input hint** (`resolve_hint_lines`), **simple mode only**: `\`mode advanced\` for SQL` (the verb "type" omitted — the prompt implies it; advanced mode shows **no** pointer per a post-trial user decision — a switcher knows how they got there and `help` covers the way back). The one-shot's old `Backspace cancel one-shot` label is subsumed by the editing state (behaviour intact). Forks all user-chosen: **editing state shows the #29 keys** (vs unadvertised); **`Ctrl-C quit` omitted** from the strip (vs always shown); **no width-drop machinery** — the longest strip (~65 cols) fits all supported widths, so a **width-budget unit test** keeps it lean by construction instead (the user's own observation). Catalog: 12 new `shortcut.*` labels + the `panel.hint_mode_advanced` string added to `en-US.yaml`+`keys.rs` (validator-checked 1:1), 5 now-dead strip strings removed. **Modal-aware strip is OOS** (pre-existing: a modal owns the keyboard and carries its own hints; the strip under it is unchanged-in-kind, not worsened). Tests: 9 Tier-1 unit (per-state key sets — completion/history driven through real key events; width budget; mode-pointer presence/absence), 1 Tier-3 rewritten (`status_bar_is_keystroke_only_and_state_aware`), 15 full-panel snapshots re-accepted (reviewed — strip/hint only). **2467 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: modal-aware strip; a full-key cheatsheet overlay; Ctrl-K/U advertisement (editing strip shows the highest-value subset within the width budget) - [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (status field gains a `:adv` tag; **journaling moves from the worker to the dispatch layer**), **ADR-0015 §5/§6** (history.log leaves the worker transaction — `commit-db-last` now scopes yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure is best-effort, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression). **Follow-up done 2026-06-14:** the vestigial worker `source` plumbing was fully unwound (compiler-guided, no behaviour change) — `_source` removed from `finalize_persistence`/`do_rebuild_from_text`, the three `*_request` wrappers inlined+deleted, the dead `source` param dropped from the ~30 forwarding worker handlers, and the `source` field removed from the `DescribeTable`/`QueryData`/`RunSelect` requests + their `DatabaseHandle` methods (~164 mostly-test call sites); the only worker `source` left is the snapshot/undo label (see ADR-0052 *Consequences*) -- [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implementation pending (2026-06-14)**. Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help ` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.` (per command form) and `hint.err.` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed on a new `hint_id` field on `CommandNode`, not `help_id`** (`/runda` correction): `help_id` is not 1:1 with command forms — the 7 advanced-mode SQL nodes carry `help_id: None` purely to dedup the `help` *list*, so they'd be unkeyable and would wrongly share their simple sibling's content despite different syntax; a dedicated `hint_id` gives every one of the ~37 REGISTRY nodes its own mode-correct block (the parallel `help`-side gap is tracked as issue **#36**). Two error routes share `hint.err.*`: pre-submit `diagnostic.*` read live from the walker (F1 path), runtime `translate_error` classes via stored `last_error_hint_key` (`hint` command / empty-F1). Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_id` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): **comprehensive for v1** — ~37 command forms + 9 runtime error classes + ~33 `diagnostic.*` classes ≈ 80 teaching blocks — authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test** (every node/error class has a key), with graceful fall-back to tier-2 if a key is ever missing. Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive scope; exemplars-first. OOS: per-topic `hint ` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); the `help`-side advanced-SQL gap (issue #36) +- [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implementation in progress (2026-06-14; Phase A done, Phase B underway)**. Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help ` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.` (per command form) and `hint.err.` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed per-form via a new `hint_ids: &[&str]` field on `CommandNode` mirroring `usage_ids`** (revised in Phase B): a per-*node* key proved too coarse — `add`/`drop`/`show`/`create` are each one node spanning many forms, and a live-input hint for `add 1:n relationship` must be specific to relationships; `hint_key_for_input_in_mode` reuses `usage_key_for_input_in_mode`'s form-word disambiguation, and covers the advanced-SQL forms whose `usage_ids` are empty. Not keyed off `help_id` (it is `None` on the advanced-SQL nodes purely to dedup the `help` list; that parallel gap is issue **#36**). **Clause-concept hints** (`on delete` actions, constraint slots, `with pk`, cardinality) are a recorded **deferred extension** (`hint.concept.`, issue **#37**) — per-form is the right tier-3 granularity, with position-awareness owned by tier-2 + the live `Next:` line. Two error routes share `hint.err.*`: pre-submit `diagnostic.*` read live from the walker (F1 path), runtime `translate_error` classes via stored `last_error_hint_key` (`hint` command / empty-F1). Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_ids` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): **comprehensive for v1** — ~37 command forms + 9 runtime error classes + ~33 `diagnostic.*` classes ≈ 80 teaching blocks — authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test** (every node/error class has a key), with graceful fall-back to tier-2 if a key is ever missing. Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive scope; exemplars-first. OOS: per-topic `hint ` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); the `help`-side advanced-SQL gap (issue #36) diff --git a/docs/plans/20260614-adr-0053-contextual-hint-H2.md b/docs/plans/20260614-adr-0053-contextual-hint-H2.md index 0992a1e..00391ae 100644 --- a/docs/plans/20260614-adr-0053-contextual-hint-H2.md +++ b/docs/plans/20260614-adr-0053-contextual-hint-H2.md @@ -39,19 +39,29 @@ Build order: **Phase A** (mechanism skeleton, falls back to tier-2) → **Phase C** (comprehensive content, batched) → **Phase D** (polish: strip advertisement, snapshots, full green). -## 3. Grammar: the `hint_id` field + the `HINT` node +## 3. Grammar: the `hint_ids` field + the `HINT` node -### 3a. New `CommandNode.hint_id` -- Add `pub hint_id: Option<&'static str>` to `CommandNode` - (`src/dsl/grammar/mod.rs:512`, beside `help_id` / `usage_ids`), with a - doc comment mirroring `help_id`'s. Compiler will force every node - literal (~37, across `grammar/app.rs`, `data.rs`, `ddl.rs`) to set it — - in Phase A set them all to `None` (everything falls back to tier-2); - fill them in Phase C. -- **Why `hint_id` not `help_id`** (ADR-0053 D3): `help_id` is `None` on the - 7 advanced-SQL forms purely to dedup the `help` *list*; those forms have - distinct SQL syntax and need their own block. `hint_id` is 1:1 with - forms. +### 3a. New `CommandNode.hint_ids` (per-form — revised in Phase B) +- Add `pub hint_ids: &'static [&'static str]` to `CommandNode` + (`src/dsl/grammar/mod.rs:512`, beside `help_id` / `usage_ids`), + **mirroring `usage_ids`** — *not* a per-node `Option<&str>`. The Phase-B + exemplar (`add 1:n relationship`) showed per-*node* keying is too coarse: + `add`/`drop`/`show`/`create` are each one node spanning many forms, and + a live-input hint must be specific to the typed form. Compiler forces + every node literal (~37, across `grammar/app.rs`, `data.rs`, `ddl.rs`) to + set it — Phase A/B leave most `&[]` (tier-2 fallback); Phase C fills them. + **Multi-form nodes list ALL their form keys** (e.g. `add` → + `["add_column", "add_relationship", "add_index", "add_constraint"]`) so + the form-word disambiguation resolves correctly and unauthored forms fall + back at render rather than mis-resolving to a sibling. +- **Lookup:** `hint_key_for_input_in_mode(source, mode)` returns the single + typed form's hint stem, reusing `pick_form_key` (factored out of + `usage_key_for_input_in_mode` — shared digit/`m:n`/suffix disambiguation). +- **Why a new field, not `help_id`** (ADR-0053 D3): `help_id` is `None` on + the 7 advanced-SQL forms purely to dedup the `help` *list*; those forms + have distinct SQL syntax and need their own block. `hint_ids` is per + form. (The parallel `help`-side gap is issue #36; clause-concept hints + are deferred — issue #37.) ### 3b. `AppCommand::Hint` + the `HINT` node - `AppCommand::Hint` variant (no fields — no topic arg) in diff --git a/src/app.rs b/src/app.rs index e8a645d..cf22c16 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3152,7 +3152,7 @@ impl App { let (view, cursor, _off) = self.feedback_view(); let probe = view.to_string(); let mode = self.effective_mode().as_mode(); - if let Some(id) = crate::dsl::grammar::hint_id_for_input_in_mode(&probe, mode) + if let Some(id) = crate::dsl::grammar::hint_key_for_input_in_mode(&probe, mode) && self.emit_tier3_block(&format!("hint.cmd.{id}")) { return; @@ -5800,6 +5800,55 @@ mod tests { assert!(output_contains(&app, "explain the most recent error")); } + // ── Phase B: tier-3 exemplar content renders ──────────────── + + #[test] + fn f1_on_insert_input_renders_the_insert_hint_block() { + let mut app = App::new(); + type_str(&mut app, "insert into Customers "); + f1(&mut app); + assert!( + output_contains(&app, "Add one or more rows to a table"), + "expected the insert tier-3 block" + ); + } + + #[test] + fn f1_on_add_relationship_renders_the_relationship_block() { + let mut app = App::new(); + type_str(&mut app, "add 1:n relationship from Customers.id to Orders.cust "); + f1(&mut app); + assert!( + output_contains(&app, "one parent, many children"), + "expected the add-relationship tier-3 block" + ); + } + + #[test] + fn f1_on_add_column_does_not_render_the_relationship_block() { + // Per-form disambiguation (ADR-0053 D3): `add column` resolves + // to `add_column` (no tier-3 block yet → tier-2 fallback), NOT + // the relationship block — proving the multi-form node keys + // per form, not per node. + let mut app = App::new(); + type_str(&mut app, "add column Note text to Customers"); + f1(&mut app); + assert!(!output_contains(&app, "one parent, many children")); + assert!(!output_contains(&app, "1:n")); + } + + #[test] + fn hint_renders_the_foreign_key_error_block() { + let mut app = App::new(); + app.last_error_hint_key = Some("foreign_key.child_side".to_string()); + type_str(&mut app, "hint"); + submit(&mut app); + assert!( + output_contains(&app, "doesn't match any parent row"), + "expected the FK child-side tier-3 block" + ); + } + #[test] fn messages_command_toggles_verbosity_and_reports() { let mut app = App::new(); diff --git a/src/dsl/grammar/app.rs b/src/dsl/grammar/app.rs index 75dfada..1deaea7 100644 --- a/src/dsl/grammar/app.rs +++ b/src/dsl/grammar/app.rs @@ -266,7 +266,7 @@ pub static QUIT: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_quit, help_id: Some("app.quit"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.quit"],}; pub static HELP: CommandNode = CommandNode { @@ -274,7 +274,7 @@ pub static HELP: CommandNode = CommandNode { shape: HELP_TOPIC_OPT, ast_builder: build_help, help_id: Some("app.help"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.help"],}; pub static HINT: CommandNode = CommandNode { @@ -283,7 +283,7 @@ pub static HINT: CommandNode = CommandNode { ast_builder: build_hint, help_id: Some("app.hint"), // hint_id assigned in Phase C with the tier-3 corpus (ADR-0053). - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.hint"],}; pub static REBUILD: CommandNode = CommandNode { @@ -291,7 +291,7 @@ pub static REBUILD: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_rebuild, help_id: Some("app.rebuild"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.rebuild"],}; pub static SAVE: CommandNode = CommandNode { @@ -299,7 +299,7 @@ pub static SAVE: CommandNode = CommandNode { shape: SAVE_AS_OPT, ast_builder: build_save, help_id: Some("app.save"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.save"],}; pub static NEW: CommandNode = CommandNode { @@ -307,7 +307,7 @@ pub static NEW: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_new, help_id: Some("app.new"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.new"],}; pub static LOAD: CommandNode = CommandNode { @@ -315,7 +315,7 @@ pub static LOAD: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_load, help_id: Some("app.load"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.load"],}; pub static EXPORT: CommandNode = CommandNode { @@ -323,7 +323,7 @@ pub static EXPORT: CommandNode = CommandNode { shape: EXPORT_PATH_OPT, ast_builder: build_export, help_id: Some("app.export"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.export"],}; pub static IMPORT: CommandNode = CommandNode { @@ -331,7 +331,7 @@ pub static IMPORT: CommandNode = CommandNode { shape: IMPORT_BODY_OPT, ast_builder: build_import, help_id: Some("app.import"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.import"],}; pub static MODE: CommandNode = CommandNode { @@ -339,7 +339,7 @@ pub static MODE: CommandNode = CommandNode { shape: MODE_VALUE, ast_builder: build_mode, help_id: Some("app.mode"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.mode"],}; pub static MESSAGES: CommandNode = CommandNode { @@ -347,7 +347,7 @@ pub static MESSAGES: CommandNode = CommandNode { shape: MESSAGES_VALUE_OPT, ast_builder: build_messages, help_id: Some("app.messages"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.messages"],}; pub static UNDO: CommandNode = CommandNode { @@ -355,7 +355,7 @@ pub static UNDO: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_undo, help_id: Some("app.undo"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.undo"],}; pub static REDO: CommandNode = CommandNode { @@ -363,7 +363,7 @@ pub static REDO: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_redo, help_id: Some("app.redo"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.redo"],}; pub static COPY: CommandNode = CommandNode { @@ -371,5 +371,5 @@ pub static COPY: CommandNode = CommandNode { shape: COPY_VALUE_OPT, ast_builder: build_copy, help_id: Some("app.copy"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.copy"],}; diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index f76cebe..752014d 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -1790,7 +1790,7 @@ pub static SHOW: CommandNode = CommandNode { shape: SHOW_SHAPE, ast_builder: build_show, help_id: Some("data.show"), - hint_id: None, + hint_ids: &[], usage_ids: &[ "parse.usage.show_data", "parse.usage.show_table", @@ -1806,7 +1806,7 @@ pub static SEED: CommandNode = CommandNode { shape: SEED_SHAPE, ast_builder: build_seed, help_id: Some("data.seed"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.seed"], }; @@ -1815,7 +1815,8 @@ pub static INSERT: CommandNode = CommandNode { shape: INSERT_SHAPE, ast_builder: build_insert, help_id: Some("data.insert"), - hint_id: None, + // ADR-0053 Phase-B exemplar. + hint_ids: &["insert"], usage_ids: &["parse.usage.insert"],}; pub static UPDATE: CommandNode = CommandNode { @@ -1823,7 +1824,7 @@ pub static UPDATE: CommandNode = CommandNode { shape: UPDATE_SHAPE, ast_builder: build_update, help_id: Some("data.update"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.update"],}; pub static DELETE: CommandNode = CommandNode { @@ -1831,7 +1832,7 @@ pub static DELETE: CommandNode = CommandNode { shape: DELETE_SHAPE, ast_builder: build_delete, help_id: Some("data.delete"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.delete"],}; pub static REPLAY: CommandNode = CommandNode { @@ -1839,7 +1840,7 @@ pub static REPLAY: CommandNode = CommandNode { shape: REPLAY_PATH, ast_builder: build_replay, help_id: Some("data.replay"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.replay"],}; pub static EXPLAIN: CommandNode = CommandNode { @@ -1847,7 +1848,7 @@ pub static EXPLAIN: CommandNode = CommandNode { shape: EXPLAIN_SHAPE, ast_builder: build_explain, help_id: Some("data.explain"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.explain"],}; /// `explain` over advanced-mode SQL (ADR-0039). @@ -1867,7 +1868,7 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode { // too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE` // precedent; otherwise `note_help` would print `explain` twice. help_id: None, - hint_id: None, + hint_ids: &[], usage_ids: &[],}; /// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032). @@ -1883,7 +1884,7 @@ pub static SELECT: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL), ast_builder: build_select, help_id: None, - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.select"],}; /// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c). @@ -1898,7 +1899,7 @@ pub static WITH: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL), ast_builder: build_select, help_id: None, - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.with"],}; /// SQL `INSERT` — the `Advanced`-category node of the shared @@ -1916,7 +1917,7 @@ pub static SQL_INSERT: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE), ast_builder: build_sql_insert, help_id: None, - hint_id: None, + hint_ids: &[], usage_ids: &[], }; @@ -1930,7 +1931,7 @@ pub static SQL_UPDATE: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE), ast_builder: build_sql_update, help_id: None, - hint_id: None, + hint_ids: &[], usage_ids: &[], }; @@ -1946,7 +1947,7 @@ pub static SQL_DELETE: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE), ast_builder: build_sql_delete, help_id: None, - hint_id: None, + hint_ids: &[], usage_ids: &[], }; diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index e6189d9..69e7774 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -968,7 +968,7 @@ pub static DROP: CommandNode = CommandNode { shape: DROP_SHAPE, ast_builder: build_drop, help_id: Some("ddl.drop"), - hint_id: None, + hint_ids: &[], usage_ids: &[ "parse.usage.drop_table", "parse.usage.drop_column", @@ -982,7 +982,16 @@ pub static ADD: CommandNode = CommandNode { shape: ADD_SHAPE, ast_builder: build_add, help_id: Some("ddl.add"), - hint_id: None, + // Per-form (ADR-0053 D3): every form is listed so the form-word + // disambiguation resolves correctly; forms without an authored + // block yet fall back to tier-2 at render. `add_relationship` is + // authored as a Phase-B exemplar. + hint_ids: &[ + "add_column", + "add_relationship", + "add_index", + "add_constraint", + ], usage_ids: &[ "parse.usage.add_column", "parse.usage.add_relationship", @@ -995,7 +1004,7 @@ pub static RENAME: CommandNode = CommandNode { shape: RENAME_COLUMN, ast_builder: build_rename_column, help_id: Some("ddl.rename"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.rename_column"],}; pub static CHANGE: CommandNode = CommandNode { @@ -1003,7 +1012,7 @@ pub static CHANGE: CommandNode = CommandNode { shape: CHANGE_COLUMN, ast_builder: build_change_column, help_id: Some("ddl.change"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.change_column"],}; // ================================================================= @@ -1364,7 +1373,7 @@ pub static CREATE: CommandNode = CommandNode { shape: CREATE_TABLE, ast_builder: build_create_table, help_id: Some("ddl.create"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.create_table"],}; // ================================================================= @@ -1433,7 +1442,7 @@ pub static CREATE_M2N: CommandNode = CommandNode { shape: CREATE_M2N_SHAPE, ast_builder: build_create_m2n, help_id: Some("ddl.create_m2n"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.create_m2n"], }; @@ -1864,7 +1873,7 @@ pub static SQL_CREATE_TABLE: CommandNode = CommandNode { shape: Node::Subgrammar(&super::sql_create_table::SQL_CREATE_TABLE_SHAPE), ast_builder: build_sql_create_table, help_id: Some("ddl.sql_create_table"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.sql_create_table"], }; @@ -1884,7 +1893,7 @@ pub static SQL_DROP_TABLE: CommandNode = CommandNode { shape: SQL_DROP_TABLE_SHAPE, ast_builder: build_sql_drop_table, help_id: Some("ddl.sql_drop_table"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.sql_drop_table"], }; @@ -1904,7 +1913,7 @@ pub static SQL_DROP_INDEX: CommandNode = CommandNode { shape: SQL_DROP_INDEX_SHAPE, ast_builder: build_sql_drop_index, help_id: Some("ddl.sql_drop_index"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.sql_drop_index"], }; @@ -1986,7 +1995,7 @@ pub static SQL_CREATE_INDEX: CommandNode = CommandNode { shape: SQL_CREATE_INDEX_SHAPE, ast_builder: build_sql_create_index, help_id: Some("ddl.sql_create_index"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.sql_create_index"], }; @@ -2545,7 +2554,7 @@ pub static SQL_ALTER_TABLE: CommandNode = CommandNode { shape: SQL_ALTER_TABLE_SHAPE, ast_builder: build_sql_alter_table, help_id: Some("ddl.sql_alter_table"), - hint_id: None, + hint_ids: &[], usage_ids: &["parse.usage.sql_alter_table"], }; diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index 772d2b1..4ba3c1e 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -530,15 +530,18 @@ pub struct CommandNode { /// so a newly-registered command appears in `help` /// automatically (ADR-0024 §help_id). pub help_id: Option<&'static str>, - /// Catalog key stem (`hint.cmd.`) for this command form's - /// **tier-3** contextual hint (ADR-0053 / H2). Unlike `help_id` - /// — which is `None` on advanced-SQL forms purely to dedup the - /// `help` list — `hint_id` is 1:1 with command *forms*, so each - /// advanced-SQL form carries its own id and renders SQL-syntax - /// content distinct from its simple-DSL sibling. `None` until a - /// form's tier-3 block is authored (the surface falls back to - /// tier-2 ambient/error text). - pub hint_id: Option<&'static str>, + /// Catalog key stems (`hint.cmd.`) for this command's + /// **tier-3** contextual hints (ADR-0053 / H2), **one per form**, + /// mirroring `usage_ids`. A single-form command carries one; a + /// multi-form command (`add`, `drop`, `show`, `create`) carries + /// one per form so a live-input hint can be specific to the form + /// being typed (`hint.cmd.add_relationship`, not a shared `add` + /// block). `hint_key_for_input_in_mode` disambiguates by the form + /// word, reusing `usage_key_for_input_in_mode`'s logic. Empty + /// until a form's tier-3 block is authored (the surface falls back + /// to tier-2 ambient/error text). Distinct from `help_id` (which is + /// `None` on advanced-SQL forms purely to dedup the `help` list). + pub hint_ids: &'static [&'static str], /// Catalog keys under `parse.usage.*` to render in the /// "usage:" block when a parse error fires for this command /// (ADR-0021 §1, ADR-0024 §architecture). Multi-form families @@ -602,20 +605,30 @@ pub fn usage_keys_for_input_in_mode( Some((entry, keys)) } -/// The tier-3 `hint_id` of the command form `source` is currently -/// typing, in `mode` (H2 / ADR-0053). +/// The single tier-3 hint key (`hint.cmd.` stem) for the command +/// **form** `source` is currently typing, in `mode` (H2 / ADR-0053). /// -/// Reuses the same mode-aware -/// selection as [`usage_keys_for_input_in_mode`] and returns the -/// **mode-primary** node's `hint_id` — so an advanced-SQL form -/// resolves to its *own* id, not its simple-DSL sibling's. `None` if -/// no entry word matches, or the chosen form has no tier-3 block yet -/// (the caller then falls back to tier-2 ambient text). +/// Mirrors [`usage_key_for_input_in_mode`]: the union of the +/// mode-selected nodes' `hint_ids`, disambiguated to the typed form by +/// [`pick_form_key`] — so `add 1:n relationship` resolves to the +/// relationship hint, and an advanced-SQL form resolves to its own +/// (not its simple sibling's). `None` if no entry word matches or the +/// form has no tier-3 block yet (the caller falls back to tier-2). #[must_use] -pub fn hint_id_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> { - selected_nodes_for_input_in_mode(source, mode) - .first() - .and_then(|(_, node, _)| node.hint_id) +pub fn hint_key_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> { + let nodes = selected_nodes_for_input_in_mode(source, mode); + if nodes.is_empty() { + return None; + } + let mut keys: Vec<&'static str> = Vec::new(); + for (_, node, _) in &nodes { + for k in node.hint_ids { + if !keys.contains(k) { + keys.push(*k); + } + } + } + pick_form_key(source, &keys) } /// Shared mode-aware command-form selection for the entry word at the @@ -694,14 +707,24 @@ pub fn usage_key_for_input_in_mode( source: &str, mode: crate::mode::Mode, ) -> Option<&'static str> { - use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace}; let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?; + pick_form_key(source, &keys) +} + +/// From the form word after the entry keyword, pick the single `keys` +/// entry for the form `source` names. +/// +/// A single-entry list resolves to its one key; a multi-form list +/// disambiguates by the form word (`add 1:n relationship` → the +/// `…relationship` key, `create m:n …` → the `…m2n` key, else the +/// identifier form word matched against each key's suffix). Shared by +/// the usage-template and tier-3-hint single-key lookups so they agree. +fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> { + use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace}; let first = *keys.first()?; if keys.len() == 1 { return Some(first); } - // Multi-form: the form is named by the token right after - // the entry keyword. let start = skip_whitespace(source, 0); let (_, entry_end) = consume_ident(source, start)?; let after = skip_whitespace(source, entry_end); @@ -710,14 +733,12 @@ pub fn usage_key_for_input_in_mode( return keys.iter().copied().find(|k| k.ends_with("relationship")); } // The `create m:n relationship` form (ADR-0045) opens with `m:n` - // — a letter, so the digit branch misses it, and its usage key ends - // `…create_m2n` (not `relationship`). + // — a letter, so the digit branch misses it; its key ends `…m2n`. if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) { return keys.iter().copied().find(|k| k.ends_with("m2n")); } - // Otherwise the form word is an identifier — `column`, - // `index`, `table`, `relationship` — matched against the - // usage key's suffix. + // Otherwise the form word is an identifier — `column`, `index`, + // `table`, `relationship` — matched against each key's suffix. let (s, e) = consume_ident(source, after)?; let form = source[s..e].to_ascii_lowercase(); keys.iter().copied().find(|k| k.ends_with(form.as_str())) @@ -873,6 +894,36 @@ pub fn commands_for_entry_word( .collect() } +#[cfg(test)] +mod hint_key_tests { + use super::hint_key_for_input_in_mode; + use crate::mode::Mode; + + /// Per-form hint keying (ADR-0053 D3): a multi-form command + /// resolves the *typed* form, not the node — `add 1:n + /// relationship` → the relationship hint, `add column` → the + /// (as-yet-unauthored) column hint, never the wrong form. + #[test] + fn hint_key_resolves_the_typed_form() { + assert_eq!( + hint_key_for_input_in_mode("add 1:n relationship from A.x to B.y", Mode::Simple), + Some("add_relationship") + ); + assert_eq!( + hint_key_for_input_in_mode("add column Note text to T", Mode::Simple), + Some("add_column") + ); + assert_eq!( + hint_key_for_input_in_mode("insert into T values (1)", Mode::Simple), + Some("insert") + ); + // A node with no hint_ids yet → None (tier-2 fallback). + assert_eq!(hint_key_for_input_in_mode("drop table T", Mode::Simple), None); + // Unknown entry word → None. + assert_eq!(hint_key_for_input_in_mode("zzz", Mode::Simple), None); + } +} + #[cfg(test)] mod usage_key_tests { use super::usage_key_for_input; diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index 2ee5f17..99a8e0b 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -6910,7 +6910,7 @@ mod dispatch_3a_tests { shape: Node::Word(Word::keyword("dsltail")), ast_builder: dsl_builder, help_id: None, - hint_id: None, + hint_ids: &[], usage_ids: &[], }; static SMOKE_SQL: CommandNode = CommandNode { @@ -6918,7 +6918,7 @@ mod dispatch_3a_tests { shape: Node::Word(Word::keyword("sqltail")), ast_builder: sql_builder, help_id: None, - hint_id: None, + hint_ids: &[], usage_ids: &[], }; diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index e8ff14d..989876d 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -224,6 +224,16 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ), ("hint.ambient_expected", &["expected"]), ("hint.getting_started", &[]), + // Tier-3 teaching blocks (ADR-0053 D3) — Phase-B exemplars. + ("hint.cmd.insert.what", &[]), + ("hint.cmd.insert.example", &[]), + ("hint.cmd.insert.concept", &[]), + ("hint.cmd.add_relationship.what", &[]), + ("hint.cmd.add_relationship.example", &[]), + ("hint.cmd.add_relationship.concept", &[]), + ("hint.err.foreign_key.child_side.what", &[]), + ("hint.err.foreign_key.child_side.example", &[]), + ("hint.err.foreign_key.child_side.concept", &[]), ( "hint.ambient_invalid_ident", &["kind", "found"], diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index d9581e8..27ae330 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -391,6 +391,27 @@ hint: # H2 / ADR-0053: shown by `hint` / F1 when there is nothing specific # to expand on (no recent error, empty input). getting_started: "Start typing a command and press F1 for a hint, or type `help` for the full command list." + # ── Tier-3 teaching blocks (ADR-0053 D3) ────────────────────────── + # Per-form command hints (`hint.cmd.
`) and per-class error + # hints (`hint.err.`), each a `what` (1–2 sentences) / `example` + # (one runnable, mode-correct line) / `concept` (the relational idea — + # the teaching part). Phase B seeds the three approved exemplars; the + # rest are authored in Phase C. + cmd: + insert: + what: "Add one or more rows to a table." + example: "insert into Customers values ('Ann', 'ann@example.io')" + concept: "A row is one record; each value lines up with a column, in order. Columns typed `serial`/`shortid` fill themselves — leave them out." + add_relationship: + what: "Link two tables so a parent row can own many child rows." + example: "add 1:n relationship from Customers.id to Orders.customer_id" + concept: "The \"1:n\" means one parent, many children. The child column holds the foreign key; add `--create-fk` to create that column if it doesn't exist yet." + err: + foreign_key: + child_side: + what: "The value you gave for the child column doesn't match any parent row, so the foreign key has nothing to point at." + example: "First insert the parent (insert into Customers …), then the child that references it." + concept: "A foreign key is a promise that every child points at a real parent, so the parent must exist first. To allow orphans on delete instead, set the relationship's `on delete` to `set null` or `cascade`." # Invalid identifier in a schema slot (ADR-0022 stage 8e # + the user's #5). Voice mirrors ADR-0019's "no such # {kind}" wording for consistency with engine errors. From aeb92f56a78aa0bd475272327552f270736b0d01 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Mon, 15 Jun 2026 15:56:38 +0000 Subject: [PATCH 38/50] docs(ci): record macOS implementation in ADR-ci-003 (D1 complete) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS is no longer deferred — built natively on a Tart (Apple-Silicon) runner (real hardware → licensed SDK, no grey area). Amendment documents release-macos.yaml (dispatch-only, needs main), the libiconv de-nix + ad-hoc re-sign, the runner-label `:host` backend nuance, generation-based cache pruning, and D2-on-macOS (system libs only). All six D1 targets now produce artifacts. Updates the deferred list + index entry. --- docs/ci/adr/20260613-adr-ci-003.md | 46 +++++++++++++++++++++++++++++- docs/ci/adr/README.md | 2 +- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/docs/ci/adr/20260613-adr-ci-003.md b/docs/ci/adr/20260613-adr-ci-003.md index f840fc3..882e9d8 100644 --- a/docs/ci/adr/20260613-adr-ci-003.md +++ b/docs/ci/adr/20260613-adr-ci-003.md @@ -20,6 +20,49 @@ This ADR records the **cross-platform build strategy**; it sits on top of **ADR-ci-002** (the nix flake, which now carries the cross toolchain) and **ADR-ci-001** (the pipeline, whose release job this fills in). +## Amendment — 2026-06-14: macOS implemented (closes D1) + +macOS is no longer deferred. The two `*-apple-darwin` targets now build on a +**Tart (Apple-Silicon) macOS runner** registered to Gitea — building on **real +Apple hardware** makes the SDK fully licensed, so the whole osxcross / SDK +grey-area + public-image-redistribution problem (§5 below) simply **does not +arise**. With all six D1 targets producing artifacts, **D1 is complete.** + +Details, all verified on the runner via a throwaway smoke-test before wiring the +release leg: + +- **`release-macos.yaml`** — `workflow_dispatch` with a `tag` input, + `runs-on: macos`. The runner registered as `macos:host`, but `:host` is + act_runner's execution-backend schema (run on host, no container), **not** part + of the label, so the label is `macos`. Steps: `cargo test` (macOS gets the only + automated test coverage outside the Linux gate — user choice) → build both + darwin targets natively through the flake (`apple-sdk` added to the devShell so + the toolchain links AppKit) → **upload to the same release** via the idempotent + create-or-get. +- **De-nix + re-sign.** The darwin stdenv bakes a `/nix/store` `libiconv` load + path into the binary (the *only* non-system dependency; everything else is + AppKit/Foundation/CoreGraphics/IOKit + `libSystem`/`libobjc`). The release step + rewrites it to `/usr/lib/libiconv.2.dylib` with `install_name_tool` and + **re-signs ad-hoc** (`codesign -f -s -`) — `install_name_tool` invalidates the + signature and Apple Silicon refuses an unsigned binary. A guard fails the build + if any `/nix/store` path remains. Result: portable, signed binaries (the native + one was confirmed to launch). +- **Dispatch-only, intermittent runner.** The Mac isn't always on, so macOS is a + separate dispatched workflow (not a job in `release.yaml`) — a release always + carries the four Linux/Windows assets regardless of the Mac, and the two macOS + assets are added by dispatching `release-macos` for that tag. **Caveat:** Gitea + exposes `workflow_dispatch` only for workflows on the **default branch**, so + `release-macos` becomes triggerable once the CI work is merged to `main`. +- **Cache hygiene (host-execution runner).** The runner wipes the workspace each + run, so cargo `target/` never accumulates; the persistent cache is the nix + store, bounded by **generation** — record the current devShell in a persistent + profile, keep the 2 newest generations (`nix-env --delete-generations +2`), + reclaim the rest. (The first sweep reclaimed a ~3.8 GB one-time backlog of + build scaffolding — source + build-only deps, not re-installed toolchains.) +- **D2 on macOS.** macOS binaries cannot be fully static (`libSystem` is always + dynamic); "no runtime deps" there means *system libraries only*, which the + de-nix step guarantees. + ## Context `requirements.md` **D1** asks for binaries on **Linux, macOS, Windows × x86_64 @@ -135,7 +178,8 @@ user decides when we get there. ## Deferred / out of scope -- **macOS** (x86_64 + aarch64) — the SDK/runner decision above. +- ~~**macOS** (x86_64 + aarch64)~~ — **done** via the Tart runner (see the + 2026-06-14 amendment); §5 below is the as-deferred rationale, kept for history. - **D3 packaging** — Homebrew / Scoop / winget / `cargo-binstall` manifests (and binstall-friendly archive naming). - **CI speed** — caching per-target builds / Zig's libc cache. diff --git a/docs/ci/adr/README.md b/docs/ci/adr/README.md index 74a2223..d2a5e66 100644 --- a/docs/ci/adr/README.md +++ b/docs/ci/adr/README.md @@ -20,4 +20,4 @@ here too). - [ADR-ci-001 — CI + release pipeline on Gitea Actions](20260612-adr-ci-001.md) — **Accepted 2026-06-12** (implemented the same day on the `ci` branch). Establishes the CI/release pipeline on the self-hosted Gitea instance's Actions runner (`ci-public`). **Runner model** (established by a throwaway probe): jobs execute *inside* a container (`catthehacker/ubuntu:act-22.04` by default), as root, so the runner host's nix is **not** reachable from steps. **Toolchain delivery:** a **baked CI image** — `node:22-bookworm-slim` (satisfies the act_runner job-container contract: `/bin/sleep` keep-alive, `bash`, `node` for JS actions; a bare `nixos/nix` image lacks these and won't start) **+ single-user nix + the flake's devShell pre-warmed** — built by `build-ci-image.yaml` via DinD and pushed to the Gitea container registry as a **public** package, so CI runs `nix develop -c …` against the **same pinned toolchain as dev** (the flake, ADR-ci-002) with a warm store (~1.4 s to a ready toolchain). **Gate** (`ci.yaml`): `clippy -D warnings` + `cargo test` inside that image on branch pushes + PRs; **fmt deliberately not gated** (the tree isn't stock-rustfmt-clean — user decision, revisit on `main`; see ADR-ci-002). **Release** (`release.yaml`): on a `v*` tag, runs the tests, builds the **static `x86_64-unknown-linux-musl` binary** (D2: single static binary, no runtime deps — the glibc/nix build is non-portable), strips it, and publishes it + a `.sha256` to a Gitea release via the API and the auto-provided `GITEA_TOKEN`. **Triggers:** gate + image-build are scoped to **branch** pushes (`branches: ['**']`) so a release tag doesn't spuriously re-run them; the image-build additionally path-filters to its inputs (Dockerfile/flake/toolchain); the release owns tags. **Auth:** a dedicated PAT (`REGISTRY_USERNAME`/`REGISTRY_TOKEN` secrets) pushes the image; the auto `GITEA_TOKEN` publishes releases. **Scope:** the original release job was Linux x86_64 only; it's now the **four non-macOS D1 targets** (Linux + Windows × x86_64/aarch64) cross-built via cargo-zigbuild — see **ADR-ci-003**. macOS, D3 package-manager manifests, CI-speed dependency caching, and the website's static→Cloudflare deploy remain deferred, added step by step. Verified live: probe → runner facts; image built + checked locally; gate green (**2424 tests**); release exercised end-to-end (`v0.0.0-citest2` published with binary + checksum). Builds on **ADR-ci-002** (the nix flake, relocated here from main's ADR-0049 to avoid exactly this cross-branch collision). - [ADR-ci-002 — Nix flake for a reproducible dev + build environment](20260612-adr-ci-002.md) — **Accepted 2026-06-12** (relocated from main's **ADR-0049** on the same day — content unchanged — to keep CI/dev-env decisions out of `main`'s integer sequence). The single, version-pinned declaration of the **dev *and* build toolchain** so CI never relies on whatever Rust is on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI). Root **Nix flake** with two outputs: **`devShells.default`** (pinned **Rust 1.95.0** via `rust-toolchain.toml` + `rust-overlay`, `cargo-sweep`, and the musl cc for the static release build) and **`packages.default`** (`rustPlatform.buildRustPackage` from the committed `Cargo.lock`; `doCheck = false`). Exact-pin (not floating `stable`) so `nix flake update` can't surprise-bump clippy past the `-D warnings` gate. System inputs near-empty by design (`libsqlite3-sys bundled` → stdenv cc only; `arboard`→`x11rb` pure-Rust). `.envrc` (`use flake`) for direnv parity. Verified through the flake: `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest**. Consumed by **ADR-ci-001** (the pipeline). Alternatives rejected: dev-shell-only; a standard `rust:1.95` CI image (a second toolchain definition = drift); `rustup` on the build host (non-reproducible). -- [ADR-ci-003 — Cross-platform release builds (the D1 matrix)](20260613-adr-ci-003.md) — **Accepted 2026-06-13** (implemented + a real matrix release verified the same day — tag `v.0.0.0-citest3` published 8 assets). Cross-compiles the **four non-macOS D1 targets** from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + libc as one universal cross cc/linker, incl. rusqlite's bundled SQLite C; added to the flake devShell, replacing the single-target musl cc): **`x86_64`/`aarch64-unknown-linux-musl`** (musl + crt-static → fully static, **D2**) and **`x86_64-pc-windows-gnu`** / **`aarch64-pc-windows-gnullvm`** (Zig statically links libc → standalone `.exe`). **Windows `synchronization` stub:** Rust std links `-lsynchronization` (WaitOnAddress thread-parking), an import lib rust-overlay's toolchain doesn't ship and Zig's mingw lacks; the symbols are forwarded by `kernel32`, so an **empty 8-byte stub** `libsynchronization.a` (`ci/winstub/`, wired via `.cargo/config.toml` for the Windows targets only) satisfies the linker. **Workflow:** `release.yaml` = **`test` once (host) → `build` matrix** over the four targets (`needs: test`, `fail-fast: false`); each job packages binary (`.exe` for Windows) + `.sha256` and uploads to the **shared release** via idempotent create-or-get. **macOS deferred** — `arboard`→AppKit needs Apple's SDK, a licensing grey area on a Linux runner, and the **public** CI image can't carry it; its own step (osxcross + a private SDK, or a Mac runner). Alternatives rejected: `cross` (no macOS, needs DinD), per-target nix cross (Windows-aarch64 unpackaged, macOS-from-Linux unsupported), a real `libsynchronization.a` (more machinery, doesn't cover Windows-aarch64). Runtime-verified by the user (2026-06-13): Linux x86_64 + Windows aarch64 run correctly; Linux aarch64 + Windows x86_64 are the outstanding runtime checks. Builds on ADR-ci-002 (flake) and fills in ADR-ci-001 §3 (Release). +- [ADR-ci-003 — Cross-platform release builds (the D1 matrix)](20260613-adr-ci-003.md) — **Accepted 2026-06-13** (implemented + a real matrix release verified the same day — tag `v.0.0.0-citest3` published 8 assets). Cross-compiles the **four non-macOS D1 targets** from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + libc as one universal cross cc/linker, incl. rusqlite's bundled SQLite C; added to the flake devShell, replacing the single-target musl cc): **`x86_64`/`aarch64-unknown-linux-musl`** (musl + crt-static → fully static, **D2**) and **`x86_64-pc-windows-gnu`** / **`aarch64-pc-windows-gnullvm`** (Zig statically links libc → standalone `.exe`). **Windows `synchronization` stub:** Rust std links `-lsynchronization` (WaitOnAddress thread-parking), an import lib rust-overlay's toolchain doesn't ship and Zig's mingw lacks; the symbols are forwarded by `kernel32`, so an **empty 8-byte stub** `libsynchronization.a` (`ci/winstub/`, wired via `.cargo/config.toml` for the Windows targets only) satisfies the linker. **Workflow:** `release.yaml` = **`test` once (host) → `build` matrix** over the four targets (`needs: test`, `fail-fast: false`); each job packages binary (`.exe` for Windows) + `.sha256` and uploads to the **shared release** via idempotent create-or-get. **macOS** (2026-06-14 amendment) — built natively on a **Tart (Apple-Silicon) runner** (`runs-on: macos`), which makes the SDK fully licensed and dissolves the grey-area/public-image problem; `release-macos.yaml` is **dispatch-only** (intermittent runner; becomes triggerable once CI is on `main`), de-nixes the binary's libiconv load path (`install_name_tool` → `/usr/lib`) + re-signs ad-hoc, and uploads to the tagged release. **D1 complete (all six targets).** Alternatives rejected: `cross` (no macOS, needs DinD), per-target nix cross (Windows-aarch64 unpackaged, macOS-from-Linux unsupported), a real `libsynchronization.a` (more machinery, doesn't cover Windows-aarch64). Runtime-verified by the user (2026-06-13): Linux x86_64 + Windows aarch64 run correctly; Linux aarch64 + Windows x86_64 are the outstanding runtime checks. Builds on ADR-ci-002 (flake) and fills in ADR-ci-001 §3 (Release). From 4bdfce62505c0f1d455c863eaf12198a08475fde Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Mon, 15 Jun 2026 16:01:39 +0000 Subject: [PATCH 39/50] =?UTF-8?q?feat(hint):=20H2=20Phase=20C=20batch=201?= =?UTF-8?q?=20=E2=80=94=20app-command=20tier-3=20hints=20(ADR-0053)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-form hints for the 14 app-lifecycle commands (quit/help/hint/ rebuild/save/new/load/export/import/mode/messages/undo/redo/copy), reference-leaning what/example with concept where it teaches (rebuild, mode, messages, undo, export, help). hint_ids wired, catalogue + keys.rs registered. +1 spot test; 2489 pass / 1 ignored, clippy clean. --- src/app.rs | 13 +++++++++ src/dsl/grammar/app.rs | 28 +++++++++--------- src/friendly/keys.rs | 35 +++++++++++++++++++++++ src/friendly/strings/en-US.yaml | 50 +++++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 14 deletions(-) diff --git a/src/app.rs b/src/app.rs index cf22c16..4ad235f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5849,6 +5849,19 @@ mod tests { ); } + // ── Phase C batch 1: app-command hints render ─────────────── + + #[test] + fn f1_on_an_app_command_renders_its_hint_block() { + let mut app = App::new(); + type_str(&mut app, "mode advanced"); + f1(&mut app); + assert!( + output_contains(&app, "Switch between simple mode"), + "expected the `mode` tier-3 block" + ); + } + #[test] fn messages_command_toggles_verbosity_and_reports() { let mut app = App::new(); diff --git a/src/dsl/grammar/app.rs b/src/dsl/grammar/app.rs index 1deaea7..814151a 100644 --- a/src/dsl/grammar/app.rs +++ b/src/dsl/grammar/app.rs @@ -266,7 +266,7 @@ pub static QUIT: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_quit, help_id: Some("app.quit"), - hint_ids: &[], + hint_ids: &["quit"], usage_ids: &["parse.usage.quit"],}; pub static HELP: CommandNode = CommandNode { @@ -274,7 +274,7 @@ pub static HELP: CommandNode = CommandNode { shape: HELP_TOPIC_OPT, ast_builder: build_help, help_id: Some("app.help"), - hint_ids: &[], + hint_ids: &["help"], usage_ids: &["parse.usage.help"],}; pub static HINT: CommandNode = CommandNode { @@ -283,7 +283,7 @@ pub static HINT: CommandNode = CommandNode { ast_builder: build_hint, help_id: Some("app.hint"), // hint_id assigned in Phase C with the tier-3 corpus (ADR-0053). - hint_ids: &[], + hint_ids: &["hint"], usage_ids: &["parse.usage.hint"],}; pub static REBUILD: CommandNode = CommandNode { @@ -291,7 +291,7 @@ pub static REBUILD: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_rebuild, help_id: Some("app.rebuild"), - hint_ids: &[], + hint_ids: &["rebuild"], usage_ids: &["parse.usage.rebuild"],}; pub static SAVE: CommandNode = CommandNode { @@ -299,7 +299,7 @@ pub static SAVE: CommandNode = CommandNode { shape: SAVE_AS_OPT, ast_builder: build_save, help_id: Some("app.save"), - hint_ids: &[], + hint_ids: &["save"], usage_ids: &["parse.usage.save"],}; pub static NEW: CommandNode = CommandNode { @@ -307,7 +307,7 @@ pub static NEW: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_new, help_id: Some("app.new"), - hint_ids: &[], + hint_ids: &["new"], usage_ids: &["parse.usage.new"],}; pub static LOAD: CommandNode = CommandNode { @@ -315,7 +315,7 @@ pub static LOAD: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_load, help_id: Some("app.load"), - hint_ids: &[], + hint_ids: &["load"], usage_ids: &["parse.usage.load"],}; pub static EXPORT: CommandNode = CommandNode { @@ -323,7 +323,7 @@ pub static EXPORT: CommandNode = CommandNode { shape: EXPORT_PATH_OPT, ast_builder: build_export, help_id: Some("app.export"), - hint_ids: &[], + hint_ids: &["export"], usage_ids: &["parse.usage.export"],}; pub static IMPORT: CommandNode = CommandNode { @@ -331,7 +331,7 @@ pub static IMPORT: CommandNode = CommandNode { shape: IMPORT_BODY_OPT, ast_builder: build_import, help_id: Some("app.import"), - hint_ids: &[], + hint_ids: &["import"], usage_ids: &["parse.usage.import"],}; pub static MODE: CommandNode = CommandNode { @@ -339,7 +339,7 @@ pub static MODE: CommandNode = CommandNode { shape: MODE_VALUE, ast_builder: build_mode, help_id: Some("app.mode"), - hint_ids: &[], + hint_ids: &["mode"], usage_ids: &["parse.usage.mode"],}; pub static MESSAGES: CommandNode = CommandNode { @@ -347,7 +347,7 @@ pub static MESSAGES: CommandNode = CommandNode { shape: MESSAGES_VALUE_OPT, ast_builder: build_messages, help_id: Some("app.messages"), - hint_ids: &[], + hint_ids: &["messages"], usage_ids: &["parse.usage.messages"],}; pub static UNDO: CommandNode = CommandNode { @@ -355,7 +355,7 @@ pub static UNDO: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_undo, help_id: Some("app.undo"), - hint_ids: &[], + hint_ids: &["undo"], usage_ids: &["parse.usage.undo"],}; pub static REDO: CommandNode = CommandNode { @@ -363,7 +363,7 @@ pub static REDO: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_redo, help_id: Some("app.redo"), - hint_ids: &[], + hint_ids: &["redo"], usage_ids: &["parse.usage.redo"],}; pub static COPY: CommandNode = CommandNode { @@ -371,5 +371,5 @@ pub static COPY: CommandNode = CommandNode { shape: COPY_VALUE_OPT, ast_builder: build_copy, help_id: Some("app.copy"), - hint_ids: &[], + hint_ids: &["copy"], usage_ids: &["parse.usage.copy"],}; diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index 989876d..3edaaf3 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -234,6 +234,41 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("hint.err.foreign_key.child_side.what", &[]), ("hint.err.foreign_key.child_side.example", &[]), ("hint.err.foreign_key.child_side.concept", &[]), + // Phase C batch 1 — app-lifecycle command hints. + ("hint.cmd.quit.what", &[]), + ("hint.cmd.quit.example", &[]), + ("hint.cmd.help.what", &[]), + ("hint.cmd.help.example", &[]), + ("hint.cmd.help.concept", &[]), + ("hint.cmd.hint.what", &[]), + ("hint.cmd.hint.example", &[]), + ("hint.cmd.rebuild.what", &[]), + ("hint.cmd.rebuild.example", &[]), + ("hint.cmd.rebuild.concept", &[]), + ("hint.cmd.save.what", &[]), + ("hint.cmd.save.example", &[]), + ("hint.cmd.new.what", &[]), + ("hint.cmd.new.example", &[]), + ("hint.cmd.load.what", &[]), + ("hint.cmd.load.example", &[]), + ("hint.cmd.export.what", &[]), + ("hint.cmd.export.example", &[]), + ("hint.cmd.export.concept", &[]), + ("hint.cmd.import.what", &[]), + ("hint.cmd.import.example", &[]), + ("hint.cmd.mode.what", &[]), + ("hint.cmd.mode.example", &[]), + ("hint.cmd.mode.concept", &[]), + ("hint.cmd.messages.what", &[]), + ("hint.cmd.messages.example", &[]), + ("hint.cmd.messages.concept", &[]), + ("hint.cmd.undo.what", &[]), + ("hint.cmd.undo.example", &[]), + ("hint.cmd.undo.concept", &[]), + ("hint.cmd.redo.what", &[]), + ("hint.cmd.redo.example", &[]), + ("hint.cmd.copy.what", &[]), + ("hint.cmd.copy.example", &[]), ( "hint.ambient_invalid_ident", &["kind", "found"], diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 27ae330..c785a81 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -406,6 +406,56 @@ hint: what: "Link two tables so a parent row can own many child rows." example: "add 1:n relationship from Customers.id to Orders.customer_id" concept: "The \"1:n\" means one parent, many children. The child column holds the foreign key; add `--create-fk` to create that column if it doesn't exist yet." + # App-lifecycle commands (Phase C batch 1). Reference-leaning, so + # `concept` appears only where there's a real idea to teach. + quit: + what: "Leave the playground. Your project is already saved to disk." + example: "quit" + help: + what: "List every command, or show the detail for one." + example: "help insert" + concept: "`help` is the reference; press F1 while typing for a hint about the command you're building right now." + hint: + what: "Explain the most recent error — or, pressing F1 while typing, the command you're building." + example: "hint" + rebuild: + what: "Rebuild the project database from its saved text files." + example: "rebuild" + concept: "The text files (project.yaml + the data folder) are the source of truth; the database is derived and can always be rebuilt from them." + save: + what: "Save the current project under a name; `save as` copies it to a new one." + example: "save as my-shop" + new: + what: "Close the current project and start a fresh temporary one." + example: "new" + load: + what: "Open the project picker to switch to a saved project." + example: "load" + export: + what: "Write a shareable zip of the project — its text files only, never the database." + example: "export my-shop.zip" + concept: "The zip carries the schema and data as text, so anyone can rebuild the very same database from it." + import: + what: "Unpack a project zip into a new project and switch to it." + example: "import my-shop.zip as shop-copy" + mode: + what: "Switch between simple mode (the guided teaching commands) and advanced mode (raw SQL)." + example: "mode advanced" + concept: "Simple mode uses keyword commands; advanced mode lets you write SQL directly. A leading `:` runs a single advanced command without switching modes." + messages: + what: "Show or set how much detail error messages give." + example: "messages short" + concept: "Verbose (the default) adds a fix-it hint under each error headline; short shows just the headline." + undo: + what: "Undo the most recent change, after a confirmation." + example: "undo" + concept: "Every data or schema change is snapshotted first, so you can step back; `redo` re-applies what you undid." + redo: + what: "Re-apply the most recently undone change." + example: "redo" + copy: + what: "Copy the output panel to the clipboard — all of it, or just the last command's output." + example: "copy last" err: foreign_key: child_side: From 6429b564439071d6d42932a2adc384820f3c849e Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Mon, 15 Jun 2026 16:05:41 +0000 Subject: [PATCH 40/50] =?UTF-8?q?feat(hint):=20H2=20Phase=20C=20batch=202?= =?UTF-8?q?=20=E2=80=94=20DDL=20tier-3=20hints=20(ADR-0053)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-form hints for the schema-shaping commands: create table, create m:n, add column/index/constraint, drop table/column/relationship/ index/constraint, rename column, change column (add_relationship was the Phase-B exemplar). Examples verified against the canonical usage templates. hint_ids wired on CREATE/CREATE_M2N/DROP/RENAME/CHANGE; catalogue + keys.rs registered. +2 spot tests (incl. multi-form DROP disambiguation); 2491 pass / 1 ignored, clippy clean. --- src/app.rs | 20 ++++++++++++++ src/dsl/grammar/ddl.rs | 16 +++++++---- src/dsl/grammar/mod.rs | 9 ++++-- src/friendly/keys.rs | 37 +++++++++++++++++++++++++ src/friendly/strings/en-US.yaml | 49 +++++++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 8 deletions(-) diff --git a/src/app.rs b/src/app.rs index 4ad235f..d124b2e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5862,6 +5862,26 @@ mod tests { ); } + // ── Phase C batch 2: DDL hints render (incl. multi-form DROP) ── + + #[test] + fn f1_on_create_table_renders_its_hint_block() { + let mut app = App::new(); + type_str(&mut app, "create table Customers with pk id(serial)"); + f1(&mut app); + assert!(output_contains(&app, "Create a new table")); + } + + #[test] + fn f1_disambiguates_drop_forms() { + let mut app = App::new(); + type_str(&mut app, "drop index idx_email"); + f1(&mut app); + // Resolves drop_index, not drop_table/column/etc. + assert!(output_contains(&app, "Remove an index by name")); + assert!(!output_contains(&app, "Remove a table")); + } + #[test] fn messages_command_toggles_verbosity_and_reports() { let mut app = App::new(); diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index 69e7774..d1de91c 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -968,7 +968,13 @@ pub static DROP: CommandNode = CommandNode { shape: DROP_SHAPE, ast_builder: build_drop, help_id: Some("ddl.drop"), - hint_ids: &[], + hint_ids: &[ + "drop_table", + "drop_column", + "drop_relationship", + "drop_index", + "drop_constraint", + ], usage_ids: &[ "parse.usage.drop_table", "parse.usage.drop_column", @@ -1004,7 +1010,7 @@ pub static RENAME: CommandNode = CommandNode { shape: RENAME_COLUMN, ast_builder: build_rename_column, help_id: Some("ddl.rename"), - hint_ids: &[], + hint_ids: &["rename_column"], usage_ids: &["parse.usage.rename_column"],}; pub static CHANGE: CommandNode = CommandNode { @@ -1012,7 +1018,7 @@ pub static CHANGE: CommandNode = CommandNode { shape: CHANGE_COLUMN, ast_builder: build_change_column, help_id: Some("ddl.change"), - hint_ids: &[], + hint_ids: &["change_column"], usage_ids: &["parse.usage.change_column"],}; // ================================================================= @@ -1373,7 +1379,7 @@ pub static CREATE: CommandNode = CommandNode { shape: CREATE_TABLE, ast_builder: build_create_table, help_id: Some("ddl.create"), - hint_ids: &[], + hint_ids: &["create_table"], usage_ids: &["parse.usage.create_table"],}; // ================================================================= @@ -1442,7 +1448,7 @@ pub static CREATE_M2N: CommandNode = CommandNode { shape: CREATE_M2N_SHAPE, ast_builder: build_create_m2n, help_id: Some("ddl.create_m2n"), - hint_ids: &[], + hint_ids: &["create_m2n"], usage_ids: &["parse.usage.create_m2n"], }; diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index 4ba3c1e..1bcdc36 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -917,9 +917,12 @@ mod hint_key_tests { hint_key_for_input_in_mode("insert into T values (1)", Mode::Simple), Some("insert") ); - // A node with no hint_ids yet → None (tier-2 fallback). - assert_eq!(hint_key_for_input_in_mode("drop table T", Mode::Simple), None); - // Unknown entry word → None. + // Multi-form DROP disambiguates to the typed form too. + assert_eq!( + hint_key_for_input_in_mode("drop table T", Mode::Simple), + Some("drop_table") + ); + // Unknown entry word → None (tier-2 fallback). assert_eq!(hint_key_for_input_in_mode("zzz", Mode::Simple), None); } } diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index 3edaaf3..c27c436 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -269,6 +269,43 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("hint.cmd.redo.example", &[]), ("hint.cmd.copy.what", &[]), ("hint.cmd.copy.example", &[]), + // Phase C batch 2 — DDL command hints. + ("hint.cmd.create_table.what", &[]), + ("hint.cmd.create_table.example", &[]), + ("hint.cmd.create_table.concept", &[]), + ("hint.cmd.create_m2n.what", &[]), + ("hint.cmd.create_m2n.example", &[]), + ("hint.cmd.create_m2n.concept", &[]), + ("hint.cmd.add_column.what", &[]), + ("hint.cmd.add_column.example", &[]), + ("hint.cmd.add_column.concept", &[]), + ("hint.cmd.add_index.what", &[]), + ("hint.cmd.add_index.example", &[]), + ("hint.cmd.add_index.concept", &[]), + ("hint.cmd.add_constraint.what", &[]), + ("hint.cmd.add_constraint.example", &[]), + ("hint.cmd.add_constraint.concept", &[]), + ("hint.cmd.drop_table.what", &[]), + ("hint.cmd.drop_table.example", &[]), + ("hint.cmd.drop_table.concept", &[]), + ("hint.cmd.drop_column.what", &[]), + ("hint.cmd.drop_column.example", &[]), + ("hint.cmd.drop_column.concept", &[]), + ("hint.cmd.drop_relationship.what", &[]), + ("hint.cmd.drop_relationship.example", &[]), + ("hint.cmd.drop_relationship.concept", &[]), + ("hint.cmd.drop_index.what", &[]), + ("hint.cmd.drop_index.example", &[]), + ("hint.cmd.drop_index.concept", &[]), + ("hint.cmd.drop_constraint.what", &[]), + ("hint.cmd.drop_constraint.example", &[]), + ("hint.cmd.drop_constraint.concept", &[]), + ("hint.cmd.rename_column.what", &[]), + ("hint.cmd.rename_column.example", &[]), + ("hint.cmd.rename_column.concept", &[]), + ("hint.cmd.change_column.what", &[]), + ("hint.cmd.change_column.example", &[]), + ("hint.cmd.change_column.concept", &[]), ( "hint.ambient_invalid_ident", &["kind", "found"], diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index c785a81..9639ccb 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -456,6 +456,55 @@ hint: copy: what: "Copy the output panel to the clipboard — all of it, or just the last command's output." example: "copy last" + # DDL — schema-shaping commands (Phase C batch 2). + create_table: + what: "Create a new table — its columns, their types, and a primary key." + example: "create table Customers with pk id(serial), name(text), email(text)" + concept: "A table is a set of rows that share the same columns. The primary key uniquely identifies each row; a `serial` key numbers the rows for you." + create_m2n: + what: "Create a junction table linking two tables many-to-many." + example: "create m:n relationship from Students to Courses" + concept: "A many-to-many link (a student takes many courses; a course has many students) can't live in either table, so it gets its own junction table holding a foreign key to each side." + add_column: + what: "Add a new column to an existing table." + example: "add column Customers: phone (text)" + concept: "Existing rows take the column's default, or null. A `not null` column with no default can't be added to a table that already has rows — there'd be nothing to put in them." + add_index: + what: "Create an index on one or more columns to speed up lookups." + example: "add index as idx_email on Customers (email)" + concept: "An index is a sorted side-structure that makes a lookup like `where email = …` fast, at the cost of a little space and slightly slower writes." + add_constraint: + what: "Add a constraint — not null, unique, default, or check — to an existing column." + example: "add constraint not null to Customers.email" + concept: "A constraint is a rule the database enforces on every row. Adding one fails if existing rows already break it, so you fix the data first." + drop_table: + what: "Remove a table and all of its rows." + example: "drop table Customers" + concept: "If other tables reference this one through a relationship, drop those relationships (or their child rows) first — the database won't orphan them." + drop_column: + what: "Remove a column from a table." + example: "drop column Customers: phone" + concept: "The column's values are lost. You can't drop a primary-key column, or one a relationship depends on." + drop_relationship: + what: "Remove a relationship between two tables." + example: "drop relationship customer_orders" + concept: "This drops the foreign-key link and stops the database enforcing it; the tables and their rows stay. The foreign-key column itself remains unless you also drop it." + drop_index: + what: "Remove an index by name." + example: "drop index idx_email" + concept: "Only the lookup shortcut goes — the data is untouched. Queries still work, just without that speed-up." + drop_constraint: + what: "Remove a constraint from a column." + example: "drop constraint not null from Customers.email" + concept: "The rule stops being enforced from now on; rows already stored are left as they are." + rename_column: + what: "Rename a column, keeping its values and type." + example: "rename column Customers: email to contact_email" + concept: "Only the name changes — the stored data is the same. References to the column are reconciled so nothing breaks." + change_column: + what: "Change a column's type, converting the existing values." + example: "change column Customers: status (int)" + concept: "The database converts each stored value to the new type; if a value can't convert it refuses the change, so you don't silently lose data. Flags let you force or skip the conversion." err: foreign_key: child_side: From 9c4d520d5ced4f58d31e711bdf3e8177c52667fe Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Mon, 15 Jun 2026 16:08:57 +0000 Subject: [PATCH 41/50] =?UTF-8?q?feat(hint):=20H2=20Phase=20C=20batch=203?= =?UTF-8?q?=20=E2=80=94=20DML=20tier-3=20hints=20(ADR-0053)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-form hints for querying/changing data: update, delete, show data/table/tables/relationships/indexes, seed, explain, replay (insert was the Phase-B exemplar). hint_ids wired on UPDATE/DELETE/ SHOW/SEED/EXPLAIN/REPLAY; catalogue + keys.rs registered. +2 spot tests (incl. multi-form SHOW disambiguation); 2493 pass / 1 ignored, clippy clean. --- src/app.rs | 19 ++++++++++++++++ src/dsl/grammar/data.rs | 18 ++++++++++----- src/friendly/keys.rs | 30 +++++++++++++++++++++++++ src/friendly/strings/en-US.yaml | 40 +++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 6 deletions(-) diff --git a/src/app.rs b/src/app.rs index d124b2e..98b4fc8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5882,6 +5882,25 @@ mod tests { assert!(!output_contains(&app, "Remove a table")); } + // ── Phase C batch 3: DML hints render (incl. multi-form SHOW) ── + + #[test] + fn f1_on_update_renders_its_hint_block() { + let mut app = App::new(); + type_str(&mut app, "update Customers set email = 'x' "); + f1(&mut app); + assert!(output_contains(&app, "Change values in the rows")); + } + + #[test] + fn f1_disambiguates_show_forms() { + let mut app = App::new(); + type_str(&mut app, "show relationships"); + f1(&mut app); + assert!(output_contains(&app, "List all the relationships")); + assert!(!output_contains(&app, "rows stored in a table")); + } + #[test] fn messages_command_toggles_verbosity_and_reports() { let mut app = App::new(); diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index 752014d..665ba50 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -1790,7 +1790,13 @@ pub static SHOW: CommandNode = CommandNode { shape: SHOW_SHAPE, ast_builder: build_show, help_id: Some("data.show"), - hint_ids: &[], + hint_ids: &[ + "show_data", + "show_table", + "show_tables", + "show_relationships", + "show_indexes", + ], usage_ids: &[ "parse.usage.show_data", "parse.usage.show_table", @@ -1806,7 +1812,7 @@ pub static SEED: CommandNode = CommandNode { shape: SEED_SHAPE, ast_builder: build_seed, help_id: Some("data.seed"), - hint_ids: &[], + hint_ids: &["seed"], usage_ids: &["parse.usage.seed"], }; @@ -1824,7 +1830,7 @@ pub static UPDATE: CommandNode = CommandNode { shape: UPDATE_SHAPE, ast_builder: build_update, help_id: Some("data.update"), - hint_ids: &[], + hint_ids: &["update"], usage_ids: &["parse.usage.update"],}; pub static DELETE: CommandNode = CommandNode { @@ -1832,7 +1838,7 @@ pub static DELETE: CommandNode = CommandNode { shape: DELETE_SHAPE, ast_builder: build_delete, help_id: Some("data.delete"), - hint_ids: &[], + hint_ids: &["delete"], usage_ids: &["parse.usage.delete"],}; pub static REPLAY: CommandNode = CommandNode { @@ -1840,7 +1846,7 @@ pub static REPLAY: CommandNode = CommandNode { shape: REPLAY_PATH, ast_builder: build_replay, help_id: Some("data.replay"), - hint_ids: &[], + hint_ids: &["replay"], usage_ids: &["parse.usage.replay"],}; pub static EXPLAIN: CommandNode = CommandNode { @@ -1848,7 +1854,7 @@ pub static EXPLAIN: CommandNode = CommandNode { shape: EXPLAIN_SHAPE, ast_builder: build_explain, help_id: Some("data.explain"), - hint_ids: &[], + hint_ids: &["explain"], usage_ids: &["parse.usage.explain"],}; /// `explain` over advanced-mode SQL (ADR-0039). diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index c27c436..490e7eb 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -306,6 +306,36 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("hint.cmd.change_column.what", &[]), ("hint.cmd.change_column.example", &[]), ("hint.cmd.change_column.concept", &[]), + // Phase C batch 3 — DML command hints. + ("hint.cmd.update.what", &[]), + ("hint.cmd.update.example", &[]), + ("hint.cmd.update.concept", &[]), + ("hint.cmd.delete.what", &[]), + ("hint.cmd.delete.example", &[]), + ("hint.cmd.delete.concept", &[]), + ("hint.cmd.show_data.what", &[]), + ("hint.cmd.show_data.example", &[]), + ("hint.cmd.show_data.concept", &[]), + ("hint.cmd.show_table.what", &[]), + ("hint.cmd.show_table.example", &[]), + ("hint.cmd.show_table.concept", &[]), + ("hint.cmd.show_tables.what", &[]), + ("hint.cmd.show_tables.example", &[]), + ("hint.cmd.show_relationships.what", &[]), + ("hint.cmd.show_relationships.example", &[]), + ("hint.cmd.show_relationships.concept", &[]), + ("hint.cmd.show_indexes.what", &[]), + ("hint.cmd.show_indexes.example", &[]), + ("hint.cmd.show_indexes.concept", &[]), + ("hint.cmd.seed.what", &[]), + ("hint.cmd.seed.example", &[]), + ("hint.cmd.seed.concept", &[]), + ("hint.cmd.explain.what", &[]), + ("hint.cmd.explain.example", &[]), + ("hint.cmd.explain.concept", &[]), + ("hint.cmd.replay.what", &[]), + ("hint.cmd.replay.example", &[]), + ("hint.cmd.replay.concept", &[]), ( "hint.ambient_invalid_ident", &["kind", "found"], diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 9639ccb..5bf98ed 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -505,6 +505,46 @@ hint: what: "Change a column's type, converting the existing values." example: "change column Customers: status (int)" concept: "The database converts each stored value to the new type; if a value can't convert it refuses the change, so you don't silently lose data. Flags let you force or skip the conversion." + # DML — querying and changing data (Phase C batch 3). + update: + what: "Change values in the rows that match a condition." + example: "update Customers set email = 'new@example.io' where id = 1" + concept: "The `where` clause picks which rows change, and it's required — pass `--all-rows` to change the whole table on purpose — so you never update more than you meant to." + delete: + what: "Remove the rows that match a condition." + example: "delete from Orders where status = 'cancelled'" + concept: "A `where` is required (use `--all-rows` to clear the table on purpose). Rows a relationship points at may be blocked or cascade-deleted, per its `on delete` action." + show_data: + what: "Show the rows stored in a table." + example: "show data Customers" + concept: "This reads the data and never changes it. Add a `where` to show only matching rows." + show_table: + what: "Show a table's structure — its columns, types, keys, and relationships." + example: "show table Customers" + concept: "Structure, not data: the column definitions and how this table links to others. Use `show data` to see the rows themselves." + show_tables: + what: "List all the tables in the project." + example: "show tables" + show_relationships: + what: "List all the relationships between tables." + example: "show relationships" + concept: "Each relationship is a foreign-key link from a child column to a parent's key, with an `on delete` / `on update` rule." + show_indexes: + what: "List all the indexes in the project." + example: "show indexes" + concept: "Indexes speed up lookups; this shows which columns each one covers and whether it enforces uniqueness." + seed: + what: "Fill a table with generated sample rows, or fill one column on existing rows." + example: "seed Customers 50" + concept: "Seeding invents realistic-looking data so you have something to query. Pin a value with `set col = …`, choose a generator with `as`, or give a numeric range with `between`." + explain: + what: "Show how the database will run a query — without running it." + example: "explain show data Customers where email = 'a@example.io'" + concept: "The plan reveals whether the database scans the whole table or jumps straight to rows through an index — the payoff of `add index`. `explain` never executes, so it's safe even on a delete." + replay: + what: "Re-run the commands recorded in a history file." + example: "replay session.log" + concept: "Every successful command is journalled, so replaying re-applies them in order to reproduce a project's state — handy for scripting or redoing a sequence." err: foreign_key: child_side: From 97970f2a2c7da7c71178ea85cfc10c7b9e310adb Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Mon, 15 Jun 2026 16:14:23 +0000 Subject: [PATCH 42/50] =?UTF-8?q?feat(hint):=20H2=20Phase=20C=20batch=204?= =?UTF-8?q?=20=E2=80=94=20advanced-mode=20SQL=20tier-3=20hints=20(ADR-0053?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Distinct SQL-syntax hints for the 11 advanced-mode forms: sql create table / alter table / create index / drop index / drop table / insert / update / delete, select, with, explain. hint_ids wired on all 11 nodes. Hardened hint_key_for_input_in_mode for shared entry words: a bare multi-form entry word defers to tier-2; when the second token isn't a form word (insert into / update … set), it falls back to the mode-primary key — so advanced mode resolves to the SQL form and simple mode to the DSL form. catalogue + keys.rs registered. +2 spot tests + grammar mode-disambiguation asserts; 2495 pass / 1 ignored, clippy clean. --- src/app.rs | 21 +++++++++++++++ src/dsl/grammar/data.rs | 12 ++++----- src/dsl/grammar/ddl.rs | 10 +++---- src/dsl/grammar/mod.rs | 39 +++++++++++++++++++++++++++- src/friendly/keys.rs | 34 ++++++++++++++++++++++++ src/friendly/strings/en-US.yaml | 46 +++++++++++++++++++++++++++++++++ 6 files changed, 150 insertions(+), 12 deletions(-) diff --git a/src/app.rs b/src/app.rs index 98b4fc8..283cd55 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5901,6 +5901,27 @@ mod tests { assert!(!output_contains(&app, "rows stored in a table")); } + // ── Phase C batch 4: advanced-SQL hints (mode-aware) ──────── + + #[test] + fn f1_in_advanced_mode_renders_the_sql_insert_hint() { + let mut app = App::new(); + app.mode = Mode::Advanced; + type_str(&mut app, "insert into Customers (name) values ('x')"); + f1(&mut app); + assert!(output_contains(&app, "Insert rows with SQL")); + assert!(!output_contains(&app, "Add one or more rows")); + } + + #[test] + fn f1_on_select_renders_the_select_hint() { + let mut app = App::new(); + app.mode = Mode::Advanced; + type_str(&mut app, "select name from Customers"); + f1(&mut app); + assert!(output_contains(&app, "heart of SQL")); + } + #[test] fn messages_command_toggles_verbosity_and_reports() { let mut app = App::new(); diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index 665ba50..6441e24 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -1874,7 +1874,7 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode { // too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE` // precedent; otherwise `note_help` would print `explain` twice. help_id: None, - hint_ids: &[], + hint_ids: &["explain_sql"], usage_ids: &[],}; /// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032). @@ -1890,7 +1890,7 @@ pub static SELECT: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL), ast_builder: build_select, help_id: None, - hint_ids: &[], + hint_ids: &["select"], usage_ids: &["parse.usage.select"],}; /// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c). @@ -1905,7 +1905,7 @@ pub static WITH: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL), ast_builder: build_select, help_id: None, - hint_ids: &[], + hint_ids: &["with"], usage_ids: &["parse.usage.with"],}; /// SQL `INSERT` — the `Advanced`-category node of the shared @@ -1923,7 +1923,7 @@ pub static SQL_INSERT: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE), ast_builder: build_sql_insert, help_id: None, - hint_ids: &[], + hint_ids: &["sql_insert"], usage_ids: &[], }; @@ -1937,7 +1937,7 @@ pub static SQL_UPDATE: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE), ast_builder: build_sql_update, help_id: None, - hint_ids: &[], + hint_ids: &["sql_update"], usage_ids: &[], }; @@ -1953,7 +1953,7 @@ pub static SQL_DELETE: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE), ast_builder: build_sql_delete, help_id: None, - hint_ids: &[], + hint_ids: &["sql_delete"], usage_ids: &[], }; diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index d1de91c..83839d8 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -1879,7 +1879,7 @@ pub static SQL_CREATE_TABLE: CommandNode = CommandNode { shape: Node::Subgrammar(&super::sql_create_table::SQL_CREATE_TABLE_SHAPE), ast_builder: build_sql_create_table, help_id: Some("ddl.sql_create_table"), - hint_ids: &[], + hint_ids: &["sql_create_table"], usage_ids: &["parse.usage.sql_create_table"], }; @@ -1899,7 +1899,7 @@ pub static SQL_DROP_TABLE: CommandNode = CommandNode { shape: SQL_DROP_TABLE_SHAPE, ast_builder: build_sql_drop_table, help_id: Some("ddl.sql_drop_table"), - hint_ids: &[], + hint_ids: &["sql_drop_table"], usage_ids: &["parse.usage.sql_drop_table"], }; @@ -1919,7 +1919,7 @@ pub static SQL_DROP_INDEX: CommandNode = CommandNode { shape: SQL_DROP_INDEX_SHAPE, ast_builder: build_sql_drop_index, help_id: Some("ddl.sql_drop_index"), - hint_ids: &[], + hint_ids: &["sql_drop_index"], usage_ids: &["parse.usage.sql_drop_index"], }; @@ -2001,7 +2001,7 @@ pub static SQL_CREATE_INDEX: CommandNode = CommandNode { shape: SQL_CREATE_INDEX_SHAPE, ast_builder: build_sql_create_index, help_id: Some("ddl.sql_create_index"), - hint_ids: &[], + hint_ids: &["sql_create_index"], usage_ids: &["parse.usage.sql_create_index"], }; @@ -2560,7 +2560,7 @@ pub static SQL_ALTER_TABLE: CommandNode = CommandNode { shape: SQL_ALTER_TABLE_SHAPE, ast_builder: build_sql_alter_table, help_id: Some("ddl.sql_alter_table"), - hint_ids: &[], + hint_ids: &["sql_alter_table"], usage_ids: &["parse.usage.sql_alter_table"], }; diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index 1bcdc36..476e060 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -616,10 +616,13 @@ pub fn usage_keys_for_input_in_mode( /// form has no tier-3 block yet (the caller falls back to tier-2). #[must_use] pub fn hint_key_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> { + use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace}; let nodes = selected_nodes_for_input_in_mode(source, mode); if nodes.is_empty() { return None; } + // Mode-ordered union (advanced-primary first in advanced mode), so a + // shared entry word resolves to the surface the user is in. let mut keys: Vec<&'static str> = Vec::new(); for (_, node, _) in &nodes { for k in node.hint_ids { @@ -628,7 +631,25 @@ pub fn hint_key_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Opti } } } - pick_form_key(source, &keys) + if keys.is_empty() { + return None; + } + if keys.len() == 1 { + return Some(keys[0]); + } + // A bare multi-form entry word (no form word yet — `add`⏎) has no + // chosen form: defer to tier-2, which lists the choices. + let start = skip_whitespace(source, 0); + if let Some((_, entry_end)) = consume_ident(source, start) + && skip_whitespace(source, entry_end) >= source.len() + { + return None; + } + // A form word picks the form (`drop column` → `drop_column`); when + // the second token isn't a form word (`insert into …`, `update … + // set`), fall back to the mode-primary key — in advanced mode the + // SQL form, in simple mode the DSL form. + pick_form_key(source, &keys).or_else(|| keys.first().copied()) } /// Shared mode-aware command-form selection for the entry word at the @@ -922,6 +943,22 @@ mod hint_key_tests { hint_key_for_input_in_mode("drop table T", Mode::Simple), Some("drop_table") ); + // Mode picks the surface for a shared entry word whose second + // token isn't a form word: SQL form in advanced, DSL in simple. + assert_eq!( + hint_key_for_input_in_mode("insert into T values (1)", Mode::Advanced), + Some("sql_insert") + ); + assert_eq!( + hint_key_for_input_in_mode("insert into T values (1)", Mode::Simple), + Some("insert") + ); + // `create table` shares a form word — advanced-first ordering + // resolves it to the SQL form in advanced mode. + assert_eq!( + hint_key_for_input_in_mode("create table T (id int)", Mode::Advanced), + Some("sql_create_table") + ); // Unknown entry word → None (tier-2 fallback). assert_eq!(hint_key_for_input_in_mode("zzz", Mode::Simple), None); } diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index 490e7eb..e08ced5 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -336,6 +336,40 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("hint.cmd.replay.what", &[]), ("hint.cmd.replay.example", &[]), ("hint.cmd.replay.concept", &[]), + // Phase C batch 4 — advanced-mode SQL command hints. + ("hint.cmd.sql_create_table.what", &[]), + ("hint.cmd.sql_create_table.example", &[]), + ("hint.cmd.sql_create_table.concept", &[]), + ("hint.cmd.sql_alter_table.what", &[]), + ("hint.cmd.sql_alter_table.example", &[]), + ("hint.cmd.sql_alter_table.concept", &[]), + ("hint.cmd.sql_create_index.what", &[]), + ("hint.cmd.sql_create_index.example", &[]), + ("hint.cmd.sql_create_index.concept", &[]), + ("hint.cmd.sql_drop_index.what", &[]), + ("hint.cmd.sql_drop_index.example", &[]), + ("hint.cmd.sql_drop_index.concept", &[]), + ("hint.cmd.sql_drop_table.what", &[]), + ("hint.cmd.sql_drop_table.example", &[]), + ("hint.cmd.sql_drop_table.concept", &[]), + ("hint.cmd.sql_insert.what", &[]), + ("hint.cmd.sql_insert.example", &[]), + ("hint.cmd.sql_insert.concept", &[]), + ("hint.cmd.sql_update.what", &[]), + ("hint.cmd.sql_update.example", &[]), + ("hint.cmd.sql_update.concept", &[]), + ("hint.cmd.sql_delete.what", &[]), + ("hint.cmd.sql_delete.example", &[]), + ("hint.cmd.sql_delete.concept", &[]), + ("hint.cmd.select.what", &[]), + ("hint.cmd.select.example", &[]), + ("hint.cmd.select.concept", &[]), + ("hint.cmd.with.what", &[]), + ("hint.cmd.with.example", &[]), + ("hint.cmd.with.concept", &[]), + ("hint.cmd.explain_sql.what", &[]), + ("hint.cmd.explain_sql.example", &[]), + ("hint.cmd.explain_sql.concept", &[]), ( "hint.ambient_invalid_ident", &["kind", "found"], diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 5bf98ed..81f1be5 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -545,6 +545,52 @@ hint: what: "Re-run the commands recorded in a history file." example: "replay session.log" concept: "Every successful command is journalled, so replaying re-applies them in order to reproduce a project's state — handy for scripting or redoing a sequence." + # Advanced-mode SQL forms (Phase C batch 4). Examples are SQL, the + # advanced surface — distinct from their simple-mode siblings. + sql_create_table: + what: "Create a table using SQL syntax (advanced mode)." + example: "create table Customers (id int primary key, name text, email text)" + concept: "Advanced mode speaks SQL: constraints go inline (`primary key`, `not null`, `unique`, `check`). This is the raw form of simple mode's `create table … with pk …`." + sql_alter_table: + what: "Change a table's structure with SQL `alter table` (advanced mode)." + example: "alter table Customers add column phone text" + concept: "`alter table` adds or drops columns, renames, and adds constraints — the SQL equivalent of simple mode's `add column` / `drop column` / `change column`." + sql_create_index: + what: "Create an index with SQL (advanced mode)." + example: "create index ix_email on Customers (email)" + concept: "Add `unique` to also forbid duplicate values. The simple-mode equivalent is `add index`." + sql_drop_index: + what: "Remove an index with SQL (advanced mode)." + example: "drop index ix_email" + concept: "Only the lookup shortcut goes; the data is untouched. Add `if exists` to ignore a missing index." + sql_drop_table: + what: "Remove a table with SQL (advanced mode)." + example: "drop table Customers" + concept: "Add `if exists` to avoid an error when the table might not be there. Relationships pointing at it may block the drop." + sql_insert: + what: "Insert rows with SQL (advanced mode)." + example: "insert into Customers (name, email) values ('Ann', 'ann@example.io')" + concept: "Naming the columns lets you supply them in any order and skip ones that have a default — the SQL form of simple mode's `insert`." + sql_update: + what: "Update rows with SQL (advanced mode)." + example: "update Customers set email = 'new@example.io' where id = 1" + concept: "`set` lists the new values; `where` picks which rows change. The SQL form of simple mode's `update`." + sql_delete: + what: "Delete rows with SQL (advanced mode)." + example: "delete from Orders where status = 'cancelled'" + concept: "`where` picks the rows to remove; foreign-key rules still apply. The SQL form of simple mode's `delete`." + select: + what: "Query rows with SQL `select` (advanced mode)." + example: "select name, email from Customers where id = 1" + concept: "`select` is read-only: choose columns (or `*`), filter with `where`, sort with `order by`, cap with `limit`. This is the heart of SQL — and the reason advanced mode exists." + with: + what: "Name a sub-query (a CTE) and read from it in a `select` (advanced mode)." + example: "with recent as (select * from Orders where id > 100) select * from recent" + concept: "A `with` clause (Common Table Expression) names a query so the main `select` can use it like a temporary table — handy for breaking a complex query into readable steps." + explain_sql: + what: "Show how the database will run a SQL query, without running it (advanced mode)." + example: "explain select * from Customers where email = 'a@example.io'" + concept: "Like simple mode's `explain`, but wraps a raw SQL statement. It reveals whether an index is used, and never executes." err: foreign_key: child_side: From b6b98ad30f708958630ef85aafd23c5a2cf96ab3 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Mon, 15 Jun 2026 16:16:49 +0000 Subject: [PATCH 43/50] =?UTF-8?q?feat(hint):=20H2=20Phase=20C=20batch=205?= =?UTF-8?q?=20=E2=80=94=20runtime=20error-class=20tier-3=20hints=20(ADR-00?= =?UTF-8?q?53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit what/example(fix recipe)/concept for the 9 runtime error classes: foreign_key parent_side (child_side was the exemplar), unique, not_null, check, type_mismatch, not_found, already_exists, generic, invalid_value. Keyed by friendly::error_hint_class; catalogue + keys.rs registered. +1 spot test; 2496 pass / 1 ignored, clippy clean. --- src/app.rs | 11 ++++++++++ src/friendly/keys.rs | 26 +++++++++++++++++++++++ src/friendly/strings/en-US.yaml | 37 +++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/src/app.rs b/src/app.rs index 283cd55..f62da1f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5922,6 +5922,17 @@ mod tests { assert!(output_contains(&app, "heart of SQL")); } + // ── Phase C batch 5: runtime error-class hints render ─────── + + #[test] + fn hint_renders_a_runtime_error_block() { + let mut app = App::new(); + app.last_error_hint_key = Some("unique".to_string()); + type_str(&mut app, "hint"); + submit(&mut app); + assert!(output_contains(&app, "must be unique")); + } + #[test] fn messages_command_toggles_verbosity_and_reports() { let mut app = App::new(); diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index e08ced5..bb945ff 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -234,6 +234,32 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("hint.err.foreign_key.child_side.what", &[]), ("hint.err.foreign_key.child_side.example", &[]), ("hint.err.foreign_key.child_side.concept", &[]), + // Phase C batch 5 — runtime error-class hints. + ("hint.err.foreign_key.parent_side.what", &[]), + ("hint.err.foreign_key.parent_side.example", &[]), + ("hint.err.foreign_key.parent_side.concept", &[]), + ("hint.err.unique.what", &[]), + ("hint.err.unique.example", &[]), + ("hint.err.unique.concept", &[]), + ("hint.err.not_null.what", &[]), + ("hint.err.not_null.example", &[]), + ("hint.err.not_null.concept", &[]), + ("hint.err.check.what", &[]), + ("hint.err.check.example", &[]), + ("hint.err.check.concept", &[]), + ("hint.err.type_mismatch.what", &[]), + ("hint.err.type_mismatch.example", &[]), + ("hint.err.type_mismatch.concept", &[]), + ("hint.err.not_found.what", &[]), + ("hint.err.not_found.example", &[]), + ("hint.err.not_found.concept", &[]), + ("hint.err.already_exists.what", &[]), + ("hint.err.already_exists.example", &[]), + ("hint.err.already_exists.concept", &[]), + ("hint.err.generic.what", &[]), + ("hint.err.generic.example", &[]), + ("hint.err.invalid_value.what", &[]), + ("hint.err.invalid_value.example", &[]), // Phase C batch 1 — app-lifecycle command hints. ("hint.cmd.quit.what", &[]), ("hint.cmd.quit.example", &[]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 81f1be5..8fd4cbb 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -592,11 +592,48 @@ hint: example: "explain select * from Customers where email = 'a@example.io'" concept: "Like simple mode's `explain`, but wraps a raw SQL statement. It reveals whether an index is used, and never executes." err: + # Runtime error classes (Phase C batch 5), keyed by + # friendly::error_hint_class. `example` is a fix recipe rather than a + # runnable line; `concept` is the relational idea behind the rule. foreign_key: child_side: what: "The value you gave for the child column doesn't match any parent row, so the foreign key has nothing to point at." example: "First insert the parent (insert into Customers …), then the child that references it." concept: "A foreign key is a promise that every child points at a real parent, so the parent must exist first. To allow orphans on delete instead, set the relationship's `on delete` to `set null` or `cascade`." + parent_side: + what: "You're deleting or changing a row that other rows point at, which would orphan those children." + example: "Delete the child rows first, or set the relationship's `on delete` to `cascade` (remove them too) or `set null` (keep them, unlinked)." + concept: "A foreign key guarantees every child has a real parent, so the database won't remove a parent out from under its children unless the relationship says what should happen to them." + unique: + what: "A value you're inserting — or updating to — already exists in a column that must be unique." + example: "Pick a different value, or update the existing row instead of inserting a new one." + concept: "A unique constraint (and every primary key) forbids duplicates, so each value identifies at most one row." + not_null: + what: "You left a column empty that is required to have a value." + example: "Supply a value for the column, or give it a default so new rows fill it automatically." + concept: "A `not null` constraint means every row must have a value there — it's how you mark a fact as mandatory." + check: + what: "A value broke a `check` rule defined on the column." + example: "Use a value the rule allows — for example a positive number, or one of the permitted options." + concept: "A `check` constraint is a condition every row must satisfy, so the database enforces business rules like \"price ≥ 0\" for you." + type_mismatch: + what: "A value doesn't fit the column's type — for instance text where a number is expected." + example: "Give a value of the right type: a number for `int`/`real`, a quoted string for `text`, true/false for `bool`." + concept: "Every column has a type, and the database rejects values that don't fit, so a column's data stays consistent and comparable." + not_found: + what: "You named a table or column that doesn't exist." + example: "Check the spelling, or run `show tables` (or `show table `) to see what's there." + concept: "A command can only refer to tables and columns that already exist — create them first if you need them." + already_exists: + what: "You tried to create a table, column, relationship, or index whose name is already taken." + example: "Pick a different name, or drop the existing one first if you meant to replace it." + concept: "Names must be unique within their kind so a command is never ambiguous about what it refers to." + generic: + what: "The database refused the command for the reason shown above." + example: "Read that message for the specifics, adjust the command, and try again." + invalid_value: + what: "A value or option in the command wasn't valid for where it was used." + example: "Check the value against the column's type and the command's accepted options." # Invalid identifier in a schema slot (ADR-0022 stage 8e # + the user's #5). Voice mirrors ADR-0019's "no such # {kind}" wording for consistency with engine errors. From 417cbc8df95af4d23ff0f56e5951f38f64821e40 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Mon, 15 Jun 2026 16:28:54 +0000 Subject: [PATCH 44/50] docs(hint): defer pre-submit-diagnostic route + diagnostic.* blocks (ADR-0053) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase C scope decision: Diagnostic carries no class key, so the F1 diagnostic route would need a class field threaded through every diagnostic site (broad change) for marginal value — tier-2 already surfaces diagnostics and many duplicate the runtime error classes. Defer the route + the ~33 diagnostic.* tier-3 blocks to issue #38. v1 ships command-form hints + 9 runtime error-class hints (comprehensive for those). Updates ADR-0053 D2/D6/Status/OOS + README. --- ...-contextual-hint-command-and-keybinding.md | 106 ++++++++++-------- docs/adr/README.md | 2 +- 2 files changed, 61 insertions(+), 47 deletions(-) diff --git a/docs/adr/0053-contextual-hint-command-and-keybinding.md b/docs/adr/0053-contextual-hint-command-and-keybinding.md index 7bac135..dfe3081 100644 --- a/docs/adr/0053-contextual-hint-command-and-keybinding.md +++ b/docs/adr/0053-contextual-hint-command-and-keybinding.md @@ -10,10 +10,13 @@ Phase B implementation (2026-06-15): the first exemplar showed per-*node* keying is too coarse for multi-form commands (`add`/`drop`/`show`/ `create`), so D3 now keys tier-3 content **per form** via a `hint_ids: &[&str]` array mirroring `usage_ids` — and **clause-concept -hints** are recorded as a deferred extension (separate tracking issue). -The parallel question of whether the in-app `help` command should -likewise distinguish advanced-SQL forms is tracked **separately** as -Gitea issue #36 (it touches shipped, ADR-backed `help` behaviour). +hints** are recorded as a deferred extension (issue #37). During Phase C +the **pre-submit-diagnostic route + the ~33 `diagnostic.*` blocks** were +**deferred** (issue #38) — `Diagnostic` doesn't carry its class key, so +the route needs a broad change for marginal value (D6). v1 therefore +ships command-form hints + the 9 runtime error-class hints. The parallel +question of whether the in-app `help` command should likewise distinguish +advanced-SQL forms is tracked **separately** as Gitea issue #36. Decided in conversation 2026-06-14. Closes the last open piece of **A1** (the canonical app-command set, ADR-0003): every app command is @@ -112,19 +115,13 @@ F1 is inert behind a modal and while a sidebar panel holds navigation focus (consistent with the existing `handle_key` gates, ADR-0046); it is active in the input context in both Simple and Advanced mode. -**Two error sources, one namespace.** Errors come in two kinds and reach -`hint` by different routes: - -- **Pre-submit diagnostics** (the ~33 `diagnostic.*` classes — arity, - type, unknown table/column) are computed *while typing* by the walker. - The **F1 live-input path** reads the current under-cursor diagnostic - directly from the walker (the same source the ambient panel uses) and - renders its `hint.err.` block — no stored state needed. -- **Runtime errors** (the 9 `translate_error` classes) occur *after* - submit. The **`hint` command / empty-input F1** path reads them via the - stored `last_error_hint_key` (D5). - -Both render from the same `hint.err.*` namespace. **`:`-prefix handling:** +**Error routes.** **Runtime errors** (the 9 `translate_error` classes) +occur *after* submit; the **`hint` command / empty-input F1** path reads +them via the stored `last_error_hint_key` (D5) and renders their +`hint.err.` block. (A second route for **pre-submit diagnostics** +on the F1 live-input path was specified but is **deferred** — D6 / issue +#38; with a diagnostic present, F1 shows the command block and tier-2 +shows the diagnostic.) **`:`-prefix handling:** on the simple-mode one-shot escape (`: SELECT …`), command identification for the F1 path strips the leading `:` first, so the advanced form is matched. @@ -220,26 +217,37 @@ Option`** — set at the `translate_error` call sites cleared when a later command succeeds. Absent → the "getting started" pointer. -The **pre-submit-diagnostic route** (the F1 live-input path) needs no -stored state: it reads the current diagnostic from the walker at F1 time -(D2). This is the cleaner split the `/runda` pass surfaced — typing-time -diagnostics and post-submit runtime errors are genuinely different -sources and should not be funnelled through one stored key. +The **pre-submit-diagnostic route** (the F1 live-input path reading the +under-cursor diagnostic) is **deferred** — see the scope note in D6. -### D6 — Content scope: comprehensive for v1 +### D6 — Content scope for v1 -v1 ships tier-3 content for the **whole inventory**, not a subset (the -graceful tier-2 fallback below is a safety net, not the plan): +v1 ships tier-3 content for the **command forms and runtime error +classes** — comprehensive for those (the graceful tier-2 fallback below +is a safety net, not the plan): - **~37 command forms** — every distinct node in `REGISTRY` gets its own `hint.cmd.` block (app + DSL + DDL + advanced-mode SQL forms), each with a **mode-correct example** (the advanced-SQL forms show SQL syntax, their simple siblings show DSL — no sharing). -- **9 runtime error classes** — `unique`, `foreign_key` (×4 sides), - `not_null`, `check`, `type_mismatch`, `not_found`, `already_exists`, - `generic`, `invalid_value` — each gets a `hint.err.*` block. -- **~33 `diagnostic.*` pre-submit classes** — arity, type, unknown - table/column, etc. — each gets a `hint.err.*` block. +- **9 runtime error classes** — `unique`, `foreign_key` (child/parent + side), `not_null`, `check`, `type_mismatch`, `not_found`, + `already_exists`, `generic`, `invalid_value` — each gets a + `hint.err.*` block. + +**Deferred — the ~33 `diagnostic.*` pre-submit classes and the F1 +diagnostic route** *(Phase C scope decision, 2026-06-15; issue #38)*. The +original "comprehensive" scope included them, but implementation revealed +`Diagnostic` (`walker/outcome.rs`) carries only its rendered `message`, +not its class key — so a live diagnostic can't be mapped to +`hint.err.` without adding a `class` field threaded through every +diagnostic-creation site (a broad change). Weighed against the value, it +isn't worth it for v1: pre-submit diagnostics are already surfaced by +tier-2 (ambient message + validity indicator, ADR-0027); F1 still shows +the useful command block when a diagnostic is present; and many +diagnostic classes duplicate runtime classes already covered +(`type_mismatch`, `unknown_table`↔`not_found`, arity↔`invalid_value`). +Deferred to issue #38, additively (the keying doesn't lock it out). The full enumerated checklist is the implementation plan's tracking artifact (see *Content inventory*, below). @@ -339,26 +347,26 @@ Hint — add relationship `App` state (`last_error_hint_key`), and one new renderer family (`note_hint*`); the `AppCommand` enum gains `Hint`, the grammar a `HINT` node, the REGISTRY one entry. -- **A large, durable content corpus** (~37 command blocks + ~42 error/ - diagnostic blocks ≈ 80) enters the catalogue under `hint.cmd.*` / +- **A durable content corpus** (~37 command blocks + 10 runtime + error-class blocks) enters the catalogue under `hint.cmd.*` / `hint.err.*`, validated by `keys.rs`. This is ongoing surface area: new commands/error classes should ship with their tier-3 hint (a checklist - item for future feature ADRs). + item for future feature ADRs). (Diagnostic-class blocks deferred — #38.) - **Testing:** Tier-1 unit tests for the trigger matrix (F1 with empty/non-empty input; `hint` with/without a recent error; `last_error_hint_key` set on the `translate_error` sites and cleared on - success; the pre-submit-diagnostic vs runtime-error routing; the `:` - strip), the command-identification logic, and the tier-2 fallback; - Tier-2 `insta` snapshots for a representative rendered hint block; - Tier-3 integration tests for the end-to-end flows (type a partial - command → F1 → block appears, **buffer and completion memo untouched**; - run a failing command → `hint` → error expansion). **A - comprehensiveness coverage test** (enforces D6): iterate the REGISTRY - and assert every node has a `hint_id` resolving to a `hint.cmd.*` block, - and every runtime-error/diagnostic class has a `hint.err.*` block — - `keys.rs` only checks that *referenced* keys resolve, not that every - command/error *has* one, so this test is what makes "comprehensive" - enforceable rather than aspirational. + success; the mode-aware form resolution; the `:` strip), the + command-identification logic, and the tier-2 fallback; Tier-2 `insta` + snapshots for a representative rendered hint block; Tier-3 integration + tests for the end-to-end flows (type a partial command → F1 → block + appears, **buffer and completion memo untouched**; run a failing + command → `hint` → error expansion). **A comprehensiveness coverage + test** (enforces D6): iterate the REGISTRY and assert every node with a + `hint_ids` entry resolves to a `hint.cmd.*` block, and every runtime + error class resolves to a `hint.err.*` block — `keys.rs` only checks + that *referenced* keys resolve, not that every command/error *has* one, + so this test is what makes the scope enforceable rather than + aspirational. (Diagnostic classes are out of this scope — D6 / #38.) ## Out of scope @@ -381,6 +389,11 @@ Hint — add relationship recognized clause, deeper than tier-2's candidate list but narrower than the per-form block. Per-form keying (D3) does not lock it out. To be tackled as a deliberate follow-up job, not gated on usage statistics. +- **Pre-submit-diagnostic route + `diagnostic.*` tier-3 blocks** — OOS + (deferred, issue #38): needs a class field on `Diagnostic` threaded + through every creation site (broad change) for marginal value, since + tier-2 already surfaces diagnostics and many duplicate runtime classes + (D6). ## Content inventory (implementation tracking) @@ -401,4 +414,5 @@ The implementation plan enumerates and checks off every block: - **`hint.err.*`** — one per runtime error class (`unique`, `foreign_key.{child,parent}_side`, `not_null`, `check`, `type_mismatch`, `not_found`, `already_exists`, `generic`, - `invalid_value`) and per `diagnostic.*` pre-submit class. + `invalid_value`). The `diagnostic.*` pre-submit classes are **deferred** + (D6 / issue #38). diff --git a/docs/adr/README.md b/docs/adr/README.md index aaa1e8a..6a8a3e9 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -58,4 +58,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0050 — Incidental-DDL confirmations omit relationship info (structure-only)](0050-incidental-ddl-confirmations-omit-relationships.md) — **Accepted + implemented 2026-06-12 (issue #28)**, closes Gitea **#28**. **Supersedes** the incidental-DDL clause of **ADR-0044 §1** and the relationship-block half of **ADR-0016 §5**. Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/`rename`/`change column`, `add`/`drop index`) now render **structure only** — header + column box + `Indexes:` + constraints — with **no `References:` / `Referenced by:` block** (neither prose nor diagram), even when the table carries relationships the user did not touch. Rationale (owner): a confirmation echo reports the change just made, not untouched relationships; ADR-0044's terse prose was the lesser of "prose vs diagram", but the right answer for these surfaces is **neither**. **Relationship-subject surfaces are unchanged** — `show table`, `add`/`drop relationship`, `show relationship` still render ADR-0044 diagrams; relationships appear only when the user asks for (`show table`) or acts on (`add`/`drop relationship`) one, and are one `show table ` away — **no information lost**. Forks both user-chosen: **scope = all incidental DDL** (not just `add column` — the rationale is uniform, the mental model clean, and it's the simpler edit) and **delete the prose renderer** (not retain it dormant — no dead code). **Mechanism:** the `handle_dsl_success` `matches!` routing is unchanged (relationship-subject → diagrams; else → `render_structure`); the change is one line inside `render_structure` (`output_render.rs` — drop the relationship-block call) since all its callers are incidental DDL, plus deletion of the orphaned `relationship_prose_lines` + `cols_disp` helpers. The prose format survives in ADR-0016 §5 + git history for a future OOS-7 always-prose setting. **Tests:** the prose-presence unit test + its snapshot removed; a new unit test asserts `render_structure` on a description carrying **both** inbound and outbound relationships emits the box but no prose; the misnamed `add_relationship_flow_shows_inbound_section_on_parent` integration test (which sent an `AddColumn`) inverted + renamed to assert the add-column echo omits the prose; the diagram tests (`show table`, `add relationship`) unaffected. **2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. `requirements.md` unaffected (ADR-tracked refinement of a decided area, like ADR-0044 itself) - [ADR-0051 — Bottom keybinding strip: context- and state-aware](0051-context-state-aware-keybinding-strip.md) — **Accepted + implemented 2026-06-13 (issue #27)**, closes Gitea **#27**. Repurposes the bottom status line into a **keystrokes-only, state-selected** strip (builds on ADR-0046 nav focus, ADR-0003 modes, ADR-0049 the #29 readline keys it now advertises, ADR-0022 the completion memo). A pure `status_bar_bindings(app) -> Vec<(key,label)>` chooses the strip by **priority, first match wins**: (1) **sidebar focus** → `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input`; (2) **completion memo live** (`last_completion`) → `Tab/Shift-Tab cycle · Esc cancel · Enter run`; (3) **history navigation** (new `App::is_browsing_history()` exposing the private `history_cursor`) → `↑↓ browse · Esc clear · Enter run`; (4) **editing** (input non-empty) → `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` (surfaces the #29 keys, closing ADR-0049's deferred advertisement); (5) **default** (empty) → `Ctrl-O sidebar · Tab complete · ↑ history · Enter run`. Priority is correct because Up clears the completion memo and Tab cancels history nav, so states 2/3 never co-occur, and the five are exhaustive for Input focus. **Typed-command words leave the strip** (`mode advanced`/`mode simple` switch, `:` one-shot) and **mode discovery moves to the empty-input hint** (`resolve_hint_lines`), **simple mode only**: `\`mode advanced\` for SQL` (the verb "type" omitted — the prompt implies it; advanced mode shows **no** pointer per a post-trial user decision — a switcher knows how they got there and `help` covers the way back). The one-shot's old `Backspace cancel one-shot` label is subsumed by the editing state (behaviour intact). Forks all user-chosen: **editing state shows the #29 keys** (vs unadvertised); **`Ctrl-C quit` omitted** from the strip (vs always shown); **no width-drop machinery** — the longest strip (~65 cols) fits all supported widths, so a **width-budget unit test** keeps it lean by construction instead (the user's own observation). Catalog: 12 new `shortcut.*` labels + the `panel.hint_mode_advanced` string added to `en-US.yaml`+`keys.rs` (validator-checked 1:1), 5 now-dead strip strings removed. **Modal-aware strip is OOS** (pre-existing: a modal owns the keyboard and carries its own hints; the strip under it is unchanged-in-kind, not worsened). Tests: 9 Tier-1 unit (per-state key sets — completion/history driven through real key events; width budget; mode-pointer presence/absence), 1 Tier-3 rewritten (`status_bar_is_keystroke_only_and_state_aware`), 15 full-panel snapshots re-accepted (reviewed — strip/hint only). **2467 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: modal-aware strip; a full-key cheatsheet overlay; Ctrl-K/U advertisement (editing strip shows the highest-value subset within the width budget) - [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (status field gains a `:adv` tag; **journaling moves from the worker to the dispatch layer**), **ADR-0015 §5/§6** (history.log leaves the worker transaction — `commit-db-last` now scopes yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure is best-effort, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression). **Follow-up done 2026-06-14:** the vestigial worker `source` plumbing was fully unwound (compiler-guided, no behaviour change) — `_source` removed from `finalize_persistence`/`do_rebuild_from_text`, the three `*_request` wrappers inlined+deleted, the dead `source` param dropped from the ~30 forwarding worker handlers, and the `source` field removed from the `DescribeTable`/`QueryData`/`RunSelect` requests + their `DatabaseHandle` methods (~164 mostly-test call sites); the only worker `source` left is the snapshot/undo label (see ADR-0052 *Consequences*) -- [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implementation in progress (2026-06-14; Phase A done, Phase B underway)**. Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help ` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.` (per command form) and `hint.err.` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed per-form via a new `hint_ids: &[&str]` field on `CommandNode` mirroring `usage_ids`** (revised in Phase B): a per-*node* key proved too coarse — `add`/`drop`/`show`/`create` are each one node spanning many forms, and a live-input hint for `add 1:n relationship` must be specific to relationships; `hint_key_for_input_in_mode` reuses `usage_key_for_input_in_mode`'s form-word disambiguation, and covers the advanced-SQL forms whose `usage_ids` are empty. Not keyed off `help_id` (it is `None` on the advanced-SQL nodes purely to dedup the `help` list; that parallel gap is issue **#36**). **Clause-concept hints** (`on delete` actions, constraint slots, `with pk`, cardinality) are a recorded **deferred extension** (`hint.concept.`, issue **#37**) — per-form is the right tier-3 granularity, with position-awareness owned by tier-2 + the live `Next:` line. Two error routes share `hint.err.*`: pre-submit `diagnostic.*` read live from the walker (F1 path), runtime `translate_error` classes via stored `last_error_hint_key` (`hint` command / empty-F1). Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_ids` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): **comprehensive for v1** — ~37 command forms + 9 runtime error classes + ~33 `diagnostic.*` classes ≈ 80 teaching blocks — authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test** (every node/error class has a key), with graceful fall-back to tier-2 if a key is ever missing. Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive scope; exemplars-first. OOS: per-topic `hint ` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); the `help`-side advanced-SQL gap (issue #36) +- [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implementation in progress (Phases A–C done 2026-06-15: mechanism + per-form keying + the command-form & runtime-error content; Phase D polish next)**. Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help ` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.` (per command form) and `hint.err.` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed per-form via a new `hint_ids: &[&str]` field on `CommandNode` mirroring `usage_ids`** (revised in Phase B): a per-*node* key proved too coarse — `add`/`drop`/`show`/`create` are each one node spanning many forms, and a live-input hint for `add 1:n relationship` must be specific to relationships; `hint_key_for_input_in_mode` reuses `usage_key_for_input_in_mode`'s form-word disambiguation, and covers the advanced-SQL forms whose `usage_ids` are empty. Not keyed off `help_id` (it is `None` on the advanced-SQL nodes purely to dedup the `help` list; that parallel gap is issue **#36**). **Clause-concept hints** (`on delete` actions, constraint slots, `with pk`, cardinality) are a recorded **deferred extension** (`hint.concept.`, issue **#37**) — per-form is the right tier-3 granularity, with position-awareness owned by tier-2 + the live `Next:` line. Runtime `translate_error` classes resolve via stored `last_error_hint_key` (`hint` command / empty-F1). (The second route — pre-submit `diagnostic.*` read live from the walker on the F1 path — is **deferred**, issue **#38**: `Diagnostic` carries no class key.) Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_ids` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): v1 scope = ~37 command forms + 9 runtime error classes (comprehensive for those, ~57 blocks), authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test**, with graceful fall-back to tier-2 if a key is ever missing. The **pre-submit-diagnostic route + ~33 `diagnostic.*` blocks were deferred** (issue **#38**) — `Diagnostic` carries no class key, so the route needs a broad change for marginal value (tier-2 already surfaces diagnostics; many duplicate runtime classes). Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive-for-commands-and-errors scope; exemplars-first; diagnostics deferred. OOS: per-topic `hint ` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); clause-concept hints (issue #37); the diagnostic route (issue #38); the `help`-side advanced-SQL gap (issue #36) From 984bc30256b559003be5935ff720b74e62c473ad Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Mon, 15 Jun 2026 16:31:58 +0000 Subject: [PATCH 45/50] =?UTF-8?q?docs:=20record=20CI=20branch=20work=20?= =?UTF-8?q?=E2=80=94=20D1/D2=20done,=20TT5=20partial,=20handoff=2070?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requirements.md: D1 (all six cross-platform binaries) and D2 (no-runtime- deps, per-platform) done; D3 noted (binaries shipped, package managers pending); TT5 partial (gate + macOS test live; Windows build-only; Tier-4 unwired). CLAUDE.md: add the CI/release decision (-> docs/ci/adr) + update the deferred list. Adds handoff 70 summarising the pipeline + follow-ups (incl. the versioning gap). --- CLAUDE.md | 27 +++++++- docs/handoff/20260615-handoff-70.md | 101 ++++++++++++++++++++++++++++ docs/requirements.md | 37 +++++++++- 3 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 docs/handoff/20260615-handoff-70.md diff --git a/CLAUDE.md b/CLAUDE.md index bfd358d..483b89d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,6 +108,23 @@ Current decisions at a glance (each backed by an ADR): SQL `select` / `with` / `insert` / `update` / `delete` (ADR-0039). `EXPLAIN QUERY PLAN` never executes, so explaining a destructive command is safe. +- **Continuous integration & release** (built on the `ci` branch, + 2026-06-15; decisions in `docs/ci/adr/` — **ADR-ci-001/002/003**, + a namespace kept separate from the main ADR sequence to avoid + cross-branch number collisions, like the website's): a self-hosted + **Gitea Actions** pipeline built on a **nix flake** (pinned Rust + `1.95.0` — one source of toolchain for dev *and* CI) plus a + prebuilt CI image. **Gate** (`ci.yaml`): `clippy -D warnings` + + `cargo test` on every branch push / PR. **Release** on a `v*` tag + (`release.yaml`): the four non-macOS **D1** targets cross-built + with `cargo-zigbuild` (Linux musl static + standalone Windows + `.exe`); the two macOS targets via the **dispatched** + `release-macos.yaml` on a Tart Apple-Silicon runner (de-nix the + `libiconv` load path + ad-hoc re-sign). All published to a Gitea + release with `.sha256`s. **`fmt` is intentionally not gated yet** + (the tree isn't stock-`rustfmt`-clean). `workflow_dispatch` is + Gitea-default-branch-only, so `release-macos` is dispatchable once + this lands on `main`. ## Repository layout @@ -344,8 +361,14 @@ not yet implemented: Ctrl-Enter submits. - **Tab completion** (I3), **syntax highlighting** (I4). - **ER diagram export** (V3). -- **CI** (TT5): test infrastructure exists; CI workflow not - yet configured. +- **Full TT5** (CI): the pipeline is live (see the CI decision + above / `docs/ci/adr/`), but "all tiers on all OSes" isn't + complete — **Windows is build-only** (cross-compiled, not + executed: no Windows runner) and **Tier 4** (PTY, TT4) isn't + wired in CI. +- **D3 packaging**: prebuilt binaries + checksums ship to Gitea + releases, but the Homebrew / Scoop / winget / `cargo binstall` + manifests are not done. ## Handoff notes diff --git a/docs/handoff/20260615-handoff-70.md b/docs/handoff/20260615-handoff-70.md new file mode 100644 index 0000000..6bbd741 --- /dev/null +++ b/docs/handoff/20260615-handoff-70.md @@ -0,0 +1,101 @@ +# Session handoff — 2026-06-15 (70) + +Seventieth handover. A dedicated infrastructure session, run on a separate +**`ci`** branch (in a worktree), that built the project's **entire CI/CD +pipeline** on the self-hosted Gitea Actions runner — from nothing to a live +gate plus a six-target cross-platform release. Net: the **CI** / +`requirements.md` **TT5** item and **D1**/**D2** are now done; **D3** and a +couple of TT5 tails remain. Decisions are recorded in a new ADR namespace, +**`docs/ci/adr/`** (ADR-ci-001/002/003), kept separate from the main integer +ADR sequence to avoid cross-branch number collisions (the same split the +website branch uses — and it paid off: `main` independently took ADR-0049 this +period, which would have collided). + +## §1. State at handoff + +**Branch:** `ci` (worktree). **`main` has been merged into `ci`** (commit +`138e766`, clean — `ci` and `main` touched disjoint files) so the gate runs +against current `main` before CI lands there. Working tree clean except the +in-progress doc updates from this handoff. Pushes/promotion are the user's +step. + +**Gate verified locally on the merged code:** `clippy -D warnings` clean; +**`cargo test` 2488 passing / 0 failing / 1 ignored** (the long-standing +`friendly` doctest). main's features came in with their tests (2424 → 2488). + +**Pipeline (`.gitea/workflows/`):** + +- `build-ci-image.yaml` — builds + pushes the CI image (`node:22-bookworm-slim` + + single-user nix + the flake's devShell pre-warmed) to the Gitea registry. + Triggers only on image-input changes (Dockerfile / flake / toolchain). +- `ci.yaml` — the gate: `clippy -D warnings` + `cargo test`, branch pushes + PRs + (docs-only changes skipped). +- `release.yaml` — on a `v*` tag: `test` → `build` matrix over the **four + non-macOS** targets via `cargo-zigbuild`, upload to the Gitea release. +- `release-macos.yaml` — **workflow_dispatch** (tag input) on the Tart + Apple-Silicon runner (`runs-on: macos`): test → build both `*-apple-darwin` + → de-nix `libiconv` + ad-hoc re-sign → upload. + +**Verified live this session:** the 4-target release published **8 assets** +(binary + `.sha256` each) for tag `v.0.0.0-citest3`; the macOS build was proven +portable (system-only deps) + signed + launches on the runner. + +## §2. What was built (and the non-obvious bits) + +- **Nix flake** (ADR-ci-002, relocated from a would-be `main` ADR-0049): one + pinned toolchain (`1.95.0`) for dev *and* CI; `cargo-zigbuild` + `zig` (Linux + only) for the cross targets; `apple-sdk` on darwin. +- **Runner facts** (ADR-ci-001): jobs run *inside* a container (`ci-public` → + `catthehacker/ubuntu`), so host nix is unreachable — hence the baked image. + The Mac runner is **host execution**; its label is `macos` (`:host` in the + registration is the act_runner backend, not part of the label). +- **Cross-compile** (ADR-ci-003): `cargo-zigbuild` for the 4 non-macOS targets. + Windows needs an **empty `libsynchronization.a` stub** (`ci/winstub/`, wired + via `.cargo/config.toml`) — std links `-lsynchronization`, absent from + rust-overlay's toolchain + zig's mingw, but forwarded by `kernel32`. +- **macOS** (ADR-ci-003 amendment): built on **real Apple hardware** (Tart), so + the SDK is fully licensed — no osxcross grey area. The darwin stdenv bakes a + `/nix/store` `libiconv` path into the binary; the build rewrites it to + `/usr/lib/libiconv.2.dylib` (`install_name_tool`) and re-signs ad-hoc + (`codesign -f -s -`; `install_name_tool` invalidates the signature, arm64 + refuses unsigned). A guard fails the build on any remaining `/nix/store` dep. +- **Cache hygiene (Mac):** the runner wipes the workspace each run, so cargo + `target/` never accumulates; the persistent nix store is bounded by + **generation** (record the devShell in a persistent profile, keep the 2 + newest via `nix-env --delete-generations +2`, GC the rest). First sweep + reclaimed a ~3.8 GB one-time backlog of build scaffolding (source + build-only + deps, *not* re-installed toolchains). + +## §3. Immediate next steps (user) + +1. **Push `ci`** → the gate re-runs in CI (should be green; no image rebuild — + the merge didn't touch the flake/Dockerfile). +2. **Promote:** `git checkout main && git merge ci` — a **fast-forward** (`ci` + already contains `main`) — then push `main`. CI goes live; `release-macos` + becomes dispatchable (workflow_dispatch needs the default branch). +3. **First real release:** tag `v0.1.0` (auto-builds the 4 Linux/Windows + assets), then **dispatch `release-macos` for `v0.1.0`** with the Mac up (adds + the 2 macOS assets) → a full 6-binary release. +4. **Cleanup:** delete the `v.0.0.0-citest*` test tags + their releases. +5. **Runner-side:** add `min-free`/`max-free` to the Mac's `/etc/nix/nix.conf` + as a hands-off nix-store backstop. + +## §4. Known gaps / follow-ups + +- **Versioning is not wired into the binary** (flagged by the user). The release + **git tag is nowhere in the produced binary** — there is no `--version` flag, + no `CARGO_PKG_VERSION` use anywhere in `src/`, and the release workflows use + the tag only for the *release name* + *asset filenames* + (`rdbms-playground--`). `Cargo.toml` is a static `version = + "0.1.0"`, decoupled from the tag. So a `v0.5.0` tag yields a `…-v0.5.0-…` + asset whose binary knows nothing of "0.5.0". To fix later: add a `--version` + flag, and inject the tag at build time (e.g. a `build.rs` reading a + CI-provided env, or bumping `Cargo.toml` as part of tagging) so the binary and + the release agree. +- **D3 packaging** — Homebrew / Scoop / winget / `cargo binstall` manifests + (asset naming is already binstall-friendly). +- **TT5 tails** — Windows is build-only (no execution runner); Tier-4 PTY (TT4) + is unwired in CI. +- **`fmt` gate** — deliberately off (tree isn't stock-`rustfmt`-clean); revisit + on `main`. +- **Website → Cloudflare** deploy — the separate, simpler workflow, still to do. diff --git a/docs/requirements.md b/docs/requirements.md index d3fc046..eeb7c79 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -61,11 +61,32 @@ since ADR-0027.) ## Distribution and install -- [ ] **D1** Cross-platform binaries: Linux, macOS, Windows on +- [x] **D1** Cross-platform binaries: Linux, macOS, Windows on x86_64 and aarch64. -- [ ] **D2** Single static binary, no runtime dependencies. + *(Done 2026-06-15 — CI produces all six. The four non-macOS + targets (Linux musl + Windows gnu/gnullvm × x86_64/aarch64) are + cross-built from the Linux runner with `cargo-zigbuild` on a `v*` + tag (`release.yaml`); the two `*-apple-darwin` targets build + natively on a Tart Apple-Silicon runner via the dispatched + `release-macos.yaml`. All uploaded to the Gitea release with a + `.sha256` each. Decisions in `docs/ci/adr/` (ADR-ci-001/002/003). + Runtime-verified by the user: Linux x86_64 + Windows aarch64; the + others are link-clean / valid format.)* +- [x] **D2** Single static binary, no runtime dependencies. + *(Done 2026-06-15, per platform: **Linux** is fully static (musl + + `crt-static`); **Windows** is a standalone `.exe` (Zig statically + links libc — no mingw runtime DLLs); **macOS** links only system + libraries (`libSystem` + the AppKit/Foundation frameworks — + inherent on every Mac, never user-installed; the build rewrites the + one nix-store `libiconv` path to `/usr/lib` and re-signs ad-hoc). + No target requires anything the user must install. ADR-ci-003.)* - [ ] **D3** Released via prebuilt binaries plus Homebrew, Scoop, `winget`, and `cargo binstall`. + *(Prebuilt binaries + checksums now published to Gitea releases + (D1); the package-manager manifests (Homebrew / Scoop / winget / + `cargo binstall`) remain to do. The asset naming + `rdbms-playground--` is already binstall-friendly. + Tracked under ADR-ci-003 "Deferred".)* ## TUI shell @@ -878,8 +899,18 @@ since ADR-0027.) PTY. Correcting a stale `CLAUDE.md` line that read "Tier 4 is wired only for the listed critical flows" — it was not wired at all. Genuinely deferred.)* -- [ ] **TT5** CI runs all tiers on Linux, macOS, and Windows on +- [/] **TT5** CI runs all tiers on Linux, macOS, and Windows on stable Rust. + *(Partial, 2026-06-15. **CI is live** on the self-hosted Gitea + Actions (`docs/ci/adr/`): the gate runs `clippy -D warnings` + + `cargo test` (Tiers 1–3) on the **Linux** runner for every branch + push / PR, and `release-macos` runs the suite natively on the + **macOS** runner. **Windows is build-only** — cross-compiled, not + executed (no Windows runner). **Tier 4** (PTY, TT4) is still + unwired, so "all tiers" is not yet fully met. "Stable Rust" is + satisfied by the flake's pinned `1.95.0` (a stable release, not + nightly). Remaining for full TT5: a Windows execution runner and + Tier-4 PTY in CI.)* ## Cross-cutting From 447112b17f6a08dae932c98fac5fcf3fb65daaa7 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Mon, 15 Jun 2026 16:34:10 +0000 Subject: [PATCH 46/50] =?UTF-8?q?feat(hint):=20H2=20Phase=20D=20=E2=80=94?= =?UTF-8?q?=20coverage=20gate,=20F1=20strip,=20status=20flips=20(ADR-0053)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes H2: - comprehensiveness coverage tests: every REGISTRY command form has a hint_id resolving to a hint.cmd.* block, and every runtime error class resolves to a hint.err.* block (enforces ADR-0053 D6) - ADR-0051 keybinding strip advertises F1 in the editing (leads) and default states; +shortcut.hint label; 12 full-panel snapshots re-accepted (status-bar line only) - flip ADR-0053 -> implemented, requirements H2 + A1 -> [x], README 2498 pass / 1 ignored, clippy clean. --- ...-contextual-hint-command-and-keybinding.md | 10 +++- docs/adr/README.md | 2 +- docs/requirements.md | 21 +++++--- src/dsl/grammar/mod.rs | 50 +++++++++++++++++++ src/friendly/keys.rs | 1 + src/friendly/strings/en-US.yaml | 1 + ...und__ui__tests__default_advanced_dark.snap | 4 +- ...round__ui__tests__default_simple_dark.snap | 4 +- ...ound__ui__tests__default_simple_light.snap | 4 +- ..._demo_badge_and_caption_stacked_90x26.snap | 4 +- ...__tests__demo_badge_enter_light_90x26.snap | 4 +- ..._ui__tests__demo_badge_tab_dark_90x26.snap | 4 +- ...d__ui__tests__demo_caption_dark_90x26.snap | 4 +- ...ui__tests__demo_caption_wrapped_90x26.snap | 4 +- ...hlighted_input_all_token_classes_dark.snap | 4 +- ...nd__ui__tests__one_shot_advanced_dark.snap | 4 +- ..._ui__tests__populated_with_table_dark.snap | 4 +- ...ui__tests__rebuild_confirm_modal_dark.snap | 4 +- ...__ui__tests__relationships_panel_dark.snap | 4 +- ...ground__ui__tests__two_row_input_dark.snap | 2 +- src/ui.rs | 15 +++--- 21 files changed, 111 insertions(+), 43 deletions(-) diff --git a/docs/adr/0053-contextual-hint-command-and-keybinding.md b/docs/adr/0053-contextual-hint-command-and-keybinding.md index dfe3081..9bf6ddf 100644 --- a/docs/adr/0053-contextual-hint-command-and-keybinding.md +++ b/docs/adr/0053-contextual-hint-command-and-keybinding.md @@ -2,7 +2,15 @@ ## Status -Accepted — implementation in progress. Revised after a `/runda` review +Accepted — **implemented 2026-06-15** (plan: +`docs/plans/20260614-adr-0053-contextual-hint-H2.md`; the F1 keybinding + +`hint` command, the `hint_ids` per-form keying + `hint_key_for_input_in_mode`, +`last_error_hint_key` + `friendly::error_hint_class`, the `note_hint*` +renderers, and the `hint.cmd.*`/`hint.err.*` corpus for every command form ++ the 9 runtime error classes, with the comprehensiveness coverage test +and the ADR-0051 strip advertising F1). Closes **A1** + requirements +**H2**. Deferred: the pre-submit-diagnostic route + `diagnostic.*` blocks +(#38), clause-concept hints (#37). Revised after a `/runda` review (2026-06-14): corrected the verbosity-default fact; re-keyed tier-3 content off `help_id`; split the pre-submit-diagnostic and runtime-error paths; added a comprehensiveness coverage test. Revised again during diff --git a/docs/adr/README.md b/docs/adr/README.md index 6a8a3e9..1954476 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -58,4 +58,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0050 — Incidental-DDL confirmations omit relationship info (structure-only)](0050-incidental-ddl-confirmations-omit-relationships.md) — **Accepted + implemented 2026-06-12 (issue #28)**, closes Gitea **#28**. **Supersedes** the incidental-DDL clause of **ADR-0044 §1** and the relationship-block half of **ADR-0016 §5**. Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/`rename`/`change column`, `add`/`drop index`) now render **structure only** — header + column box + `Indexes:` + constraints — with **no `References:` / `Referenced by:` block** (neither prose nor diagram), even when the table carries relationships the user did not touch. Rationale (owner): a confirmation echo reports the change just made, not untouched relationships; ADR-0044's terse prose was the lesser of "prose vs diagram", but the right answer for these surfaces is **neither**. **Relationship-subject surfaces are unchanged** — `show table`, `add`/`drop relationship`, `show relationship` still render ADR-0044 diagrams; relationships appear only when the user asks for (`show table`) or acts on (`add`/`drop relationship`) one, and are one `show table ` away — **no information lost**. Forks both user-chosen: **scope = all incidental DDL** (not just `add column` — the rationale is uniform, the mental model clean, and it's the simpler edit) and **delete the prose renderer** (not retain it dormant — no dead code). **Mechanism:** the `handle_dsl_success` `matches!` routing is unchanged (relationship-subject → diagrams; else → `render_structure`); the change is one line inside `render_structure` (`output_render.rs` — drop the relationship-block call) since all its callers are incidental DDL, plus deletion of the orphaned `relationship_prose_lines` + `cols_disp` helpers. The prose format survives in ADR-0016 §5 + git history for a future OOS-7 always-prose setting. **Tests:** the prose-presence unit test + its snapshot removed; a new unit test asserts `render_structure` on a description carrying **both** inbound and outbound relationships emits the box but no prose; the misnamed `add_relationship_flow_shows_inbound_section_on_parent` integration test (which sent an `AddColumn`) inverted + renamed to assert the add-column echo omits the prose; the diagram tests (`show table`, `add relationship`) unaffected. **2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. `requirements.md` unaffected (ADR-tracked refinement of a decided area, like ADR-0044 itself) - [ADR-0051 — Bottom keybinding strip: context- and state-aware](0051-context-state-aware-keybinding-strip.md) — **Accepted + implemented 2026-06-13 (issue #27)**, closes Gitea **#27**. Repurposes the bottom status line into a **keystrokes-only, state-selected** strip (builds on ADR-0046 nav focus, ADR-0003 modes, ADR-0049 the #29 readline keys it now advertises, ADR-0022 the completion memo). A pure `status_bar_bindings(app) -> Vec<(key,label)>` chooses the strip by **priority, first match wins**: (1) **sidebar focus** → `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input`; (2) **completion memo live** (`last_completion`) → `Tab/Shift-Tab cycle · Esc cancel · Enter run`; (3) **history navigation** (new `App::is_browsing_history()` exposing the private `history_cursor`) → `↑↓ browse · Esc clear · Enter run`; (4) **editing** (input non-empty) → `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` (surfaces the #29 keys, closing ADR-0049's deferred advertisement); (5) **default** (empty) → `Ctrl-O sidebar · Tab complete · ↑ history · Enter run`. Priority is correct because Up clears the completion memo and Tab cancels history nav, so states 2/3 never co-occur, and the five are exhaustive for Input focus. **Typed-command words leave the strip** (`mode advanced`/`mode simple` switch, `:` one-shot) and **mode discovery moves to the empty-input hint** (`resolve_hint_lines`), **simple mode only**: `\`mode advanced\` for SQL` (the verb "type" omitted — the prompt implies it; advanced mode shows **no** pointer per a post-trial user decision — a switcher knows how they got there and `help` covers the way back). The one-shot's old `Backspace cancel one-shot` label is subsumed by the editing state (behaviour intact). Forks all user-chosen: **editing state shows the #29 keys** (vs unadvertised); **`Ctrl-C quit` omitted** from the strip (vs always shown); **no width-drop machinery** — the longest strip (~65 cols) fits all supported widths, so a **width-budget unit test** keeps it lean by construction instead (the user's own observation). Catalog: 12 new `shortcut.*` labels + the `panel.hint_mode_advanced` string added to `en-US.yaml`+`keys.rs` (validator-checked 1:1), 5 now-dead strip strings removed. **Modal-aware strip is OOS** (pre-existing: a modal owns the keyboard and carries its own hints; the strip under it is unchanged-in-kind, not worsened). Tests: 9 Tier-1 unit (per-state key sets — completion/history driven through real key events; width budget; mode-pointer presence/absence), 1 Tier-3 rewritten (`status_bar_is_keystroke_only_and_state_aware`), 15 full-panel snapshots re-accepted (reviewed — strip/hint only). **2467 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: modal-aware strip; a full-key cheatsheet overlay; Ctrl-K/U advertisement (editing strip shows the highest-value subset within the width budget) - [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (status field gains a `:adv` tag; **journaling moves from the worker to the dispatch layer**), **ADR-0015 §5/§6** (history.log leaves the worker transaction — `commit-db-last` now scopes yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure is best-effort, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression). **Follow-up done 2026-06-14:** the vestigial worker `source` plumbing was fully unwound (compiler-guided, no behaviour change) — `_source` removed from `finalize_persistence`/`do_rebuild_from_text`, the three `*_request` wrappers inlined+deleted, the dead `source` param dropped from the ~30 forwarding worker handlers, and the `source` field removed from the `DescribeTable`/`QueryData`/`RunSelect` requests + their `DatabaseHandle` methods (~164 mostly-test call sites); the only worker `source` left is the snapshot/undo label (see ADR-0052 *Consequences*) -- [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implementation in progress (Phases A–C done 2026-06-15: mechanism + per-form keying + the command-form & runtime-error content; Phase D polish next)**. Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help ` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.` (per command form) and `hint.err.` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed per-form via a new `hint_ids: &[&str]` field on `CommandNode` mirroring `usage_ids`** (revised in Phase B): a per-*node* key proved too coarse — `add`/`drop`/`show`/`create` are each one node spanning many forms, and a live-input hint for `add 1:n relationship` must be specific to relationships; `hint_key_for_input_in_mode` reuses `usage_key_for_input_in_mode`'s form-word disambiguation, and covers the advanced-SQL forms whose `usage_ids` are empty. Not keyed off `help_id` (it is `None` on the advanced-SQL nodes purely to dedup the `help` list; that parallel gap is issue **#36**). **Clause-concept hints** (`on delete` actions, constraint slots, `with pk`, cardinality) are a recorded **deferred extension** (`hint.concept.`, issue **#37**) — per-form is the right tier-3 granularity, with position-awareness owned by tier-2 + the live `Next:` line. Runtime `translate_error` classes resolve via stored `last_error_hint_key` (`hint` command / empty-F1). (The second route — pre-submit `diagnostic.*` read live from the walker on the F1 path — is **deferred**, issue **#38**: `Diagnostic` carries no class key.) Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_ids` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): v1 scope = ~37 command forms + 9 runtime error classes (comprehensive for those, ~57 blocks), authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test**, with graceful fall-back to tier-2 if a key is ever missing. The **pre-submit-diagnostic route + ~33 `diagnostic.*` blocks were deferred** (issue **#38**) — `Diagnostic` carries no class key, so the route needs a broad change for marginal value (tier-2 already surfaces diagnostics; many duplicate runtime classes). Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive-for-commands-and-errors scope; exemplars-first; diagnostics deferred. OOS: per-topic `hint ` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); clause-concept hints (issue #37); the diagnostic route (issue #38); the `help`-side advanced-SQL gap (issue #36) +- [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implemented 2026-06-15** (Phases A–D; closes **A1** + requirements **H2**). Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help ` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.` (per command form) and `hint.err.` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed per-form via a new `hint_ids: &[&str]` field on `CommandNode` mirroring `usage_ids`** (revised in Phase B): a per-*node* key proved too coarse — `add`/`drop`/`show`/`create` are each one node spanning many forms, and a live-input hint for `add 1:n relationship` must be specific to relationships; `hint_key_for_input_in_mode` reuses `usage_key_for_input_in_mode`'s form-word disambiguation, and covers the advanced-SQL forms whose `usage_ids` are empty. Not keyed off `help_id` (it is `None` on the advanced-SQL nodes purely to dedup the `help` list; that parallel gap is issue **#36**). **Clause-concept hints** (`on delete` actions, constraint slots, `with pk`, cardinality) are a recorded **deferred extension** (`hint.concept.`, issue **#37**) — per-form is the right tier-3 granularity, with position-awareness owned by tier-2 + the live `Next:` line. Runtime `translate_error` classes resolve via stored `last_error_hint_key` (`hint` command / empty-F1). (The second route — pre-submit `diagnostic.*` read live from the walker on the F1 path — is **deferred**, issue **#38**: `Diagnostic` carries no class key.) Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_ids` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): v1 scope = ~37 command forms + 9 runtime error classes (comprehensive for those, ~57 blocks), authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test**, with graceful fall-back to tier-2 if a key is ever missing. The **pre-submit-diagnostic route + ~33 `diagnostic.*` blocks were deferred** (issue **#38**) — `Diagnostic` carries no class key, so the route needs a broad change for marginal value (tier-2 already surfaces diagnostics; many duplicate runtime classes). Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive-for-commands-and-errors scope; exemplars-first; diagnostics deferred. OOS: per-topic `hint ` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); clause-concept hints (issue #37); the diagnostic route (issue #38); the `help`-side advanced-SQL gap (issue #36) diff --git a/docs/requirements.md b/docs/requirements.md index d3fc046..4aaf750 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -250,16 +250,13 @@ since ADR-0027.) ## App-level commands (per ADR-0003) -- [/] **A1** All canonical app-level commands implemented and +- [x] **A1** All canonical app-level commands implemented and available in both modes: `save`, `save as`, `load`, `new`, `rebuild`, `export`, `import`, `seed`, `replay`, `undo`, `redo`, `mode`, `help`, `hint`, `quit`. - *(Partial: **14 of 15** implemented and available in both modes — - `quit`/`q`, `mode simple|advanced`, `help`, `save`, `save as`, - `load`, `new`, `rebuild`, `export`, `import`, `replay`, `undo`, - `redo`, and now **`seed`** (ADR-0048 / SD1, done 2026-06-11). - **Only `hint`** (tracked as H2) remains unregistered. A1 closes - when H2 lands.)* + *(Done 2026-06-15: the last command, **`hint`**, landed with H2 + (ADR-0053). All 15 canonical app commands are now registered and + available in both modes.)* ## DSL data commands @@ -793,8 +790,16 @@ since ADR-0027.) `returning `) still shows the raw expression first-set — typing-time completion already offers the right candidates there, so the payoff is small. -- [ ] **H2** `hint` provides contextual help for the current +- [x] **H2** `hint` provides contextual help for the current input or the most recent error. + *(Done 2026-06-15, ADR-0053: an **F1** keybinding gives a tier-3 + teaching hint for the live partial input (read-only overlay), and a + submitted **`hint`** command expands on the most recent runtime error. + A new `hint.cmd.` / `hint.err.` catalogue tier + (`what`/`example`/`concept`) covers every command form + the 9 runtime + error classes, enforced by a comprehensiveness coverage test. Deferred: + the pre-submit-diagnostic route + `diagnostic.*` blocks (#38), + clause-concept hints (#37).)* - [x] **H3** `help` provides general reference and per-command help. *(Done 2026-06-07: the **general reference** is `help` (no arg) — diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index 476e060..3842073 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -962,6 +962,56 @@ mod hint_key_tests { // Unknown entry word → None (tier-2 fallback). assert_eq!(hint_key_for_input_in_mode("zzz", Mode::Simple), None); } + + /// Comprehensiveness gate (ADR-0053 D6): every command form in the + /// REGISTRY carries at least one `hint_id`, and each resolves to a + /// tier-3 `hint.cmd.` block. `keys.rs` checks referenced keys + /// resolve; this checks every command *has* one. + #[test] + fn every_command_form_has_a_tier3_block() { + let cat = crate::friendly::catalog(); + for (node, _category) in super::REGISTRY { + assert!( + !node.hint_ids.is_empty(), + "command `{}` has no hint_ids (ADR-0053 D6)", + node.entry.primary + ); + for id in node.hint_ids { + let key = format!("hint.cmd.{id}.what"); + assert!( + cat.get(&key).is_some(), + "missing tier-3 block `{key}` for command `{}`", + node.entry.primary + ); + } + } + } + + /// Comprehensiveness gate (ADR-0053 D6): every runtime error class + /// `friendly::error_hint_class` can return resolves to a tier-3 + /// `hint.err.` block. Keep this list in sync with + /// `error_hint_class` (its own unit tests pin the outputs). + /// Diagnostic classes are deferred (issue #38), so not checked here. + #[test] + fn every_runtime_error_class_has_a_tier3_block() { + let cat = crate::friendly::catalog(); + let classes = [ + "unique", + "foreign_key.child_side", + "foreign_key.parent_side", + "not_null", + "check", + "type_mismatch", + "not_found", + "already_exists", + "generic", + "invalid_value", + ]; + for c in classes { + let key = format!("hint.err.{c}.what"); + assert!(cat.get(&key).is_some(), "missing tier-3 error block `{key}`"); + } + } } #[cfg(test)] diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index bb945ff..177526d 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -647,6 +647,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("shortcut.confirm", &[]), ("shortcut.cycle", &[]), ("shortcut.del_word", &[]), + ("shortcut.hint", &[]), ("shortcut.history", &[]), ("shortcut.home_end", &[]), ("shortcut.load", &[]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 8fd4cbb..f6df963 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -1165,6 +1165,7 @@ shortcut: browse: "browse" clear: "clear" complete: "complete" + hint: "hint" history: "history" home_end: "home/end" del_word: "del word" 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 0505791..fc6eb98 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_advanced_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 2836 +assertion_line: 2839 expression: snapshot --- ╭ Output ──────────────────────────────────────────────────────────────────────╮ @@ -26,4 +26,4 @@ expression: snapshot │ │ ╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Ctrl-O sidebar · Tab complete · ↑ history · Enter run +Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap b/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap index e192380..8841ef3 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_simple_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 2819 +assertion_line: 2822 expression: snapshot --- ╭ Output ──────────────────────────────────────────────────────────────────────╮ @@ -26,4 +26,4 @@ expression: snapshot │for SQL │ ╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Ctrl-O sidebar · Tab complete · ↑ history · Enter run +Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap b/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap index a86c7b3..c79416c 100644 --- a/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap +++ b/src/snapshots/rdbms_playground__ui__tests__default_simple_light.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 2827 +assertion_line: 2830 expression: snapshot --- ╭ Output ──────────────────────────────────────────────────────────────────────╮ @@ -26,4 +26,4 @@ expression: snapshot │for SQL │ ╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Ctrl-O sidebar · Tab complete · ↑ history · Enter run +Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap index f429374..3482f13 100644 --- a/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap +++ b/src/snapshots/rdbms_playground__ui__tests__demo_badge_and_caption_stacked_90x26.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 3442 +assertion_line: 3445 expression: snapshot --- ╭ Output ────────────────────────────────────────────────────────────────────────────────╮ @@ -28,4 +28,4 @@ expression: snapshot │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Ctrl-O sidebar · Tab complete · ↑ history · Enter run +Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap index 86f0ce5..ecc05ad 100644 --- a/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap +++ b/src/snapshots/rdbms_playground__ui__tests__demo_badge_enter_light_90x26.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 3388 +assertion_line: 3391 expression: snapshot --- ╭ Output ────────────────────────────────────────────────────────────────────────────────╮ @@ -28,4 +28,4 @@ expression: snapshot │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Ctrl-O sidebar · Tab complete · ↑ history · Enter run +Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap index e9b9e4a..723e28e 100644 --- a/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap +++ b/src/snapshots/rdbms_playground__ui__tests__demo_badge_tab_dark_90x26.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 3378 +assertion_line: 3381 expression: snapshot --- ╭ Output ────────────────────────────────────────────────────────────────────────────────╮ @@ -28,4 +28,4 @@ expression: snapshot │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Ctrl-O sidebar · Tab complete · ↑ history · Enter run +Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap index 1d2e68a..a14d17e 100644 --- a/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap +++ b/src/snapshots/rdbms_playground__ui__tests__demo_caption_dark_90x26.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 3431 +assertion_line: 3434 expression: snapshot --- ╭ Output ────────────────────────────────────────────────────────────────────────────────╮ @@ -28,4 +28,4 @@ expression: snapshot │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Ctrl-O sidebar · Tab complete · ↑ history · Enter run +Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap b/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap index b3e064d..24a8f37 100644 --- a/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap +++ b/src/snapshots/rdbms_playground__ui__tests__demo_caption_wrapped_90x26.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 3457 +assertion_line: 3460 expression: snapshot --- ╭ Output ────────────────────────────────────────────────────────────────────────────────╮ @@ -28,4 +28,4 @@ expression: snapshot │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Ctrl-O sidebar · Tab complete · ↑ history · Enter run +Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap index 0c1353e..ce720b2 100644 --- a/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__highlighted_input_all_token_classes_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 2880 +assertion_line: 2882 expression: snapshot --- ╭ Output ──────────────────────────────────────────────────────────────────────╮ @@ -26,4 +26,4 @@ expression: snapshot │insert into
[([, ...])] [values] ([, ...]) │ ╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run +F1 hint · Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run 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 99c972e..1d6a202 100644 --- a/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__one_shot_advanced_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 2896 +assertion_line: 2898 expression: snapshot --- ╭ Output ──────────────────────────────────────────────────────────────────────╮ @@ -26,4 +26,4 @@ expression: snapshot │ │ ╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run +F1 hint · Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap b/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap index bebe44f..3956d34 100644 --- a/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__populated_with_table_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 3099 +assertion_line: 3102 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮ @@ -26,4 +26,4 @@ expression: snapshot │ ││for SQL │ ╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Ctrl-O sidebar · Tab complete · ↑ history · Enter run +Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap b/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap index f396dff..dc29061 100644 --- a/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__rebuild_confirm_modal_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 2909 +assertion_line: 2912 expression: snapshot --- ╭ Output ──────────────────────────────────────────────────────────────────────╮ @@ -26,4 +26,4 @@ expression: snapshot │for SQL │ ╰──────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Ctrl-O sidebar · Tab complete · ↑ history · Enter run +Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap b/src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap index 87afd3b..91ca731 100644 --- a/src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__relationships_panel_dark.snap @@ -1,6 +1,6 @@ --- source: src/ui.rs -assertion_line: 3209 +assertion_line: 3212 expression: snapshot --- ╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮ @@ -26,4 +26,4 @@ expression: snapshot │ Orders.customer_id ││for SQL │ ╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯ Project: Term Planner -Ctrl-O sidebar · Tab complete · ↑ history · Enter run +Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run diff --git a/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap b/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap index 88166eb..a490bcc 100644 --- a/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap +++ b/src/snapshots/rdbms_playground__ui__tests__two_row_input_dark.snap @@ -46,4 +46,4 @@ expression: snapshot │with `mode advanced`, or prefix the line with `:` to run… │ ╰──────────────────────────────────────────────────────────╯ Project: Term Planner -Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Ente +F1 hint · Esc clear · Ctrl-A/E home/end · Ctrl-W del w diff --git a/src/ui.rs b/src/ui.rs index c50e95d..d2f6f25 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1894,22 +1894,25 @@ fn status_bar_bindings(app: &App) -> Vec<(&'static str, String)> { ("Enter", crate::t!("shortcut.run")), ]; } - // 4. Editing — the input has text: surface the readline edit keys - // (ADR-0049). The highest-value subset stays within the width - // budget; Ctrl-K/U remain unadvertised muscle memory. + // 4. Editing — the input has text: F1 (the contextual hint for what + // you're typing, ADR-0053) leads, then the readline edit keys + // (ADR-0049). Ctrl-K/U remain unadvertised muscle memory. if !app.input.is_empty() { return vec![ + ("F1", crate::t!("shortcut.hint")), ("Esc", crate::t!("shortcut.clear")), ("Ctrl-A/E", crate::t!("shortcut.home_end")), ("Ctrl-W", crate::t!("shortcut.del_word")), ("Enter", crate::t!("shortcut.run")), ]; } - // 5. Default — empty input, Input focus. + // 5. Default — empty input, Input focus. F1 here expands on the most + // recent error, or points the user at getting started (ADR-0053). vec![ ("Ctrl-O", crate::t!("shortcut.nav")), ("Tab", crate::t!("shortcut.complete")), ("↑", crate::t!("shortcut.history")), + ("F1", crate::t!("shortcut.hint")), ("Enter", crate::t!("shortcut.run")), ] } @@ -2664,7 +2667,7 @@ mod tests { #[test] fn strip_default_state_is_nav_complete_history_run() { let app = App::new(); - assert_eq!(strip_keys(&app), vec!["Ctrl-O", "Tab", "↑", "Enter"]); + assert_eq!(strip_keys(&app), vec!["Ctrl-O", "Tab", "↑", "F1", "Enter"]); } #[test] @@ -2675,7 +2678,7 @@ mod tests { app.input.push_str("create ta"); assert_eq!( strip_keys(&app), - vec!["Esc", "Ctrl-A/E", "Ctrl-W", "Enter"], + vec!["F1", "Esc", "Ctrl-A/E", "Ctrl-W", "Enter"], ); } From 329adfc93583d61f768587e5e109a7d1da19aed3 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Mon, 15 Jun 2026 16:45:47 +0000 Subject: [PATCH 47/50] fix(hint): labelled tier-3 block format + snapshot (ADR-0053 /runda) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final /runda found the rendered block was three bare unlabelled lines, deviating from the approved exemplar format. Fix: - emit_tier3_block now renders a `Hint` heading + aligned `What:` / `Example:` / `Concept:` lines (hint.block.* labels); concept stays muted - lock the format with an insta snapshot (hint_block_insert) - amend ADR-0053 D2/D4 + exemplars: drop the `Next:` line (tier-2 ambient already owns live position-awareness — user-confirmed), align exemplars to the shipped format 2499 pass / 1 ignored, clippy clean. --- ...-contextual-hint-command-and-keybinding.md | 48 ++++++++++--------- src/app.rs | 43 ++++++++++++++--- src/friendly/keys.rs | 4 ++ src/friendly/strings/en-US.yaml | 7 +++ ...ground__app__tests__hint_block_insert.snap | 9 ++++ 5 files changed, 82 insertions(+), 29 deletions(-) create mode 100644 src/snapshots/rdbms_playground__app__tests__hint_block_insert.snap diff --git a/docs/adr/0053-contextual-hint-command-and-keybinding.md b/docs/adr/0053-contextual-hint-command-and-keybinding.md index 9bf6ddf..fdef8b6 100644 --- a/docs/adr/0053-contextual-hint-command-and-keybinding.md +++ b/docs/adr/0053-contextual-hint-command-and-keybinding.md @@ -113,7 +113,7 @@ into the output journal. (It must therefore be handled in `handle_key` | Trigger | Buffer / state | Result | |---|---|---| -| **F1** | non-empty input | tier-3 hint for the command being typed, plus the live "expected next" (from the walker's `tail_expected` / parser `expected`) | +| **F1** | non-empty input | tier-3 hint for the command being typed. (No "expected next" line — the always-on tier-2 ambient panel already shows it live; tier-2 owns position-awareness.) | | **F1** | empty input, a recent error exists | tier-3 expansion of that error | | **F1** | empty input, no recent error | a short "getting started" pointer (press F1 while typing a command; `help` for the full list) | | **`hint`** (submitted) | a recent error exists | tier-3 expansion of that error (primary use) | @@ -205,14 +205,17 @@ mechanics (e.g. `quit`); `what` + `example` are always present. ### D4 — Rendering -Both surfaces render through one new renderer, `App::note_hint*` (sibling -of `note_help`/`note_help_topic`, `src/app.rs`), emitting a small framed -block into the `output` buffer as `OutputKind::System` with -`OutputStyleClass::Hint` on the `what`/`concept` prose and `Neutral` on -the `example` line. The block is **persistent** (scrolls in the journal), -unlike the transient ambient panel — pressing F1 is an explicit request -to *keep* the deeper guidance on screen. The bottom keybinding strip -(ADR-0051) advertises F1 in the editing/typing state. +Both surfaces render through the `App::note_hint*` family (sibling of +`note_help`/`note_help_topic`, `src/app.rs`) via `emit_tier3_block`, +emitting into the `output` buffer as `OutputKind::System`: a **`Hint` +heading** followed by aligned **`What:` / `Example:` / `Concept:`** lines +(labels + heading from `hint.block.*`). The `concept` line is muted +(`OutputStyleClass::Hint`); the rest are plain. The block is +**persistent** (scrolls in the journal), unlike the transient ambient +panel — pressing F1 is an explicit request to *keep* the deeper guidance +on screen. Its rendered shape is locked by an `insta` snapshot +(`hint_block_insert`). The bottom keybinding strip (ADR-0051) advertises +F1 in the editing (leading) and default states. ### D5 — "Most recent (runtime) error" state @@ -278,32 +281,31 @@ maintainer owns, content is produced in two stages: **reviewable batches** (grouped by area: DDL, DML, app commands, error classes), not one monolithic drop. -### Exemplars (the style reference to approve) +### Exemplars (the style reference; shipped as the rendered format) -**Command (F1 live-input), `insert`:** +**Command (F1 live-input), `insert`** (the rendered shape, locked by the +`hint_block_insert` snapshot — a `Hint` heading + aligned labels, no +`Next:` line since tier-2 owns position-awareness): ``` -Hint — insert +Hint What: Add one or more rows to a table. - Example: insert into Customers values ('Ann', 'ann@x.io') + Example: insert into Customers values ('Ann', 'ann@example.io') Concept: A row is one record; each value lines up with a column, in order. Columns typed serial/shortid fill themselves — leave them out. - Next: a value list `(...)`, or `(col, ...) values (...)` to name columns ``` -(The "Next:" line is the live expected-set from the walker, shown only on -the non-empty-input F1 path.) **Error (`hint` command), foreign-key child-side violation:** ``` -Hint — no parent row to point at - What: The value you inserted into Orders.customer_id doesn't match - any Customers row, so the foreign key has nothing to point at. - Example: First insert into Customers values ('Ann', ...) - Then insert into Orders values (..., 'Ann') +Hint + What: The value you gave for the child column doesn't match any + parent row, so the foreign key has nothing to point at. + Example: First insert the parent (insert into Customers …), then the + child that references it. Concept: A foreign key is a promise that every child points at a real - parent. The parent must exist first. To allow orphans on + parent, so the parent must exist first. To allow orphans on delete instead, set the relationship's `on delete` to `set null` or `cascade`. ``` @@ -311,7 +313,7 @@ Hint — no parent row to point at **Command (F1 live-input), `add 1:n relationship`:** ``` -Hint — add relationship +Hint What: Link two tables so a parent row can own many child rows. Example: add 1:n relationship from Customers.id to Orders.customer_id Concept: The "1:n" means one parent, many children. The child column diff --git a/src/app.rs b/src/app.rs index f62da1f..5750535 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3205,17 +3205,32 @@ impl App { /// polish (the framed block) lands with the corpus. fn emit_tier3_block(&mut self, stem: &str) -> bool { let cat = crate::friendly::catalog(); - if cat.get(&format!("{stem}.what")).is_none() { + let what_key = format!("{stem}.what"); + if cat.get(&what_key).is_none() { return false; } - self.note_system(crate::friendly::translate(&format!("{stem}.what"), &[])); + // Labelled block (ADR-0053 D4): a `Hint` heading, then aligned + // `What:` / `Example:` / `Concept:` lines. `concept` renders + // muted (`OutputStyleClass::Hint`); the rest are plain system. + let labelled = |label: &str, value: &str| { + // Pad `()[, ...]]` is itself + misleading — the `[, ...]` is the PK list, not regular columns. +- **Correct mental model:** `create table with pk ` then + `add column : ()` for each non-key column. Confirm + against ADR-0005 (compound PK) and ADR-0009 (DSL syntax) when fixing. + +## §3. Root cause — why this needs a *full* pass + +During Phase C I verified *some* examples against `parse.usage.*` +templates and real test greps, but for others I **extrapolated** beyond +verified syntax. For `create_table` I saw `... with pk id(int)` (single +col) and wrongly generalised to "pk + more columns," misreading the +`with pk` list as a column list. The examples are **syntactically** +checked but not **semantically** — i.e. not verified to *do what the +`what`/`concept` claims*. + +So the corpus needs a pass that, for **every** `hint.cmd.*` and +`hint.err.*` block, checks: +1. the `example` parses **and runs**, and +2. it actually demonstrates what `what`/`concept` says, and +3. `what`/`concept` are factually true of the real behaviour. + +**Don't trust grep+extrapolation.** Prefer: run the example in the app +(or a Tier-3 test), or check it against the authoritative ADR. + +## §4. The pass — how to do it (next session) + +The corpus lives in `src/friendly/strings/en-US.yaml` under `hint.cmd.*` +(per command form) and `hint.err.*` (per runtime error class). The +inventory and authoritative syntax sources: + +- **`hint.cmd.`** — for each, cross-check the example against the + matching `parse.usage.` template **and** the form's ADR, and run + it. Highest-risk (extrapolated, verify first): **DDL** — `create_table` + (known wrong), `add_column`, `add_index`, `add_constraint`, + `change_column`, `drop_*`, `create_m2n`; **advanced-SQL** — confirm + each is in the supported SQL subset (`select`, `with` CTE, + `sql_insert/update/delete`, `sql_create_table`, `sql_alter_table`, + `sql_create_index/drop_index/drop_table`, `explain_sql`); **DML** — + `seed` forms, `explain`, `show_*`, `update`/`delete` (`--all-rows` / + required-WHERE wording). App commands are lower-risk (reference-style). +- **`hint.err.`** — verify the fix recipe in `example` is actually + the right remedy and `concept` matches the engine's real behaviour + (FK sides, `on delete` actions, check/not_null/unique semantics). +- Relevant ADRs: 0005 (types + compound PK), 0009 (DSL syntax), 0011 (FK + type compat), 0013 (relationships/rebuild), 0014 (data ops + + required-WHERE), 0025 (indexes), 0028/0039 (explain), 0030–0036 (SQL + subset), 0048 (seed). `docs/requirements.md` for scope. + +**Suggested method:** drive the app (`/run` or a small PTY/Tier-3 harness) +and actually execute each example; or add a test that parses+runs every +`hint.cmd.*` example and asserts success. The latter would also be a +durable regression guard — consider adding it as part of the pass (it +upgrades the comprehensiveness coverage test from "a block exists" to +"the example actually works"). + +## §5. Immediate fix ready to apply + +`create_table` is diagnosed (§2). The corrected block should make the +example a PK-only `create table` and move the regular columns to a +follow-up `add column`, e.g.: + +``` +What: Create a new table with its primary key. +Example: create table Customers with pk id(serial) +Concept: A table is a set of rows sharing the same columns. `with pk` + declares the primary key (one column, or several for a compound + key); add the other columns afterwards with `add column`. +``` + +Apply this (and re-check `create_m2n` / `add_*` while there), but only as +part of the systematic pass — a one-off fix risks leaving siblings wrong. + +## §6. How to take over + +1. Read handoffs 70 → 71, `CLAUDE.md`. +2. Confirm green: `cargo test` (2499 / 1 ignored), `cargo clippy + --all-targets`. +3. Do the §4 pass (consider the run-every-example test in §4). Test-first, + `/runda` before commit, confirm the commit message with the user. +4. Pedagogy wins — these are teaching strings; correctness and clarity + over cleverness.