112 Commits

Author SHA1 Message Date
claude@clouddev1 bd5be5ecc7 fix(ci): read release version from Cargo.toml, not cargo metadata (ADR-0054)
ci / gate (push) Successful in 3m12s
release / test (push) Successful in 2m44s
release / build (aarch64-pc-windows-gnullvm) (push) Successful in 3m56s
release / build (aarch64-unknown-linux-musl) (push) Successful in 4m18s
release / build (x86_64-pc-windows-gnu) (push) Successful in 4m39s
release / build (x86_64-unknown-linux-musl) (push) Successful in 3m50s
The ADR-0054 version guard piped `nix develop -c cargo metadata` to node,
but the flake devShell prints a banner to stdout — corrupting the JSON
pipe, so the guard aborted under `set -e` and the v0.2.0 release failed
there (before building anything). Replace it with a toolchain-free
`grep -m1 '^version = ' Cargo.toml` (the anchor excludes dependency
`version =` keys). No real version mismatch occurred — the tagged commit
has version 0.2.0.
2026-06-17 21:58:32 +00:00
claude@clouddev1 88830ed06a chore(release): bump version to 0.2.0
ci / gate (push) Successful in 3m9s
release / test (push) Failing after 1m43s
release / build (aarch64-pc-windows-gnullvm) (push) Has been skipped
release / build (aarch64-unknown-linux-musl) (push) Has been skipped
release / build (x86_64-pc-windows-gnu) (push) Has been skipped
release / build (x86_64-unknown-linux-musl) (push) Has been skipped
First release carrying the version surfaces (--version / -V / the in-app
version command, ADR-0054), the curl|sh + PowerShell installers
(ADR-0055), crates.io/binstall readiness (ADR-0056), the verified hint
corpus, the Ctrl-G demo F1 alias, and the cargo fmt gate (#35). The
release guard checks this equals the v0.2.0 tag.
2026-06-17 21:46:52 +00:00
claude@clouddev1 ec3c7c304c ci: enable the cargo fmt --check gate (ADR-ci-002 Amendment 1)
ci / gate (push) Successful in 3m17s
Adds `cargo fmt --check` (stock defaults) to ci.yaml's gate, now that the
tree is rustfmt-clean (commit 41b7e9a). Records that reformat in
.git-blame-ignore-revs so `git blame` skips it. Amends ADR-ci-002 (the
deferred "revisit on main" fmt decision) + the ci ADR index.

Closes #35.
2026-06-17 21:40:58 +00:00
claude@clouddev1 41b7e9a049 style: format the whole tree with cargo fmt (stock defaults, #35)
One-time, mechanical reformat — no functional changes. The tree was not
rustfmt-clean (~1800 hunks across ~100 files); this brings it to stock
`cargo fmt` defaults so a `cargo fmt --check` CI gate can follow.
Behaviour-preserving: 2509 pass / 0 fail / 1 ignored (unchanged baseline),
clippy clean. A .git-blame-ignore-revs entry follows so `git blame`
skips this commit.
2026-06-17 21:39:19 +00:00
claude@clouddev1 e9606b5f6d feat(dist): crates.io + binstall + Windows install.ps1 + license files
ci / gate (push) Successful in 3m14s
Distribution prep on the road to public availability (plan steps 2–3a).

- Cargo.toml: publish-ready (drop publish=false; homepage/keywords/
  categories/exclude) + [package.metadata.binstall] with per-target
  overrides (linux-gnu->musl, windows-msvc->gnu/gnullvm). dry-run clean.
- scripts/install.ps1: Windows `irm | iex` one-liner — written but
  untested here (no PowerShell; validate on Windows). README Windows block.
- README.md (new); LICENSE-MIT + LICENSE-APACHE (dual, (c) Lazy
  Evaluation Ltd); CONTRIBUTING.md (inbound=outbound dual-license note).
- ADR-0055 Amendment 1 (install.ps1), ADR-0056 (crates.io/binstall),
  README index + plan updates.

The actual `cargo publish` remains a gated maintainer step (token,
irreversible) at a new tagged release; real cargo-binstall validation
pending.
2026-06-17 21:25:45 +00:00
claude@clouddev1 ef99e6c676 feat(install): curl|sh installer script (ADR-0055)
ci / gate (push) Successful in 3m19s
website / deploy (push) Successful in 1m58s
scripts/install.sh — POSIX sh, shellcheck-clean: detects uname OS/arch ->
target triple (Linux uses the static musl build; Windows rejected with a
Scoop/winget pointer), resolves the latest release (or RDBMS_VERSION),
downloads the asset + its .sha256 and verifies it, installs to
~/.local/bin with a PATH hint. RDBMS_OS/RDBMS_ARCH + --print-target are
testing seams. Verified end-to-end against the live public v0.1.0 (all
mappings, pinned + latest, checksum incl. tamper-rejection, install+run).

ADR-0055 + README index; plan-doc step 2 done + decisions recorded
(crates.io=yes, releases public, tracking via doc+ADR).
2026-06-17 19:41:34 +00:00
claude@clouddev1 c30a6114b9 feat(cli): --version/-V + in-app version command + release guard (ADR-0054)
Cargo.toml version is the single source of truth, surfaced by a
--version/-V CLI flag and an in-app `version` command (both via
cli::version_text -> cli.version_line). release.yaml gains a guard that
fails the release unless the v* tag equals v<CARGO_PKG_VERSION>, keeping
--version, the release name, and the asset in lockstep. New app command
wired across grammar/REGISTRY/dispatch/usage/help/hint-corpus/keys; 6
test-first tests. Also fixes a stale "macOS deferred" comment in
release.yaml. ADR-0054 + README index + plan-doc step 1.
2026-06-16 15:57:54 +00:00
claude@clouddev1 fe9d58e037 docs: plan the road to public availability (versioning, install, packaging) 2026-06-16 15:57:48 +00:00
claude@clouddev1 628b250db6 docs: reconcile docs after ci+website merges; gitignore wrangler/vscode
Post-merge documentation accuracy pass (CI and website branches both
merged to main; website deployed).

- CLAUDE.md: rewrite the stale repository-layout tree; add a Website &
  docs-site decision bullet (Astro+Starlight, Cloudflare Pages via Gitea
  Actions, ADR-website-001, the website branch stays open); update the CI
  note (merged to main; release-macos dispatchable + verified working).
- requirements.md: D1 — macOS targets now runtime-verified (release-macos
  dispatched end-to-end); DOC1 — canonical user docs now live on the
  deployed website.
- ADR-ci-003 (+ docs/ci/adr README): Amendment 2 — CI on main,
  release-macos dispatched + verified; macOS runtime-verified.
- docs/website/adr README: drop the stale "no CI yet".
- .gitignore: ignore .wrangler/ (Cloudflare Wrangler cache) and .vscode/;
  remove the tracked website/.vscode/ an Astro template had added.

D3 (package-manager manifests) + some install instructions remain open.
2026-06-16 15:06:49 +00:00
claude@clouddev1 784373a254 docs: handoff 73 — Ctrl-G demo-mode F1 alias (ADR-0047 Amendment 1) 2026-06-16 14:41:28 +00:00
claude@clouddev1 dff78412dd docs(website): hint cast — press F1 mid-command for realism
ci / gate (push) Successful in 3m20s
website / deploy (push) Successful in 1m44s
The previous take pressed F1 after a complete command, which no one does.
Now the cast starts `add column `, pauses, presses F1 (Ctrl-G→[F1]) to recall
the syntax, then finishes the command from the example — the real reason you
reach for a hint. The `hint`-on-an-error half is unchanged.
2026-06-15 22:19:29 +00:00
claude@clouddev1 028d32420d docs(website): hint feature docs + cast (content for c84a640)
ci / gate (push) Successful in 2m59s
website / deploy (push) Successful in 1m43s
Completes the preceding empty-rename commit: the getting-help "Hints"
section — F1 for a tier-3 teaching hint on the live input, `hint` to
explain the most recent error — with the real rendered block and a cast
showing both (the live-input hint via the demo-mode Ctrl-G→[F1] alias,
since autocast can't send F1, then `hint` on an error). the-assistive-editor
points at F1; CtrlG added to the cast generator's key map.
2026-06-15 21:56:20 +00:00
claude@clouddev1 c84a640259 docs(website): document the hint feature with a cast (ADR-0053)
getting-help gains a "Hints" section — F1 for a tier-3 teaching hint on the
live input, `hint` to explain the most recent error — with the real rendered
block and a cast showing both: the live-input hint (via the demo-mode
Ctrl-G→[F1] alias, since autocast can't send F1) and `hint` on an error.
the-assistive-editor points at F1. Page converted to .mdx to embed the cast;
CtrlG added to the cast generator's key map.
2026-06-15 21:49:29 +00:00
claude@clouddev1 407408ec29 Merge branch 'main' into website 2026-06-15 21:44:22 +00:00
claude@clouddev1 4016c3e5cd feat(demo): Ctrl-G as a demo-mode F1 alias for casts (ADR-0047 Amendment 1)
ci / gate (push) Successful in 3m3s
The contextual hint overlay (ADR-0053) opens on F1, but F1 is an escape
sequence the autocast recorder can't emit — so casts (and presenter /
teacher sessions) couldn't trigger the most teaching-relevant overlay.

In demo mode only, Ctrl-G now aliases F1: it runs the same hint logic and
badges AS [F1], so a recording is visually identical to a real F1 press.
Ctrl-G is the only fit — Ctrl+digit (e.g. Ctrl-1) isn't encodable in a
legacy terminal (arrives as a bare `1`), and the kitty protocol that would
encode it needs escape sequences autocast can't send (and the app doesn't
enable keyboard-enhancement flags). Demo-gated, so the shipped keymap
stays F1-only; outside demo mode Ctrl-G is inert.

- app.rs: hint_key guard gains the demo-gated Ctrl-G disjunct;
  demo_badge_label maps Ctrl-G -> [F1]; 3 Tier-1 tests + badge assertion.
- ADR-0047 Amendment 1 + README index; also removed two stray
  </content> / </invoke> lines accidentally committed in the ADR file.

docs: drop three more stale "deferred" entries from CLAUDE.md — readline
shortcuts (I1b, ADR-0049), tab completion (I3), and syntax highlighting
(I4) are all implemented; only multi-line input (I1) remains open.
2026-06-15 21:30:37 +00:00
claude@clouddev1 1feb803aab chore(website): re-record all casts against the current app
pnpm casts refresh so every cast reflects the merged app — the
context/state-aware keybinding strip, the readline keys, year/choice-set
seeding, and the DDL-confirmation changes. The .cast files are
regenerable artifacts.
2026-06-15 20:59:49 +00:00
claude@clouddev1 93a40970c3 docs(website): landing — Wordmark replaces the plain title heading
Hide the splash hero's redundant <h1> (kept in the DOM for SEO/a11y,
landing-scoped via a title-only hero) and render the Wordmark + tagline +
buttons in the body, so the brand lockup sits where the heading was and
the content rises above the fold (it was nearly hidden on an iPad).
Styles in global.css; doc-page headings are unaffected.
2026-06-15 20:59:49 +00:00
claude@clouddev1 96b9581089 docs(website-adr): record website CI as implemented (ADR-website-001 §4)
The Gitea Actions → Cloudflare Pages pipeline shipped; update §4 from
"No CI yet" to the implemented workflow, the `relplay` project, the
branch→environment mapping, and the required secrets.
2026-06-15 20:59:49 +00:00
claude@clouddev1 b60c0bb0ec ci: skip the crate gate for website-only changes
ci / gate (push) Successful in 3m0s
Add website/** and the website workflow to ci.yaml's paths-ignore, so a
push confined to the website subproject (built + published by
website.yaml) no longer runs clippy+test. A push that also touches crate
code still gates (paths-ignore skips only when all files match).
2026-06-15 20:24:46 +00:00
claude@clouddev1 c2baf6923b ci(website): Cloudflare Pages deploy via Gitea Actions
ci / gate (push) Successful in 3m5s
website / deploy (push) Successful in 1m47s
New .gitea/workflows/website.yaml: on a push to main or website that
touches website/**, build the Astro site with pnpm and deploy
website/dist to the `relplay` Cloudflare Pages project via wrangler —
--branch selects production (main) vs preview (website). Runs on the
bare ci-public runner (node present; pnpm via corepack). Pin pnpm with
package.json's packageManager for deterministic corepack installs.

Requires repo Actions secrets CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID.
2026-06-15 20:04:48 +00:00
claude@clouddev1 1660a6a17c docs: handoff 72 — H2 hint corpus verified (4 fixes + parse guard)
ci / gate (push) Successful in 2m59s
2026-06-15 19:03:13 +00:00
claude@clouddev1 ea38e7a151 docs(website): update seed (year + choice-sets) and readline keys for the merge
build-ci-image / build (push) Successful in 11m19s
ci / gate (push) Successful in 3m8s
Seed page reflects #33/#34: year-as-int columns, built-in value sets
(priority/severity/rating), advisory now status-only; output blocks
re-captured and the seed cast re-recorded. Assistive-editor documents
the #29 readline shortcuts (Ctrl-A/E/W/K/U, Esc) and #30 cross-mode
history recall; multi-line stays the only planned item.
2026-06-15 18:59:50 +00:00
claude@clouddev1 5a37437055 fix(hint): correct H2 corpus errors + add parse guard (handoff-71)
Semantic verification pass over the tier-3 `hint` corpus (ADR-0053).
Four content errors corrected in src/friendly/strings/en-US.yaml:

- cmd.create_table: the example `with pk id(serial), name(text),
  email(text)` declares a 3-column COMPOUND primary key, not a PK
  plus regular columns (every `with pk` column is a key member,
  ADR-0005). Rewritten to a single-column PK + `add column` for the
  rest; what/concept aligned.
- cmd.save: `save as my-shop` does not parse — `save as` takes no
  inline name, it opens a path-entry prompt. Example -> `save as`;
  what no longer implies inline naming; added a temp-vs-named concept.
- cmd.import: target `shop-copy` does not parse — the `as <target>`
  slot is a NewName ident that rejects hyphens. -> `shop_copy`.
- err.foreign_key.child_side: dropped the bogus `on delete set
  null/cascade` remedy — that governs the parent direction; a
  child-side violation is fixed by inserting the parent first
  (matches the tier-1 hint).

Adds every_cmd_hint_example_parses_in_its_mode — a catalog-driven
guard that parses every hint.cmd.* example in its taught mode,
backstopping syntactic drift (it caught the save and import errors).
Registers the new hint.cmd.save.concept key.

docs: drop two stale "deferred" entries from CLAUDE.md — project
storage (export/import, --resume, input history, migration scaffold)
and m:n convenience (C4) are all implemented (ADR-0015/0045); record
the verification pass on requirements.md H2.
2026-06-15 18:59:38 +00:00
claude@clouddev1 3fe62af886 Merge branch 'main' into website 2026-06-15 17:22:46 +00:00
claude@clouddev1 b4441507e2 docs: handoff 71 — hint content needs a semantic verification pass
User smoke-test found hint.cmd.create_table is semantically wrong: the
example `create table Customers with pk id(serial), name(text),
email(text)` reads as a 3-column table but actually declares a compound
PK (id, name, email) — everything after `with pk` is the PK column list
(ADR-0005). Root cause: Phase C examples were syntax-checked but some
were extrapolated, not verified to *do* what what/concept claims. Handoff
specifies a full per-block semantic pass (run each example / check the
ADR) + a ready-to-apply create_table fix.
2026-06-15 17:14:22 +00:00
claude@clouddev1 8ae0eedd44 Merge branch 'ci'
ci / gate (push) Successful in 3m7s
release / test (push) Successful in 2m43s
release / build (aarch64-pc-windows-gnullvm) (push) Successful in 4m17s
release / build (aarch64-unknown-linux-musl) (push) Successful in 4m15s
release / build (x86_64-pc-windows-gnu) (push) Successful in 4m48s
release / build (x86_64-unknown-linux-musl) (push) Successful in 4m10s
2026-06-15 16:57:18 +00:00
claude@clouddev1 5f28de8ac3 docs(ci): move CI handoff into docs/ci/handoff (avoid main collision)
main independently wrote its own docs/handoff/20260615-handoff-70.md the
same day, so my global-sequence handoff-70 was an add/add conflict waiting
to merge. Relocate it to docs/ci/handoff/20260615-handoff-ci-01.md (its own
namespace, like docs/ci/adr) + a README index. main's handoff-70 is
untouched; the merge becomes conflict-free.
2026-06-15 16:56:50 +00:00
claude@clouddev1 888be16090 docs: handoff 70 — ADR-0052 follow-up + H2 hint shipped (ADR-0053)
ADR-0052 vestigial-source unwind; H2 contextual hint (F1 keybinding +
`hint` command) fully implemented Phases A–D, closing A1 + H2. 4 issues
filed (#35–#38, incl. 3 hint/help deferrals). CI branch merged into main
mid-session (D1 release work now on main). 2499 pass / 1 ignored, clippy
clean.
2026-06-15 16:47:18 +00:00
claude@clouddev1 329adfc935 fix(hint): labelled tier-3 block format + snapshot (ADR-0053 /runda)
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.
2026-06-15 16:45:47 +00:00
claude@clouddev1 447112b17f feat(hint): H2 Phase D — coverage gate, F1 strip, status flips (ADR-0053)
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.
2026-06-15 16:34:10 +00:00
claude@clouddev1 984bc30256 docs: record CI branch work — D1/D2 done, TT5 partial, handoff 70
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).
2026-06-15 16:31:58 +00:00
claude@clouddev1 417cbc8df9 docs(hint): defer pre-submit-diagnostic route + diagnostic.* blocks (ADR-0053)
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.
2026-06-15 16:28:54 +00:00
claude@clouddev1 b6b98ad30f feat(hint): H2 Phase C batch 5 — runtime error-class tier-3 hints (ADR-0053)
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.
2026-06-15 16:16:49 +00:00
claude@clouddev1 97970f2a2c feat(hint): H2 Phase C batch 4 — advanced-mode SQL tier-3 hints (ADR-0053)
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.
2026-06-15 16:14:23 +00:00
claude@clouddev1 9c4d520d5c feat(hint): H2 Phase C batch 3 — DML tier-3 hints (ADR-0053)
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.
2026-06-15 16:08:57 +00:00
claude@clouddev1 47a08166a4 merging ci branch
build-ci-image / build (push) Successful in 10m58s
ci / gate (push) Successful in 3m8s
2026-06-15 16:07:10 +00:00
claude@clouddev1 6429b56443 feat(hint): H2 Phase C batch 2 — DDL tier-3 hints (ADR-0053)
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.
2026-06-15 16:05:41 +00:00
claude@clouddev1 4bdfce6250 feat(hint): H2 Phase C batch 1 — app-command tier-3 hints (ADR-0053)
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.
2026-06-15 16:01:39 +00:00
claude@clouddev1 138e766817 Merge branch 'main' into ci
ci / gate (push) Successful in 3m5s
Bring main's latest (ADRs 0049-0053 + their features) onto the CI branch so
the gate runs against current main before CI lands on main. Clean merge —
ci and main touched disjoint files.
2026-06-15 16:01:09 +00:00
claude@clouddev1 aeb92f56a7 docs(ci): record macOS implementation in ADR-ci-003 (D1 complete)
ci / gate (push) Successful in 3m23s
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.
2026-06-15 15:56:38 +00:00
claude@clouddev1 4a5fd1b5c1 feat(hint): H2 Phase B — per-form keying + the three exemplars (ADR-0053)
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
2026-06-15 12:18:41 +00:00
claude@clouddev1 050b36391e feat(hint): H2 Phase A — hint command + F1 keybinding skeleton (ADR-0053)
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
2026-06-15 10:36:51 +00:00
claude@clouddev1 9868442889 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".
2026-06-14 22:18:59 +00:00
claude@clouddev1 309d2e0b3f ci: release-macos workflow (dispatch); retire macOS smoke-test
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).
2026-06-14 22:18:02 +00:00
claude@clouddev1 e16ad50aa7 docs(adr): ADR-0053 — contextual hint command + F1 keybinding (H2)
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.
2026-06-14 22:14:11 +00:00
claude@clouddev1 60dbb903cc ci: macOS smoke-test — run tests + nix-store generation pruning
macos-build-test / build (push) Successful in 2m59s
ci / gate (push) Successful in 3m6s
- 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.
2026-06-14 22:07:48 +00:00
claude@clouddev1 9a126782f1 ci: de-nix macOS binary libiconv via install_name_tool + re-sign
macos-build-test / build (push) Successful in 2m11s
build-ci-image / build (push) Successful in 9m49s
ci / gate (push) Successful in 2m50s
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.
2026-06-14 21:43:01 +00:00
claude@clouddev1 4d004f5847 ci: drop nix libiconv from darwin build (portable macOS binary)
macos-build-test / build (push) Failing after 1m36s
build-ci-image / build (push) Successful in 9m45s
ci / gate (push) Successful in 2m59s
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.
2026-06-14 21:36:08 +00:00
claude@clouddev1 d5fb47bcc8 ci: macOS build smoke-test + flake darwin support
macos-build-test / build (push) Successful in 3m52s
build-ci-image / build (push) Successful in 10m3s
ci / gate (push) Successful in 2m52s
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.
2026-06-14 21:28:41 +00:00
claude@clouddev1 0878c6df19 ci: target the macOS runner by label name macos (not macos:host)
macos-probe / probe (push) Successful in 57s
ci / gate (push) Successful in 2m46s
In act_runner a label is `<name>:<backend>`; `: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.
2026-06-14 21:21:13 +00:00
claude@clouddev1 52815f1a76 ci: trigger macos-probe on push (workflow_dispatch needs default branch)
ci / gate (push) Successful in 2m48s
macos-probe / probe (push) Has been cancelled
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.
2026-06-14 21:18:25 +00:00
claude@clouddev1 2721bd8d04 ci: macOS (Tart) runner probe — throwaway diagnostic
ci / gate (push) Successful in 3m1s
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.
2026-06-14 21:11:28 +00:00
claude@clouddev1 e8fa859ab9 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.
2026-06-14 13:47:49 +00:00
claude@clouddev1 ae73a4be85 docs: handoff 69 — four issues closed (#27/#28/#29/#30) + ADRs 0049–0052; tracker empty 2026-06-14 12:08:24 +00:00
claude@clouddev1 4aeea55984 feat(history): mode-tagged history + top-of-chain journaling (#30)
Record the submission mode per history entry so advanced commands are
reusable in simple mode, and fix the bug where a ':'-one-shot command
lost its ':' across sessions (ADR-0052, closing #30).

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

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

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

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

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

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

Forks user-chosen: editing state shows #29 keys; quit omitted; no
width-drop machinery (longest strip ~65 cols fits; a width-budget test
keeps it lean). Modal-aware strip is OOS (pre-existing). Tests: 9
Tier-1 unit (per-state key sets, width budget, mode pointer), 1 Tier-3
rewritten, 15 full-panel snapshots re-accepted (reviewed). 2467 pass /
0 fail / 0 skip, clippy clean.
2026-06-13 12:18:37 +00:00
claude@clouddev1 298475b326 ci: D1 release matrix over the four non-macOS targets
build-ci-image / build (push) Successful in 9m56s
ci / gate (push) Successful in 2m47s
release / test (push) Successful in 2m18s
release / build (aarch64-pc-windows-gnullvm) (push) Successful in 3m31s
release / build (aarch64-unknown-linux-musl) (push) Successful in 3m52s
release / build (x86_64-pc-windows-gnu) (push) Successful in 4m14s
release / build (x86_64-unknown-linux-musl) (push) Successful in 3m25s
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.
2026-06-13 12:14:49 +00:00
claude@clouddev1 04ebd83f08 build: D1 cross-compile via cargo-zigbuild (4 non-macOS targets)
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+.
2026-06-13 12:14:49 +00:00
claude@clouddev1 18d08642d7 ci: skip the gate for docs-only changes
ci / gate (push) Successful in 2m31s
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.
2026-06-12 22:42:50 +00:00
claude@clouddev1 da8bfebc36 docs(ci): establish docs/ci/adr namespace (ci-001 pipeline, ci-002 flake)
ci / gate (push) Successful in 2m33s
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.
2026-06-12 22:38:34 +00:00
claude@clouddev1 89b9392c25 ci: release job — test before publish, pin bash, fix diagnostic
release / release (push) Successful in 3m59s
build-ci-image / build (push) Successful in 7m27s
ci / gate (push) Successful in 2m27s
- 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`.
2026-06-12 22:11:24 +00:00
claude@clouddev1 bba24120f1 ci: scope gate + image-build to branch pushes (skip tags)
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.
2026-06-12 22:11:24 +00:00
claude@clouddev1 88145225cc ci: release workflow — static binary to Gitea releases on tag
build-ci-image / build (push) Has been cancelled
ci / gate (push) Successful in 2m32s
release / release (push) Failing after 3m2s
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.
2026-06-12 21:43:23 +00:00
claude@clouddev1 8e3208528e build: static musl release build capability
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.
2026-06-12 21:43:23 +00:00
claude@clouddev1 9d8161218a ci: gate workflow + CI-image build/push, drop probe
build-ci-image / build (push) Successful in 6m18s
ci / gate (push) Successful in 3m19s
- 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).
2026-06-12 21:08:04 +00:00
claude@clouddev1 dc63ed66f1 ci: nix CI toolchain image (node-slim base + warmed flake)
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.
2026-06-12 21:08:04 +00:00
claude@clouddev1 c7ac0c9877 ci: add throwaway runner-probe workflow
ci-probe / host (push) Failing after 54s
ci-probe / nix-container (push) Failing after 1m12s
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.
2026-06-12 20:35:39 +00:00
claude@clouddev1 9189740028 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.
2026-06-12 20:35:39 +00:00
claude@clouddev1 77c55fa669 docs(website): document the seed command, with cast (ADR-0048)
New Reference page "Generating sample data" (captured output + a
two-table seed cast showing generation, FK sampling context, and a
`set` override); cross-links from inserting-and-editing-data and
columns; seed added to the rdbms highlight grammar;
querying/sql-queries renumbered. Cast stance in STYLE.md revised to
"justify the absence". Refs #33, #34.
2026-06-12 14:41:37 +00:00
claude@clouddev1 4691d7950a Merge branch 'main' into website 2026-06-12 13:22:52 +00:00
claude@clouddev1 069f9277d1 fix(website): landing card links + keep inline code from breaking mid-token
- Add a teal 'more' link pinned to each landing feature card's
  bottom-right, pointing at the relevant doc page (modes, the SQL echo,
  undo & history, query plans, the assistive editor).
- Stop short inline code (flags like --all-rows) from breaking after a
  hyphen: white-space:nowrap on inline code only; block code in <pre>
  is unaffected and still wraps/scrolls.
2026-06-11 19:24:21 +00:00
claude@clouddev1 09b64cbfb7 feat(website): Open Graph social card
Add a 1200x630 social card (dark bg, monospace, teal database-table
motif, the relplay/RDBMS reveal) shown when pages are shared. Source
SVG in src/assets/og-card.svg, rasterised to public/og-card.png with
sharp. Wire site-wide og:image + twitter summary_large_image tags via
Starlight head, with the absolute relplay.org URL.
2026-06-11 18:59:02 +00:00
claude@clouddev1 abd3739168 feat(website): brand layer — teal palette, table logo, wordmark, footer
Anchor the site to the product's identity (Phase B branding):
- Accent palette mapped to the app's in-TUI teal (--sl-color-accent-*,
  light + dark); trim the splash hero's oversized desktop bottom padding.
- Header logo: a database-table glyph with a teal primary-key cell
  (light/dark variants); favicon redrawn to match.
- Landing wordmark 'RELational PLAYground': teal picks out REL+PLAY
  ('relplay', the domain) and R/D/B/M/S (spelling out RDBMS). Sizes
  locked in em off one master scale so the lockup zooms as a rigid unit.
- Footer override: default footer + an understated company (Lazy
  Evaluation Ltd) and source/issues line.

No engine names or 'DSL' in user-facing copy.
2026-06-11 18:50:07 +00:00
claude@clouddev1 a72d53de51 docs(website-adr): hosting target Vercel -> Cloudflare via Gitea Actions
Record the deployment decision (2026-06-11): static build deploys to
Cloudflare (Workers static assets or Pages; no Astro adapter needed),
driven by a planned Gitea Actions pipeline. Update the ADR-website-001
index entry to match.
2026-06-11 15:37:11 +00:00
claude@clouddev1 6777216e37 feat(website): set production site URL (relplay.org); record Phase B decisions
Set Starlight `site` to https://relplay.org (apex) — enables the
sitemap, canonical URLs, and Open Graph URL resolution; clears the
sitemap build warning. Record the resolved STYLE.md decisions:
single-version launch, fixed-dark cast theme (casts bake RGB colours,
so light/dark would need dual-theme recordings), and the site origin.
2026-06-11 15:36:29 +00:00
claude@clouddev1 13c9c1bcd9 feat(website): add payoff captions to joins/relationship-diagram/sql-echo
Use the ADR-0047 Ctrl+]-delimited demo caption to narrate the payoff
moment of three casts with a neutral one-liner (no key names): the join
result, the relationship diagram, and the m:n junction expansion. Add a
`caption` step kind to the cast generator. Captions show at the climax
during playback and clear as the cast quits.
2026-06-11 13:54:42 +00:00
claude@clouddev1 946dd88db6 feat(website): pace modes/undo-redo/joins casts (split submits, surface state)
Split the short pivotal commands from their submit so they read before
the screen changes (`mode advanced` in modes/joins; `undo`/`redo` before
their confirm modals), and surface the source tables in joins with
`show data` before the join, since the schema sidebar is hidden at 90 cols.
2026-06-11 13:49:40 +00:00
claude@clouddev1 ad43cce945 fix(website): connect box-art in plaintext output blocks
Tighten line-height (1.75 -> 1.2) on plaintext code blocks only, so the
box-drawing in rendered output (tables, query results, plan trees)
connects vertically as it does in the app. Command blocks keep the
looser spacing.
2026-06-11 13:35:29 +00:00
claude@clouddev1 7099bd3cde docs(website): expand the SQL-echo section; prune over-promised notes
Rewrite "Seeing the SQL behind a command" with the learning framing,
a grounded ALTER TABLE example, and the sql-echo cast. Drop the
"multiple result tabs" promise (won't-do on main) and the planned
`hint`-command note (superseded by the hint panel).
2026-06-11 13:28:37 +00:00
claude@clouddev1 5908891d6b feat(website): sql-echo cast — the DSL→SQL teaching echo demo
Advanced-mode cast running simple commands (create table, add column),
culminating in `create m:n relationship` expanding to a full junction
table, each tagged `Executing SQL:`. Recorded at height 34 so the long
m:n echo + junction structure stay fully on screen. Verified against
real app output.
2026-06-11 13:28:37 +00:00
claude@clouddev1 6778c338d4 docs(website): document m:n, --demo, schema sidebar, responsive input
Document the features the main merge shipped: `create m:n relationship`
(relationships ref + build-the-library note), the `--demo` teaching
flag (command-line-options), the Ctrl-O schema sidebar (output-pane,
now .mdx to embed the new cast), and horizontal/two-row input
(assistive-editor).
2026-06-11 12:26:31 +00:00
claude@clouddev1 823b413ca3 feat(website): schema-sidebar cast + Ctrl-O/Esc cast keys
Add a `schema-sidebar` cast that reveals the ADR-0046 sidebar with
Ctrl-O (the only way to show it at 90 cols) and steps through the
Tables and Relationships panels. Teach the generator the CtrlO/Esc
control codes; quote control codes so `^[`/`^]` stay valid YAML.
2026-06-11 12:26:25 +00:00
claude@clouddev1 a0dd202f67 feat(website): pace the projects cast + show table state; record cast guidelines
Projects cast review fixes:
- Pace the confirms: `save as` name and `new` now type, pause, then Enter as
  separate steps, so the viewer can read them before they execute.
- Insert `show tables` at each phase (before save / after `new` / after load),
  since the schema sidebar is hidden at 90 cols (ADR-0046) — the sequence now
  reads "books -> no tables -> books" so the round-trip is followable.

STYLE.md: new "Cast pacing & clarity" guidelines (beat-before-submit; surface
state where the sidebar would). Handoff item 2: chase these up across the
existing casts.
2026-06-11 10:56:46 +00:00
claude@clouddev1 595386e370 docs: note caption-banner review for existing casts in the handoff
Expand next-work item 2: review whether neutral step-caption banners (the demo
overlay) would improve the existing casts — narrating phases or calling out the
relationship diagram / teaching echo — cast-by-cast, with the no-naming-keys
constraint.
2026-06-11 10:43:38 +00:00
claude@clouddev1 51a29e5069 docs: website-branch session handoff (website-2)
Captures everything since website-1: the ADR-namespace move, all Reference +
Guides + the new SQL queries page, the cast pipeline + 7 casts (incl. the new
projects cast via #24 vi-nav), --demo on all casts (#22), and the main merge
(m:n/ADR-0045, UI sidebar+responsive input/ADR-0046, demo overlays/ADR-0047,
logging, FK fixes).

Flags the next-session work: document the merge's new features (m:n command,
--demo flag, ADR-0046 UI) which are not yet in the docs; the no-advertising
constraint (vi keys / Ctrl+] secret); cast tooling limits (no arrow keys);
the capture-harness recipe; Phase B; and open STYLE decisions.
2026-06-11 10:19:22 +00:00
claude@clouddev1 e782a280cc feat(website): projects cast (vi-nav load picker) + --demo on all casts
- New projects cast: create → save as library → new (fresh) → load → navigate
  the picker to the saved project (j, now possible via #24 vi-nav) → Enter
  loads it, the table is restored. Runs under an isolated --data-dir so the
  picker lists only this cast's projects.
- Turn on the demonstration overlay (--demo, #22 / ADR-0047) for ALL casts,
  for a consistent viewer experience: special keys show a badge — e.g.
  [ENTER], and [TAB] at the assistive-editor's completion moment, finally
  making that keystroke visible. Plain j/k navigation stays unbadged, so the
  picker navigation is not surfaced.
- Generator: per-cast `dataDir` (isolated data root) + default-on `--demo`
  (opt out with demo:false). All 7 casts regenerated.

Convert projects.md → .mdx and embed. Build clean (26 pages). Visual playback
of all casts pending a tunnel check.
2026-06-11 10:17:04 +00:00
claude@clouddev1 927e6b2d50 Merge branch 'main' into website (m:n, logging, UI nav, demo overlays, vi-nav)
Brings a large batch of app work onto the website branch so the docs (and
casts) can reflect it:

- #24 vi-style j/k/g/G navigation in the load picker (ADR-0047 era) — unblocks
  a scriptable projects cast (autocast can send j/k; not arrows)
- #22 demonstration overlay layer (ADR-0047): `--demo` mode, keystroke badges,
  and step-caption info banners — usable from casts to highlight key moments
- C4 m:n convenience command (ADR-0045): `add m:n relationship … via <junction>`
- ADR-0046 UI: width-derived schema sidebar + Ctrl-O nav mode, responsive
  two-row input + horizontal scroll, geometry-fixed hint panel
- X1 comprehensive logging sweep across worker/parser/app/persistence/runtime
- FK fixes: compound-FK violation message names every column pair; inline FK
  referencing a compound PK points at the table-level form

Merged clean — no conflicts (the docs/website/ ADR namespace split kept the new
main ADRs 0045–0047 from colliding). Tests on the merged tree: 2290 passed,
0 failed (1 ignored doctest, inherited from main).
2026-06-11 10:06:18 +00:00
claude@clouddev1 52860c3267 feat(website): casts for first-project/modes/undo-redo; quit invisibly via Ctrl-C
Three more casts on "doing" pages:
- first-project reuses the quickstart cast (the create→insert→show tour)
- modes (new): a simple command, then `mode advanced` where the same command
  also prints "Executing SQL: …" (the teaching echo — "learn the SQL underneath")
- undo-redo (new): insert two rows, `undo` (Y-confirm modal) backs one out,
  `redo` restores it

Also fix the cast endings (review feedback): scripts ended by typing a `quit`
command, which — once the trim drops the shell exit — left a dangling "quit" in
frame with no payoff. End every cast with Ctrl-C instead (the app's quit key,
KeyCode::Char('c')+CTRL): it types nothing, so the cast ends cleanly on the
last content frame. Generator gains a `CtrlC` key; all six casts regenerated.

Convert the three pages to .mdx and embed. Build clean (26 pages); 6 casts.
2026-06-10 16:36:07 +00:00
claude@clouddev1 ce153bde4c docs(website): add SQL queries reference page (advanced query surface)
New dedicated Reference page for the advanced-mode SQL query features
under-covered by "Querying & inspecting": DISTINCT, GROUP BY/HAVING, set
operations (UNION/INTERSECT/EXCEPT), subqueries (IN + correlated NOT EXISTS),
CTEs (WITH), and expressions (CASE/CAST/functions) — each with a worked example
on the library schema and real captured output. Adds an explicit "supported
subset" boundary note (views, triggers, transactions, window functions,
multi-statement batches are not available) rather than linking to general SQL,
which would advertise unsupported features. Grounded in ADR-0030 §3/§13 and the
SQL grammar tests.

Cross-link added from Querying & inspecting. Build clean (26 pages);
box-drawing output verified; forbidden terms clean.
2026-06-10 15:31:05 +00:00
claude@clouddev1 302329d5b2 docs(website): record the cast-placement policy in STYLE.md
"A cast wherever the app does something": broad on Getting-started /
Using-the-playground / Guides + the landing; selective on Reference (motion
beats the still, static output kept regardless); skip pure-lookup/conceptual.
Casts are selective (a representative slice, not every command); autoplay only
the landing; all re-record via `pnpm casts`.
2026-06-10 14:32:48 +00:00
claude@clouddev1 65a48fa5ae feat(website): joins cast on the querying-with-joins guide
Fourth cast: build a minimal two-table schema with rows, switch to advanced
mode (`mode advanced`), and run a join pairing each book with its author —
shows the mode switch + SQL + multi-table result, motion that complements the
guide's static examples. Convert the guide to .mdx and embed above the intro.

Recorded via `pnpm casts`; build clean (25 pages).
2026-06-10 14:26:27 +00:00
claude@clouddev1 bb7887ea82 feat(website): relationship-diagram cast on the relationships page
Third earmarked cast: declare a 1:n relationship, then `show relationship`
draws the two-table connector diagram — showcases the V1 relationship
visualization with motion a still block can't. Convert the relationships
reference page to .mdx and embed it above the syntax (the static diagrams
below remain the exact reference).

Recorded via `pnpm casts`; build clean (25 pages).
2026-06-10 14:13:14 +00:00
claude@clouddev1 a8f84c9d17 feat(website): refine casts — trim shell, autoplay+loop landing, cap size
Address cast review feedback:

- Trim every cast to the in-app region (generate.mjs): the recording now
  starts with the app already running and ends on the last in-app frame —
  drops the `$ rdbms-playground` launch and the return-to-shell frame (the
  latter was the stray cursor-under-$ artifact). Opt out per cast with
  `keepShell: true` for demos that document the CLI launch.
- Landing quickstart cast: autoPlay + loop, with a 2.5s hold on the final
  frame so it pauses before restarting.
- Cap the demo at max-width 46rem and centre it, so the player (fit:'width')
  no longer scales its font up to the full splash column.

Casts re-recorded via `pnpm casts`. Build clean (25 pages).

Tab-keypress visibility deferred to an in-app overlay primitive (filed as
issue #22 — also serves the planned guided-lesson system); the cast notes
Tab in its caption for now.
2026-06-10 13:56:39 +00:00
claude@clouddev1 1f82fb2c79 chore(website): upgrade astro 6.4.5 + starlight 0.40.0 (clears markdown deprecation)
Astro 6.4.5 deprecated markdown.remarkPlugins/rehypePlugins/remarkRehype in
favour of the new unified() API. The warning came from @astrojs/starlight
0.39.3's own integration code, not our config; Starlight 0.40.0 adopts the new
API, so it's gone.

- astro 6.4.4 -> 6.4.5; @astrojs/starlight 0.39.3 -> 0.40.0
  (brings astro-expressive-code 0.43.1, @astrojs/markdown-remark 7.2.0)
- Starlight 0.40.0 adds an optional @astrojs/markdown-satteri peer (an opt-in
  high-performance markdown engine); it's an optional peer so pnpm doesn't
  install it, and we have no need for it — we stay on the default
  markdown-remark/unified pipeline

Verified: deprecation gone; pnpm build clean (25 pages); rendering signals
unchanged vs baseline (highlighting, > prompt + copy-button :has() CSS, asides,
embedded casts); pnpm audit clean.
2026-06-10 13:17:49 +00:00
claude@clouddev1 44f91724b6 feat(website): assistive-editor cast content (completes c904dbb)
The previous commit captured only the .md→.mdx rename — a botched `git add`
(a stale .md pathspec aborted the whole add) dropped the actual content. This
adds it:

- casts.mjs: the assistive-editor cast definition (Tab completion → the [ERR]
  validity indicator catching a misspelled table → friendly error → corrected
  command). Behavior verified by a throwaway spike before scripting.
- public/casts/assistive-editor.cast (generated via `pnpm casts`)
- embed the cast under the intro on the assistive-editor page

Verified: pnpm build clean (25 pages); cast bundled, served, and referenced.
Visual playback check pending (verify via dev server/tunnel).
2026-06-10 13:05:04 +00:00
claude@clouddev1 c904dbb68b feat(website): assistive-editor cast (completion + [ERR] indicator)
Add a second demo, earmarked as prime cast material: Tab completes a table
name, then the [ERR] validity indicator catches a misspelled table before
submit, the friendly error confirms it, and the corrected command runs.
Behavior verified by a throwaway spike before scripting.

- casts.mjs: assistive-editor cast definition
- public/casts/assistive-editor.cast (generated)
- convert the-assistive-editor.md -> .mdx and embed the cast under the intro

Verified: pnpm build clean (25 pages); cast bundled, served, and referenced
on the page. Visual playback check pending (verify via dev server/tunnel).
2026-06-10 13:04:31 +00:00
claude@clouddev1 fbf449f9e0 feat(website): asciinema cast pipeline + landing quickstart demo
Settle the cast toolchain (STYLE.md #9) and build the demo pipeline end to
end. Driver: autocast, chosen by spike — its !Interactive feeds keys to the
running TUI and captures the redraw, the right model for a full-screen
crossterm app. asciinema-automation was rejected (assumes shell echo/\n Enter;
produced a garbled cast against the TUI).

- add asciinema-player; Cast.astro (player island) + Demo.astro (the WASM-swap
  seam, ADR-website-001 §3)
- casts-src/: human-readable command-lists (casts.mjs) + generate.mjs, exposed
  as `pnpm casts`; expands steps to autocast YAML and records to public/casts/.
  Command-lists are the durable source; .cast files are regenerable (final
  re-record sweep due once the app is locked).
- quickstart.cast (create -> add columns -> insert -> show data) embedded on
  the landing page above the feature cards.

Verified: pnpm build clean (25 pages); player + cast bundled and served;
landing HTML references the cast. Visual playback check pending (no headless
browser here — verify via dev server over the tunnel).
2026-06-10 12:59:50 +00:00
claude@clouddev1 c0cc92a741 docs(website): rewrite Build the library + add Querying with joins guide
Build the library: polish and extend from a 2-table draft into the full
guided build — all four tables, the authors→books 1:n and the books↔members
m:n through the loans bridge (bridge-table concept taught in context), the
isbn unique constraint, and captured show-relationships / show-data output.
Remove the draft marker; it is now publication-quality. Uses the same sample
rows as the Reference pages so output matches across the site.

Querying with joins (new): joins built up from two tables → the three-table
bridge join → a filtered join → a group-by aggregate, all in advanced mode
with real captured output.

Verified: pnpm build clean (25 pages); no forbidden terms; internal links
resolve. Advanced-mode `mode advanced`/`mode simple` and the unique
constraint checked against source.
2026-06-10 12:11:39 +00:00
claude@clouddev1 10655e46de docs(website): fill the six Reference stubs with worked examples + output
Expand columns, relationships, indexes, constraints, inserting-and-editing-
data, and querying-and-inspecting from syntax-only stubs into full pages,
each with worked examples on the library schema and real captured app output
(structure boxes, relationship diagrams, data tables, show-lists, query
plans, cascade summaries). All output captured verbatim from the app — never
hand-drawn. Both simple- and advanced-mode forms shown where both apply;
advanced syntax verified against tests/source.

STYLE.md: record the output-block convention (plain unlabelled fence,
captured from a throwaway harness, not hand-drawn).

Verified: pnpm build clean (24 pages); no forbidden terms; internal links
and heading anchors resolve.
2026-06-10 11:50:18 +00:00
claude@clouddev1 619c200ea1 Merge branch 'main' into website (V1 relationship visualization)
Brings main's relationship-visualization feature (ADR-0044 in the main
namespace) and Gitea-migration cleanup onto the website branch, so the
docs can be written against the new diagram output:

- show relationship <name> / show table <T> render two-table connector
  diagrams (child-FK-left, parent-right, n…1 cardinality)
- compound-FK bus routing + pairing line
- ~2000 lines across src/{app,db,event,output_render,runtime,ui}.rs,
  new insta snapshots, tests/it/{show_list,compound_fk}.rs

Merged clean — no conflicts. The prior commit moved the website ADR out
of docs/adr/ into its own namespace, so main's ADR-0044
(relationship-visualization) lands with no collision.

Tests on the merged tree: 2207 passed, 0 failed, 0 skipped
(1 ignored doctest, inherited from main).
2026-06-10 11:06:14 +00:00
claude@clouddev1 dfb5f0b1b1 docs: move website ADR + plan into a dedicated docs/website/ namespace
The website subproject drew ADR numbers from the same global integer pool
as main, so every merge risked a collision — this already happened twice
(drafted 0042, bumped to 0044, both landing on numbers main had taken; main
has now used 0044 for relationship visualization). Give the website its own
ADR namespace so the two never compete again.

- docs/adr/0044-public-website-...md
    -> docs/website/adr/20260604-adr-website-001.md  (id: ADR-website-001)
- docs/plans/20260604-adr-0044-website.md
    -> docs/website/plans/20260604-website-implementation-plan.md
- new docs/website/adr/README.md index (dated <date>-adr-website-<NNN> seq)
- docs/adr/README.md: drop the 0044 entry, add a namespace pointer in the
  intro (keeps the list tail == merge-base, so main's 0044 merges cleanly)
- ADR-0000: record the subproject-ADR-namespace convention
- update references in STYLE.md, astro.config.mjs, the plan body

Handoff files left untouched as point-in-time history.
2026-06-10 11:04:55 +00:00
claude@clouddev1 39e97ac3f9 docs: website-branch session handoff (website-1)
First handoff for the website work, on a branch-scoped name sequence
(…-website-handoff-N.md) to avoid colliding with main's handoff-NN files.
Captures stack/layout, the five-section structure, binding conventions
(no DSL / no engine name / fence + prompt + copy rules), the canonical
library schema, a verified-syntax cheat-sheet, the dev-server IPv4 gotcha,
next-work priorities (fill the 6 Reference stubs, iterate guides, Phase B
landing, deferred casts), and process pins.
2026-06-10 10:41:45 +00:00
claude@clouddev1 936d9254c0 feat: add "Using the playground" section + Reference skeleton
Restructure the docs into five top-level sections, splitting the
application you drive from the database language you build with.

- New "Using the playground" section: command-line options; the assistive
  editor (completion, highlighting, [ERR]/[WRN] indicator, hints, in-line
  editing); the output pane (scrolling); projects (save/load/new/rebuild);
  undo/redo & history; export & import; clipboard; getting help. Grounded in
  the in-app help/usage and ADR-0003/0022/0027.
- Reference: seed the remaining topic pages (Columns, Relationships,
  Indexes, Constraints, Inserting & editing data, Querying & inspecting)
  with real syntax synopses; worked examples to follow.
- Surface the assistive editor on the landing page and in Getting started;
  restore cross-links now that targets exist.

Plan + STYLE updated to the five-section structure. 24 pages, build green,
links resolve, content clean; planned features carry "planned" callouts.
2026-06-10 10:40:07 +00:00
claude@clouddev1 44390e765d feat: simple-mode code-block highlighting, prompt, and copy rules
Add a custom Shiki grammar for the simple-mode command language
(src/grammars/rdbms.mjs), registered with Expressive Code. Two language ids
share it: rdbms (real commands) and rdbms-syntax (abstract templates).
Simple-mode blocks now highlight; advanced examples keep sql.

Separation + copy ergonomics via CSS (global.css): a decorative, copy-safe
"> " prompt on rdbms command lines (not in the copy buffer), and the copy
button hidden on multi-command rdbms blocks and on rdbms-syntax templates
(the app input is single-line, so a multi-command paste is not runnable);
single-command, sql, and sh blocks keep copy.

Content: convert 22 simple-mode fences to rdbms; lead the simplest examples
(first project, Tables reference) with bare "with pk" (the beginner default
that creates a ready-made id key), pointing to the named form. Record the
fence + prompt conventions in STYLE.md.
2026-06-09 22:30:44 +00:00
claude@clouddev1 995c0ba8eb docs: reconcile website doc inventory with merged main scope
The merge from main added user-facing surface the pre-merge inventory had
listed as planned. Mark them documented-as-shipped: show tables /
relationships / indexes + show relationship/index <name> (V5/V5a),
help [<command>] + help types (H3), compound-primary-key foreign-key
references (T3, ADR-0043), and friendlier parse-error messaging (H1a).
Refresh the test count to 2193 and note requirements.md now uses a [/]
partial marker (trust the code, not the marker).
2026-06-09 22:30:44 +00:00
claude@clouddev1 c72c624daa chore: bind website dev/preview server to IPv4 loopback (127.0.0.1)
Astro/Vite's default localhost bind resolves to IPv6 ::1 on this host,
which silently breaks `ssh -L 4321:127.0.0.1:4321` tunnels (they target
IPv4). Pin server.host to 127.0.0.1 so dev/preview is reachable over an
IPv4 loopback forward. Loopback-only — no network exposure.
2026-06-09 21:44:43 +00:00
claude@clouddev1 9e774b2dfa docs: ADR numbering discipline — assign numbers at merge-to-main
Codifies the fix for the ADR-0042 cross-branch collision (resolved this
merge by renumbering the website ADR to 0044): ADR numbers are assigned
when a branch merges to main, not at creation. On a branch, draft under
a placeholder (ADR-XXXX title / draft-<slug>.md filename); main's
docs/adr/README.md is the single source of truth for the next free
number.

- ADR-0000: new "Numbering discipline" section.
- CLAUDE.md: pointer to it from the documentation-discipline note.
2026-06-09 20:30:36 +00:00
claude@clouddev1 40de389bcb Merge branch 'main' into website (Gitea migration + ADR renumber)
Brings website up to date with main (18 commits): H1a parse-error
pedagogy, V5/H3/V5a show+help commands, ADR-0043 compound-PK FK,
handoffs 58-59, and the GitHub->Gitea doc scrub (Cargo.toml repository,
CLAUDE.md, ADR-0001 amendment, requirements).

Conflict: docs/adr/README.md. main and website had each created an
ADR-0042 (main: H1a parse-error pedagogy; website: public website &
docs site). Renumbered the website ADR to 0044 (next free after main's
0042/0043) and updated all references (ADR file, plan file, STYLE.md,
astro.config.mjs, README index). Website build verified green.
2026-06-09 20:28:27 +00:00
claude@clouddev1 0fcb7b1105 docs: website docs structure + first content pages
Phase D foundation. Configures the pragmatic four-section sidebar
(Getting started / Guides / Reference / Concepts) and replaces the
template example pages with grounded content built on the shared
"library" example database (authors/books/members/loans):

- Getting started: installation, first project, simple vs advanced,
  the example library.
- Reference: Types (all ten + serial/shortid + advanced aliases),
  Tables (create/drop, compound PK, advanced CREATE TABLE).
- Concepts: projects & storage (readable files, derived database,
  autosave, temp projects).
- Guides: Build the library (draft, to be refined for teaching).

Command syntax grounded in en-US.yaml usage/help, command.rs, and
types.rs (verified against tests). Records the settled doc decisions
in STYLE.md. Build green (10 pages, Pagefind); content clean of
"DSL"/engine-name.
2026-06-06 07:34:57 +00:00
claude@clouddev1 cea99e8b70 chore: scaffold website (Astro 6 + Starlight + Tailwind v4)
Phase A of docs/plans/20260604-adr-0042-website.md. Scaffolds the site
under website/ from the Starlight template; adds Tailwind v4 (via
@tailwindcss/vite) bridged to Starlight with @astrojs/starlight-tailwind
(src/styles/global.css + customCss). Production build is green: static
output, Pagefind search index, sharp image optimization.

Template placeholders (title, example pages, sidebar) are left for
Phase B/D. Reconciles the ADR/plan/index wording from "Astro 5" to
"Astro 6" to match the scaffolded toolchain.
2026-06-05 15:00:12 +00:00
claude@clouddev1 1fad29c0f9 docs: ADR-0042 — public website + documentation site plan
Planning artifacts for the first public website, recorded before any
code is written.

- ADR-0042: the decisions — Astro 5 + Starlight + Tailwind v4 (over
  SvelteKit); asciinema .cast demos reusable in docs (scripted-input
  driver, not history.log replay); in-page WASM playground deferred
  behind a stable demo seam, with the portable-core vs native-edge
  boundary recorded for a future ADR; portable static hosting (Vercel
  target); monorepo (website/); website is the canonical docs home;
  full-feature-set docs with "planned" callouts; user-facing copy uses
  no engine name and no "DSL"; install via prebuilt binaries + package
  managers.
- docs/plans/20260604-adr-0042-website.md: implementation plan with the
  grounded documentation inventory and phases A–E.
- website/STYLE.md: living documentation style guide + open-decisions log.
- docs/adr/README.md: index updated for ADR-0042 (numerical order).
2026-06-05 08:13:36 +00:00
235 changed files with 28490 additions and 5713 deletions
+17
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
use flake
+7
View File
@@ -0,0 +1,7 @@
# Revisions to ignore in `git blame` — bulk, mechanical, no-behaviour-change
# commits whose authorship is noise. Enable locally with:
# git config blame.ignoreRevsFile .git-blame-ignore-revs
# (Forges that support it, e.g. GitHub, pick this up automatically.)
# style: format the whole tree with cargo fmt (stock defaults, #35)
41b7e9a04992cd9708f1775b57044de838b48b85
+65
View File
@@ -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.
+51
View File
@@ -0,0 +1,51 @@
# 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:
# 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'
- '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"
+48
View File
@@ -0,0 +1,48 @@
# 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 = fmt + clippy + test. The fmt gate (`cargo fmt --check`, stock defaults)
# was enabled once the tree was reformatted on main (ADR-ci-002 Amendment 1 /
# issue #35). The release job (static binary for D2) and the platform matrix
# layer on later, step by step.
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: ['**']
# Skip the gate for changes that can't affect clippy/test — docs, markdown,
# and the website subproject (it has its own workflow, website.yaml, that
# builds + publishes it). A push touching crate code *and* these still runs
# (paths-ignore only skips when *all* changed files match).
# Note: flake/toolchain changes are NOT ignored — they can shift the
# toolchain and thus lint/test outcomes.
paths-ignore:
- 'docs/**'
- '**/*.md'
- 'website/**'
- '.gitea/workflows/website.yaml'
pull_request:
paths-ignore:
- 'docs/**'
- '**/*.md'
- 'website/**'
- '.gitea/workflows/website.yaml'
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: fmt (check, stock defaults)
run: nix develop -c cargo fmt --check
- 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
+95
View File
@@ -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
+117
View File
@@ -0,0 +1,117 @@
# 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.
#
# 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)
# The two macOS targets are built separately by the dispatched
# release-macos.yaml (native Tart runner; ADR-ci-003 amendment), uploading to
# the same release. D3 package-manager manifests layer on later.
#
# 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. The
# version guard (ADR-0054) refuses to publish a tag whose vX.Y.Z disagrees with
# Cargo.toml's version, keeping `--version`, the release name, and the asset in
# lockstep.
name: release
on:
push:
tags:
- 'v*'
jobs:
test:
runs-on: ci-public
container:
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
steps:
- uses: actions/checkout@v4
- name: version guard — tag must equal Cargo.toml version (ADR-0054)
shell: bash
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# Read the [package] version straight from Cargo.toml — toolchain-free
# and robust. (An earlier `nix develop -c cargo metadata | node` version
# broke: the flake devShell prints a banner to stdout, corrupting the
# JSON pipe.) `^version = ` is anchored, so it matches only the package
# version, never the `version = ` inside dependency inline tables.
VER=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/^version = "(.*)"/\1/')
echo "tag=$TAG cargo=v$VER"
if [ -z "$VER" ]; then
echo "ERROR: could not read the package version from Cargo.toml" >&2
exit 1
fi
if [ "$TAG" != "v$VER" ]; then
echo "ERROR: release tag '$TAG' != 'v$VER' (Cargo.toml). Bump Cargo.toml to the release version, commit, then retag (ADR-0054)." >&2
exit 1
fi
- name: test
run: nix develop -c cargo test --no-fail-fast
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: 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
env:
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
# 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" \
-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 $TARGET assets for $TAG"
+66
View File
@@ -0,0 +1,66 @@
# Build the docs/marketing website and deploy it to Cloudflare Pages.
#
# One Pages project, two branches (no second project, no sub-folders — Pages
# maps a branch to a *subdomain alias*, not a path):
# main → production (the project's production branch → relplay.org)
# website → preview (alias `website.<project>.pages.dev`; a custom
# `staging.relplay.org` can be attached to it)
# wrangler treats `--branch=<production-branch>` as a production deploy and any
# other branch as a preview, so a single workflow covers both — the Pages
# project's production branch MUST be set to `main`.
#
# Pure-Node build: the `.cast` recordings are committed, so no cargo/Rust is
# needed here. Runs on the bare `ci-public` runner (node already present; pnpm
# via corepack, pinned by package.json's `packageManager`). No job container —
# unlike the Rust gate, this needs none.
#
# Required Actions secrets (set once in the repo/org settings):
# CLOUDFLARE_API_TOKEN — token with "Cloudflare Pages: Edit" on the account
# CLOUDFLARE_ACCOUNT_ID — the account id that owns the Pages project
name: website
on:
push:
branches: [main, website]
# Only when the site (or this workflow) actually changes — crate-only
# pushes don't redeploy the site.
paths:
- 'website/**'
- '.gitea/workflows/website.yaml'
workflow_dispatch:
jobs:
deploy:
runs-on: ci-public
defaults:
run:
working-directory: website
steps:
- uses: actions/checkout@v4
- name: preflight — toolchain present
run: |
node --version
corepack --version
- name: enable pnpm (pinned by packageManager)
run: corepack enable
- name: install
run: pnpm install --frozen-lockfile
- name: build
run: pnpm build
- name: deploy to Cloudflare Pages
shell: bash
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
# `dist` is relative to website/ (the working-directory). The branch
# name decides production (main) vs preview (anything else).
npx --yes wrangler@4 pages deploy dist \
--project-name=relplay \
--branch="$BRANCH"
+12
View File
@@ -2,11 +2,23 @@
/target
**/*.rs.bk
# Nix
# `nix build` output symlinks (`result`, `result-<name>`), direnv's cached env
/result
/result-*
.direnv/
# Snapshot test review files
*.snap.new
*.pending-snap
# Website tooling — Cloudflare Wrangler local cache/state (regenerable;
# CI deploys from website/, this dir only appears on a local wrangler run)
.wrangler/
# Editor / OS
.DS_Store
*.swp
*.swo
# Astro/template-seeded editor configs we don't track (e.g. website/.vscode)
.vscode/
+88 -44
View File
@@ -37,9 +37,9 @@ Current decisions at a glance (each backed by an ADR):
simple to advanced (ADR-0003). No other sigils.
- **Project format:** `project.yaml` + `data/<table>.csv` +
`history.log`; `playground.db` is a derived artifact (ADR-0004,
amended by ADR-0015). Implemented through Iteration 4 +
cleanup; export/import (Iter 5) and migration framework /
--resume / persistent input history (Iter 6) pending.
amended by ADR-0015). Fully implemented (ADR-0015 Iterations
16): export/import, `--resume`, persistent input history, and
the migration framework scaffold are all done.
- **Project storage runtime:** every command persists through to
db + yaml + csv + history.log in one execution context, gated
by the combined db persistence logic; commit-db-last ordering
@@ -108,41 +108,87 @@ 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** (developed on the `ci` branch,
**merged to `main` 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). Now that this is on `main`,
`release-macos` is dispatchable (`workflow_dispatch` is
Gitea-default-branch-only) — **dispatched and verified working**: the
macOS build + de-nix/re-sign + upload runs end-to-end and the binaries
launch.
- **Website & docs site** (developed on the `website` branch, **merged
to `main`**; the branch **stays open** for future staging deploys;
decisions in `docs/website/adr/` — **ADR-website-001**, its own
namespace like CI's): an **Astro + Starlight + Tailwind** marketing
landing page plus the **canonical** user docs, under `website/`.
Showcase demos are **asciinema casts** (a scripted-input driver, paced
+ re-recordable — *not* `history.log` replay; the `--demo` overlay,
ADR-0047, dresses them). **Deployed to Cloudflare Pages via Gitea
Actions** (`.gitea/workflows/website.yaml`; the crate gate is skipped
for website-only changes). Two copy rules bind user-facing text: **no
engine name** (continues ADR-0002) and **no "DSL"** (say "simple mode"
/ "advanced mode"). Install docs are still partial — package-manager
manifests + some installation instructions are pending (`requirements.md`
D3 / DOC1).
## Repository layout
```
.
├── Cargo.toml # dependencies, lints (nursery)
├── CLAUDE.md # this file
├── Cargo.toml / Cargo.lock # dependencies, lints (nursery)
├── CLAUDE.md # this file
├── flake.nix / flake.lock # pinned Rust toolchain, one source for dev + CI (ADR-ci-002)
├── rust-toolchain.toml # toolchain pin
├── .gitea/ # Gitea Actions workflows + prebuilt CI image (ADR-ci-001..003, website)
├── ci/ # CI build helpers (e.g. winstub/ — Windows link stub)
├── docs/
│ ├── adr/ # all decision records (read 0000 first)
│ ├── handoff/ # session-handover notes
── requirements.md # the Phase-1 checklist with progress
│ ├── adr/ # project-wide decision records (read 0000 first)
│ ├── ci/{adr,handoff}/ # CI subproject ADRs (ci-001..003) + handoffs
── website/{adr,plans}/ # website subproject ADRs (website-001) + plan
│ ├── handoff/ # session-handover notes
│ ├── plans/ # working plans
│ └── requirements.md # the Phase-1 checklist with progress
├── src/
│ ├── action.rs # Action enum (Quit / ExecuteDsl)
│ ├── app.rs # App state + pure update() + Tier-1 tests
│ ├── cli.rs # CLI args (--theme, --log-file)
│ ├── db.rs # rusqlite worker, all DDL/DML, metadata tables
│ ├── action.rs # Action enum (Quit / ExecuteDsl / …)
│ ├── app.rs # App state + pure update() + Tier-1 tests
│ ├── cli.rs # CLI args (--theme, --log-file, --demo, --no-undo, --resume, …)
│ ├── clipboard.rs # copy output to the system clipboard
│ ├── completion.rs # Tab completion + schema cache
│ ├── db.rs # rusqlite worker, all DDL/DML, metadata tables
│ ├── dsl/
│ │ ├── action.rs # ReferentialAction enum + parsing
│ │ ├── command.rs # Command AST + RelationshipSelector + RowFilter
│ │ ├── mod.rs # re-exports
│ ├── parser.rs # parse entry point → unified-grammar walker
│ ├── shortid.rs # base58 generator + validator
│ ├── types.rs # user-facing Type enum + fk_target_type
│ └── value.rs # Value/Bound + per-type validation
│ ├── event.rs # AppEvent (input + DSL outcomes)
│ ├── lib.rs # module re-exports for tests
│ ├── logging.rs # tracing setup, file-backed
│ ├── main.rs # binary entry; thin
│ ├── mode.rs # Simple/Advanced mode enum
│ ├── runtime.rs # Tokio loop, terminal setup, dispatch
│ ├── snapshots/ # insta snapshots for Tier-2 tests
── theme.rs # light/dark themes
│ └── ui.rs # ratatui rendering
└── tests/
└── walking_skeleton.rs # Tier-3 integration tests
│ │ ├── grammar/ # hand-rolled unified grammar nodes (DSL + SQL)
│ │ ├── walker/ # grammar walker (driver / context / highlight / outcome)
│ │ ├── command.rs parser.rs types.rs value.rs action.rs shortid.rs sql_functions.rs
├── echo.rs # command echo / SQL rendering
├── event.rs # AppEvent (input + DSL outcomes)
├── friendly/ # friendly-error layer + string catalog (strings/en-US.yaml) + keys
├── input_render.rs # input-field render + ambient hint classification
│ ├── output_render.rs # output-panel render helpers (incl. relationship diagrams)
│ ├── logging.rs main.rs mode.rs runtime.rs # tracing / entry / mode enum / Tokio loop
│ ├── persistence/ # csv + yaml + history IO + migrations
│ ├── project/ # open/create, lock, naming, prettifier
│ ├── seed/ # seed generators / heuristics / vocabulary (ADR-0048)
│ ├── snapshots/ # insta snapshots for Tier-2 tests
│ ├── theme.rs type_change.rs ui.rs undo.rs # themes / column type-change / render / undo ring
── lib.rs # module re-exports for tests
├── tests/
│ ├── it/ # Tier-3 integration tests (consolidated into one binary)
└── typing_surface_matrix.rs # typing-surface matrix (separate Tier-3 target)
└── website/ # Astro + Starlight docs/marketing site (ADR-website-001)
├── src/ public/ casts-src/ # pages + assets + asciinema cast sources
└── astro.config.mjs package.json … # deploys to Cloudflare Pages via Gitea Actions
```
Key invariants in the code:
@@ -165,7 +211,10 @@ Key invariants in the code:
ADR. In-flight discussion stays in conversation or issues
until it settles. The ADR-0000 index-upkeep rule applies:
every ADR change updates `docs/adr/README.md` in the same
edit.
edit. ADR **numbers** are assigned at merge-to-`main` (draft
under a placeholder `ADR-XXXX` / `draft-<slug>.md` on a
branch) to avoid cross-branch collisions — see ADR-0000
"Numbering discipline".
- **Issue tracking.** Bugs and enhancements are filed as Gitea
issues (see *Issue tracking — Gitea via `tea`* below).
`docs/requirements.md` and the ADRs remain the source of truth
@@ -318,16 +367,8 @@ all of `target/`, forcing a full from-scratch rebuild).
These are explicitly tracked (mostly in `requirements.md`) but
not yet implemented:
- **Project storage** (track 2): largely implemented through
Iteration 4 + cleanup pass + safety hardening (Iterations
14 of ADR-0015). Pending pieces: `export` / `import` (Iter
5), `--resume` + persistent input history hydration +
migration framework scaffold (Iter 6).
- **Modify relationship** (C3a): drop+add covers the use case
today.
- **m:n convenience** (C4): auto-generates a junction table
with appropriate FKs — depends on relationships being solid
(they are).
- **Strong syntax-help in parse errors** (H1a): point users at
missing keywords/clauses rather than the unexpected
character. *(H1 — the friendly **database**-error layer — is
@@ -338,14 +379,17 @@ not yet implemented:
- **Session log + Markdown export** (V4): the bigger UX
project — scrollable session journal, smart structure
rendering, save-as-markdown.
- **Readline shortcuts** (I1b): Ctrl-A/Ctrl-E, Ctrl-W/Ctrl-K/
Ctrl-U.
- **Multi-line input** (I1): Enter inserts newline,
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
+20
View File
@@ -0,0 +1,20 @@
# Contributing to rdbms-playground
Contributions are welcome — bug reports, ideas, and pull requests. The
project lives on Gitea at
<https://git.lazyeval.net/oli/rdbms-playground>; please file issues and
open pull requests there. It's approaching its first public release, so
the most useful contributions right now are bug reports and rough edges
you hit while learning.
## License of contributions
Unless you explicitly state otherwise, any contribution you intentionally
submit for inclusion in this project — as defined in the Apache-2.0
license — shall be **dual-licensed under `MIT OR Apache-2.0`** (the
project's licenses), without any additional terms or conditions.
This is the standard Rust "inbound = outbound" arrangement: your
contribution is offered under the same licenses the project distributes
under, so — via Apache-2.0 §5 — it carries the Apache-2.0 §3 patent grant
to all users. No separate CLA is required.
Generated
+1 -1
View File
@@ -1535,7 +1535,7 @@ dependencies = [
[[package]]
name = "rdbms-playground"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"anyhow",
"arboard",
+47 -2
View File
@@ -1,12 +1,21 @@
[package]
name = "rdbms-playground"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
description = "A cross-platform TUI playground for learning relational databases."
license = "MIT OR Apache-2.0"
repository = "https://git.lazyeval.net/oli/rdbms-playground"
homepage = "https://relplay.org"
readme = "README.md"
publish = false
keywords = ["database", "sql", "tui", "learning", "playground"]
categories = ["command-line-utilities", "database"]
# Keep the published crate to the code that builds the binary — the website,
# decision records, and CI plumbing are repo-only (ADR-0056).
exclude = ["/website", "/docs", "/.gitea", "/.codegraph"]
# `publish = false` removed (ADR-0056): the crate is intended for
# crates.io. The actual `cargo publish` is a deliberate, irreversible
# maintainer step (needs the crates.io token) — do it at a tagged release
# whose assets the binstall metadata below points at.
[dependencies]
anyhow = "1.0.102"
@@ -68,6 +77,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"
@@ -79,3 +94,33 @@ nursery = { level = "warn", priority = -1 }
module_name_repetitions = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
# cargo-binstall (ADR-0056): let `cargo binstall rdbms-playground` fetch the
# prebuilt release binary instead of compiling from source. Our release assets
# are BARE binaries (no archive) named `rdbms-playground-v<version>-<target>`
# (`.exe` on Windows) with `.sha256` sidecars (ADR-ci-003), so `pkg-fmt = "bin"`.
# `{ version }` excludes the leading `v`, so the template spells `v{ version }`.
#
# Target mapping: macOS host triples match our asset triples directly. But we
# ship the fully-static *-linux-MUSL build (glibc hosts are *-linux-gnu) and
# *-windows-GNU/GNULLVM (most Windows hosts are *-msvc), so those common host
# triples need explicit overrides pointing at the asset we actually publish.
#
# NOTE: unverified against a real `cargo binstall` run (binstall isn't a dep and
# nothing is on crates.io yet) — validate at the first publish + matching release.
[package.metadata.binstall]
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-{ target }{ archive-suffix }"
pkg-fmt = "bin"
bin-dir = "{ bin }{ binary-ext }"
[package.metadata.binstall.overrides.x86_64-unknown-linux-gnu]
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-x86_64-unknown-linux-musl"
[package.metadata.binstall.overrides.aarch64-unknown-linux-gnu]
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-aarch64-unknown-linux-musl"
[package.metadata.binstall.overrides.x86_64-pc-windows-msvc]
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-x86_64-pc-windows-gnu.exe"
[package.metadata.binstall.overrides.aarch64-pc-windows-msvc]
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-aarch64-pc-windows-gnullvm.exe"
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Lazy Evaluation Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Lazy Evaluation Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+98
View File
@@ -0,0 +1,98 @@
# rdbms-playground
A cross-platform terminal app for **learning relational database
concepts** — tables, columns, primary and foreign keys, relationships,
indexes, queries, and query plans — in a safe sandbox.
It's a teaching tool, not a database administration tool. It meets
beginners with guided, keyword-based commands (**simple mode**) and grows
with them to raw SQL (**advanced mode**), so the same playground works
from "what is a primary key?" through to writing real queries and reading
their execution plans.
Website & documentation: **<https://relplay.org>**
## Install
### One line (Linux / macOS)
```sh
curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh
```
Detects your OS and CPU, downloads the matching release binary, verifies
its SHA-256 checksum, and installs it to `~/.local/bin`. Set
`RDBMS_INSTALL_DIR` to install elsewhere, or `RDBMS_VERSION=vX.Y.Z` to
pin a version. (Prefer to read before you run? The script lives at
`scripts/install.sh`.)
### One line (Windows, PowerShell)
```powershell
irm https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.ps1 | iex
```
Downloads the matching `.exe`, verifies its checksum, installs to
`%LOCALAPPDATA%\Programs\rdbms-playground`, and adds it to your user
PATH. Or use a package manager (Scoop / winget) once those land.
### With `cargo binstall`
If you have [`cargo-binstall`](https://github.com/cargo-bins/cargo-binstall)
(install it first — it is not part of `cargo` itself):
```sh
cargo binstall rdbms-playground
```
### From source
```sh
cargo install rdbms-playground # from crates.io
# or, from a clone:
cargo build --release # binary at target/release/rdbms-playground
```
### Prebuilt binaries
Every release publishes static Linux, standalone Windows, and macOS
binaries (x86_64 and aarch64) with `.sha256` checksums on the
[releases page](https://git.lazyeval.net/oli/rdbms-playground/releases).
Windows users can also use the binary directly (package-manager support
is planned).
## A quick taste
```
create table Customers with pk id(serial)
add column Customers: name (text)
add column Customers: email (text)
insert into Customers values ('Ann', 'ann@example.io')
show data Customers
```
Press **F1** while typing for a contextual hint about the command you're
building, or type `help` for the full command list. Switch to raw SQL
with `mode advanced` (or prefix a single line with `:`).
## Project status
Approaching its first public release. See the website for current
features; installation via package managers (Homebrew, Scoop, winget) is
on the roadmap.
## License
Dual-licensed under either of
- MIT license ([LICENSE-MIT](LICENSE-MIT))
- Apache License 2.0 ([LICENSE-APACHE](LICENSE-APACHE))
at your option.
## Contribution
Unless you explicitly state otherwise, any contribution intentionally
submitted for inclusion in the work by you, as defined in the Apache-2.0
license, shall be dual-licensed as above, without any additional terms or
conditions. See [CONTRIBUTING.md](CONTRIBUTING.md).
+30
View File
@@ -0,0 +1,30 @@
# `ci/winstub/` — empty Windows import-lib stub
`libsynchronization.a` here is an **empty `ar` archive** (8 bytes: `!<arch>\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
```
+1
View File
@@ -0,0 +1 @@
!<arch>
@@ -38,6 +38,44 @@ The index lists ADRs in numerical order. Each entry shows the
number, title, and — where relevant — status annotations such as
"Superseded by ADR-NNNN" or "Deprecated".
## Numbering discipline
ADR numbers are a single global sequence, so two branches can each grab
"the next number" independently and collide on merge. (This happened when
the `website` branch's ADR-0042 met `main`'s ADR-0042, resolved by
renumbering the former to ADR-0044.) To prevent it:
**Assign an ADR's number at merge-to-`main`, not at creation.** While the
work lives on a non-`main` branch, draft the ADR under a placeholder — an
`ADR-XXXX` title and a `draft-<slug>.md` filename — and reference it that
way from any plan or notes. Give it the next free number only when the
branch merges to `main`, renaming the file and updating its references in
the same step.
A number is "taken" only once it appears in `main`'s `docs/adr/README.md`,
which is the single source of truth for the next free number — never
compute "next" from a feature branch. A branch that genuinely needs a real
number up front may instead reserve one by landing a stub index entry on
`main` first, but placeholder-until-merge is the default.
### Subproject ADR namespaces
A long-lived subproject developed on its own branch can escape the shared
integer pool entirely by keeping its decision records in a **separate
namespace**, rather than fighting collisions on every merge. The **website**
(`docs/website/adr/`) is the first: its ADRs use a dated sequence —
`<date>-adr-website-<NNN>.md`, referenced in prose as `ADR-website-NNN`
and are indexed by their own `docs/website/adr/README.md`. Because the
date-plus-subproject prefix is disjoint from `main`'s integer sequence, a
website ADR and a `main` ADR can never claim "the same number" again. (This
namespace was created on 2026-06-10 after the website's ADR collided with
`main`'s on consecutive numbers — drafted 0042, bumped to 0044, both times
landing on a number `main` had taken; the move retired it from the pool as
**ADR-website-001**.) The main `docs/adr/` index carries a pointer to each
such namespace. Use this for a new subproject only when it is genuinely
self-contained and branch-isolated; one-off cross-cutting decisions stay in
the global sequence.
## Out-of-scope discipline
ADRs (and the plans they spawn) lean heavily on "out of scope" language.
+8
View File
@@ -213,6 +213,14 @@ working copy.
### 6. Persistence ordering
> **Amended by ADR-0052 (2026-06-13, issue #30):** `history.log` is no
> longer written inside the worker transaction. It is a *journal* of typed
> commands, not state, so success journaling moved to the dispatch layer
> (next to the already-top-level failure journaling); `commit-db-last` now
> governs the three **state** targets only (db + `project.yaml` +
> `data/*.csv`), which still commit atomically in the worker. The journal
> write is best-effort (amends ADR-0040).
A successful user command produces effects in four targets:
the SQLite database, `project.yaml`, the relevant
`data/<table>.csv` file(s), and `history.log`. INV-2 from the
@@ -2,7 +2,13 @@
## Status
Accepted
Accepted. **Amended by ADR-0052 (2026-06-13, issue #30):** the status
field gains an optional `:adv` mode suffix (`ok:adv` / `err:adv`) — the
"non-breaking future extension" this ADR reserved — and **success
journaling moves out of the worker to the dispatch layer**
(`spawn_dsl_dispatch` / `run_replay` / app-command sites), next to the
failure path, where the submission mode is in scope. `status_is_ok` keys
off the base token, so `ok:adv` replays like `ok`.
## Context
@@ -5,7 +5,11 @@
**Accepted** — 2026-05-30 (issue #9). Amends the output conventions of
ADR-0014 (data operations), ADR-0028 (query plans / `explain`), and
ADR-0019 (failure rendering); builds on ADR-0037's mode-tagged echo
line.
line. **Amended by ADR-0052 (2026-06-13, issue #30):** a `history.log`
*journal*-write failure on a **successful** command is no longer fatal —
journaling moved to the dispatch layer (after the db commit), so it is
best-effort (logged + ignored), consistent with the failure-journal path.
State-write failures (yaml/csv/db) remain fatal.
## Context
+38 -2
View File
@@ -414,5 +414,41 @@ time-boxed-`recv` path. We therefore test the **pure pieces**
exhaustively (label fn, capture state machine, nearest-deadline helper)
and assert plumbing via Tier-3, rather than over-claiming an integration
test of the `tokio` timeout itself.
</content>
</invoke>
## Amendment 1 — `Ctrl-G` demo-mode alias for F1 (2026-06-15)
**Context.** The contextual `hint` overlay (ADR-0053 / H2) is opened with
**F1**. But F1 reaches the app only as an escape sequence (`\eOP` /
`\e[11~`), and the `autocast` recorder used for our screencasts **cannot
emit escape sequences** — so a cast can never trigger F1, and the single
most teaching-relevant overlay is unreachable in recordings. The same
wall already bit step-captions (which is why `Ctrl+]`, a single control
byte, was chosen over `Ctrl+!`).
**Decision.** In **demo mode only**, **`Ctrl-G`** is an alias for F1. It
runs the exact F1 hint logic (live-input → form hint; empty input →
recent-error / getting-started) and is **badged as `[F1]`** (not
`[CTRL-G]`) so a recorded cast is visually identical to a genuine F1
press. `Ctrl-G` is the only viable choice: it is a single legacy control
byte autocast can send, whereas `Ctrl`+digit (e.g. the mnemonic `Ctrl-1`)
is **not encodable in a legacy terminal at all** — digits have no control
byte, so `Ctrl-1` arrives as a bare `1`; the kitty protocol *would* encode
it but only as an escape sequence (the very thing autocast can't send),
and this app deliberately does not enable keyboard-enhancement flags.
**Why demo-gated.** The shipped keymap stays F1-only — a real user never
trips the alias, and demo mode is also the mode teachers/presenters run,
so the alias is available exactly where it's wanted. Outside demo mode
`Ctrl-G` falls through to the inert catch-all (the `Char(c)` insert arm
excludes CONTROL, so no `g` is typed).
**Scope.** `hint_key` guard in `App::handle_key` gains the demo-gated
`Ctrl-G` disjunct; `demo_badge_label` maps `Ctrl-G → [F1]` (consulted
only in demo mode). Test-first: three `app.rs` Tier-1 tests (alias fires
on input + on empty input; inert when demo off) + the badge-map
assertion. The keybinding strip (ADR-0051) is **not** changed — F1 stays
the advertised key; `Ctrl-G` is a recorder aid, and the badge already
reads `[F1]`.
*(Editorial: this amendment also removed two stray `</content>` /
`</invoke>` lines accidentally committed at the end of this file.)*
@@ -0,0 +1,147 @@
# ADR-0051: Bottom keybinding strip — context- and state-aware
## Status
**Accepted 2026-06-13 (issue #27).** Closes Gitea **#27**. All forks
below were escalated to the user and user-chosen before any code was
written; to be implemented test-first. Builds on ADR-0046 (nav focus),
ADR-0003 (input modes), ADR-0049 (the #29 readline keys this strip now
advertises), and ADR-0022 (the Tab-completion memo).
## Context
The bottom status line (`render_status_bar`, `ui.rs`) mixed keystrokes
with typed-command words: `Enter submit · : advanced once · mode
advanced switch · Ctrl-C quit`. That is redundant — the hint panel
already teaches `help` and `Enter` when the input is empty — and it is
static apart from a three-way mode branch, so it never reflects what the
user can actually do *right now* (navigating the sidebar, cycling a
completion, browsing history, editing a line).
Issue #27: repurpose the line as a **keybindings-only** strip that is
**context-sensitive to nav focus** and **state-aware of the current
transient interaction**, and move mode discovery into the empty-input
hint.
## Decision
### 1. The strip is keybindings-only and state-selected
A single pure function `status_bar_bindings(app) -> Vec<Binding>`
computes the strip from app state; `render_status_bar` is a thin
renderer over it (so the binding sets are unit-testable without a
Frame). `history_cursor` is private to `App`, so a small
`pub fn is_browsing_history(&self) -> bool` accessor exposes the
history-navigation predicate; `mode` / `nav_focus` / `last_completion`
are already `pub` and `effective_mode()` is a `pub` method. The state is
chosen by **priority — first match wins**:
| Priority | State (predicate) | Strip |
|---|---|---|
| 1 | **Sidebar focus** (`nav_focus` in a sidebar) | `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input` |
| 2 | **Completion memo live** (`last_completion.is_some()`) | `Tab/Shift-Tab cycle · Esc cancel · Enter run` |
| 3 | **History navigation** (`history_cursor.is_some()`) | `↑↓ browse · Esc clear · Enter run` |
| 4 | **Editing** (Input focus, input non-empty) | `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` |
| 5 | **Default** (Input focus, input empty) | `Ctrl-O sidebar · Tab complete · ↑ history · Enter run` |
Priority order matters: a completion memo or history navigation is a
non-empty-input situation, so states 2 and 3 must precede state 4. The
sidebar overlay occludes the input entirely (ADR-0046), so state 1 wins
outright.
### 2. Mode discovery moves off the strip, into the empty-input hint
The typed-command advertisements (`mode advanced` / `mode simple`
switch, the `:` one-shot) leave the strip — they are not keystrokes.
Mode discovery moves to the **empty-input hint** (`resolve_hint_lines`'s
`(None, None)` arm), in **simple mode only**:
- **Simple:** `… · \`mode advanced\` for SQL`
- **Advanced (persistent):** no pointer.
The pointer omits the verb "type" — the surrounding prompt already
implies it (we don't say "type `help`" either). Advanced mode shows
**no** pointer (user decision, post-trial): a user who switched into
advanced mode knows how they got there, and `help` covers the way back —
a "switch back" pointer only reads naturally in the moment right after
switching, so it earns its space poorly.
The one-shot advanced state's old `Backspace cancel one-shot` label is
**subsumed** by the editing state (the input is non-empty in one-shot;
Esc-clear and Backspace both cancel it). No behaviour is lost — only the
dedicated label.
### 3. Width: no drop machinery; a budget test instead
The longest strip (state 4, editing) is ≈ **65 display columns**, which
fits every supported width (90-col screencasts, 80-col terminals) with
margin — so the priority-drop / abbreviation machinery considered would
never trigger and is not built (user-confirmed). Ratatui's existing
**clip-at-edge** is the trivial fallback for pathologically narrow
(< 65-col) terminals. Instead, a **width-budget unit test** pins the
longest rendered strip within an 80-col budget, keeping the strip lean
*by construction* — a future over-long strip fails the test rather than
silently clipping in a cast.
## Forks (all user-chosen)
- **Editing state — yes:** when the input has text, surface the #29
readline keys (Esc-clear, Ctrl-A/E, Ctrl-W); the strip stays lean
(nav/complete/history) when empty. (vs not advertising the #29 keys.)
- **`Ctrl-C quit` — omitted** from the strip (vs always shown): quit is
a near-universal convention; omitting it keeps the strips lean and
matches the issue's sketch.
- **Width — budget test, no drop logic** (vs graceful priority-drop /
abbreviation): the strips fit at supported widths, so the machinery
would be dead weight (user's own observation).
## Consequences
- The strip now teaches the keys for the *current* situation; learners
see `Tab/Shift-Tab cycle` exactly while cycling, the editing keys
exactly while editing, etc.
- The #29 readline keys (ADR-0049) gain their on-screen advertisement,
closing that ADR's deferred item.
- 15 existing full-panel insta snapshots churn (the bottom line — and,
on empty-input views, the hint pointer — changes in every one,
including the rebuild-confirm modal view, whose modal box is itself
unchanged); each diff was reviewed, not blind-accepted.
- `requirements.md` is unaffected (an ADR-tracked UI refinement); the
change is cross-referenced from the commit + this ADR.
## Tests
- **Tier-1 (`ui.rs` unit):** `status_bar_bindings` returns the expected
key set for each of the five states (sidebar, completion-live,
history-nav, editing, default) — the completion/history states driven
through real key events (`update`) so the predicate transitions are
exercised, the others by setting `App` fields; plus the width-budget
assertion across states. (Per-state coverage is these unit tests, not
snapshots — a one-line strip is asserted more precisely by its exact
key list than by a full-panel snapshot.)
- **Tier-1:** the empty-input hint appends the correct mode pointer in
Simple vs Advanced, and does **not** append it when an ambient hint is
showing (non-empty input).
- **Tier-3 (`walking_skeleton`):** the old `status_bar_lists_quit_and_
submit_in_all_modes` (which asserted the pre-ADR strip) is rewritten +
renamed to assert the keystroke-only, state-aware strip end-to-end
through the real render path (default → editing transition).
- **Tier-2 (insta):** the 15 full-panel snapshots re-accepted (each diff
reviewed — strip line and/or hint pointer only).
## Out of scope
- **Modal-aware strip.** While a modal is open (load picker, rebuild /
undo confirm) it owns the keyboard and carries its own in-box key
hints; the bottom strip under a modal computes from input state
exactly as it does today (modals render *over* the status bar). This
issue does not redesign the modal case — pre-existing behaviour,
unchanged and not worsened.
- A persistent/togglable help overlay listing *all* keys (the strip is a
contextual subset, not a cheatsheet).
- Per-key colour theming beyond the existing key/label/separator styles.
- Localisation of the new label strings beyond adding catalog entries.
- The remaining I1b kill keys' (Ctrl-K/Ctrl-U) advertisement — the
editing strip shows the highest-value subset (Esc/Ctrl-A/E/Ctrl-W) to
stay within the width budget; Ctrl-K/U remain unadvertised muscle
memory.
@@ -0,0 +1,250 @@
# ADR-0052: Mode-tagged history for cross-mode recall
## Status
**Accepted + implemented 2026-06-13 (issue #30).** Closes Gitea **#30** —
both the feature ("reuse advanced history commands in simple mode by
prepending `:`") and the bug reported in its comment (the `:` one-shot
prefix lost across sessions). All forks user-chosen before any code.
**Amends ADR-0034** (journal status field gains a `:adv` tag; *journaling
moves from the worker to the dispatch layer*), **ADR-0015 §5/§6**
(history.log leaves the worker transaction — `commit-db-last` now scopes
yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure
is best-effort, no longer fatal); references ADR-0003 (the `:` one-shot
sigil). Plan: `docs/plans/20260613-issue-30-top-of-chain-journaling.md`
(pre-build `/runda`, then a second `/runda` that drove the journaling
relocation + the app-command exclusion). **2471 tests pass / 0 fail / 0
skip (1 ignored), clippy clean.**
> **Why journaling moved (the key architectural turn).** The first draft
> kept journaling in the worker and threaded the mode down to it (~30-site
> plumbing). On review the user asked the right question: why is the
> journal written deep in the worker at all, when the failure path already
> journals at the top of the chain where command + mode + outcome are all
> in scope? It shouldn't — `history.log` is a *journal of typed commands*,
> not *state*. So success journaling moved up next to the failure path
> (`spawn_dsl_dispatch` / `run_replay` / the app-command sites), the
> mode-plumbing dilemma dissolved, and the worker's `finalize_persistence`
> now writes only the state sources (yaml/csv). Consequence: the journal
> write is best-effort (the command is already committed), consistent with
> the failure path — see §5.
## Context
The input-history ring and `history.log` carry **no mode information**,
which causes two coupled problems:
1. **Feature gap.** A command typed in advanced mode (`select * from T`)
is stored bare. Recalled in simple mode it is not valid DSL → it just
errors. There is no way to know it was an advanced (SQL) command and
offer it back in a runnable form.
2. **Bug (issue #30 comment).** A `:`-one-shot advanced command in simple
mode recalls correctly **in-session** (the in-memory ring stores the
raw `:select 1`), but after quit+resume it comes back **without** the
`:` and is unusable. Root cause: the ring stores the raw input
(`:select 1`), but the worker journals the **stripped** `effective_input`
(`select 1`) — submission strips the `:` before dispatch (ADR-0003) —
so the on-disk `source` never carried the `:`, and hydration loses it.
Both reduce to: **history does not record the submission mode**, and the
in-memory and on-disk representations disagree about the `:`.
## Decision
Record the **submission mode** per history entry, keep the on-disk
`source` **canonical** (stripped — replay is unaffected), and have
**recall reconstruct the runnable line** for the current mode.
### 1. In-memory ring stores the `:`-prefixed runnable form
`App.history` stays `Vec<String>` — no type change, so the public ring,
the `ProjectSwitched` payload, and `seed_history` are untouched. An
**advanced** entry is stored in its **simple-mode runnable form**, the
`: `-prefixed string (e.g. `: select * from T`); a **simple** entry is
stored bare. This is exactly what the in-session one-shot ring already
does (`:select 1` recalls as typed) — generalised to *persistent*-advanced
commands too, and made reconstructable on hydration. Because a simple
DSL command can never begin with `:` (the sole sigil, ADR-0003), a
leading `:` unambiguously marks an advanced entry.
`submit` builds the stored line from the submission: advanced →
`": " + effective_input` (the `: ` matches the auto-space the typed
one-shot inserts), simple → `effective_input`. This is computed **after**
`effective_input` (today `push_history` runs on the raw `trimmed` before
stripping; the reorder also drops a bare `:`, which never executed). The
draft (`history_draft`) stays a plain `String`. `push_history` itself is
unchanged — it still takes one `&str`.
### 2. Recall strips the `:` for advanced mode
`history_back` / `history_forward` set `self.input` from the stored
string, then strip a leading `:` **iff the current persistent mode is
Advanced**:
```
if self.mode == Mode::Advanced && stored.starts_with(':') { stored[1..].trim_start() } else { stored }
```
So an advanced entry recalls as `: select * from T` in **simple** mode
(runs via the one-shot escape — the feature, and the cross-session bug
fix) and bare `select * from T` in **advanced** mode (runs as SQL). A
simple entry recalls bare in either mode (simple DSL already runs in
advanced mode — issue #30). In-session and cross-session paths share the
same stored form, so they finally agree.
### 3. On-disk: a mode tag in the status field
The record stays three pipe-separated fields `<ts>|<status>|<source>`
(so `source` remains the last, pipe-tolerant, canonical field — replay
reads it unchanged). The **status token** gains an optional `:adv`
suffix:
| Submission | Success | Failure |
|---|---|---|
| Simple | `ok` | `err` |
| Advanced (persistent or one-shot) | `ok:adv` | `err:adv` |
ADR-0034 §1 already reserved the status field for "additional values …
a non-breaking future extension"; this is that extension. The status
parser splits the token on `:`: the base (`ok`/`err`) gives replayability
(`status_is_ok` ⇔ base == `ok`), the `adv` suffix gives the mode — so an
unknown future token degrades to "not ok, simple" rather than mis-parsing.
### Journaling location: the dispatch layer, not the worker
Both tags are written **at the dispatch layer**, where command + mode +
outcome are all in scope — so the mode needs no plumbing into the worker:
- **Success:** `spawn_dsl_dispatch`, immediately after
`execute_command_typed` returns `Ok`, calls
`append_history(source, submission_mode.is_advanced())` (best-effort).
`run_replay` does the same per replayed line (tagged simple — replay is
mode-agnostic), and the app-command sites (`perform_switch` /
`spawn_export` / `spawn_rebuild`) journal **simple** (`advanced = false`
— app commands run in any mode, so no `:` on recall; this also avoids a
redundant `: undo`).
- **Failure:** unchanged location (the App→`JournalFailure`→runtime path,
already at the top), now carrying the mode — `JournalFailure` gains
`advanced`, and `DslFailed` gains `submission_mode` for the
worker-rejection sub-path (the parse-failure sub-path has it in
`dispatch_dsl`). `Ok`/`Err` are exclusive, so success-in-spawn and
failure-in-App-path never double-journal.
The worker's `finalize_persistence` and the four no-op-skip / three
read-only sites **no longer journal** — they leave the state writes
(yaml/csv) in the worker transaction and let the dispatch layer journal
the `Ok` outcome.
### 4. Hydration reconstructs the `:`-prefixed form
`read_recent_sources` parses each record's status tag and, for an
advanced record, **reconstructs** the `: `-prefixed string from the
canonical `source` (`format!(": {source}")`); simple records pass through
bare. It still returns `Vec<String>`, so `read_history_seed`,
`seed_history`, and the `ProjectSwitched` payload are **unchanged**. A
hydrated entry is therefore byte-identical to its in-session form, and
recall behaves identically.
### Back-compatibility
Old `history.log` files have only `ok` / `err` tokens → parsed as
`advanced = false` (simple). Their advanced commands stay un-`:`-able on
recall — the pre-existing behaviour, not a regression; nothing migrates.
`status_is_ok` keys off the base token, so `ok:adv` records replay
exactly as `ok` does today (source is canonical either way).
### Journal write is best-effort (amends ADR-0040)
Because the journal is now written *after* the worker replies (i.e. after
`tx.commit`), a journal-write failure can no longer roll the command back.
It is **best-effort** — logged and ignored, exactly like the failure path
already is (ADR-0034 §4) — so the two journal paths are finally
consistent. State integrity is unchanged: yaml/csv/db still commit
atomically in the worker (a *state*-write failure still rolls back and is
fatal). The only property given up: on a rare journal-write failure (disk
full) a committed command may be missing from `history.log` — not
recallable/replayable next session, but the state is correct. User-chosen
over keeping journaling coupled in the worker (which would have needed the
~30-site mode plumbing). See the plan's §2 for the full trade-off.
## Forks (user-chosen)
- **Format = mode tag in the status field** (`ok:adv`/`err:adv`), over a
new 4th field (ambiguous with unescaped pipes in old `source`s without
a version bump) or a `:`-prefix in `source` (would make `source`
non-canonical and force replay to strip it).
- **Scope = unified** (bug + feature) over bug-only: one mechanism does
both, and keeping `source` canonical for replay needs the mode tag
regardless, so bug-only is barely smaller and leaves the main ask open.
- **Journaling location = dispatch layer, best-effort** over keeping it
worker-coupled-and-fatal (which needed the ~30-site mode plumbing). The
user's architectural call (§Status).
## Consequences
- Advanced history is reusable in simple mode; the `:` one-shot survives
resume. The in-memory and on-disk representations agree.
- **Journaling left the worker.** `finalize_persistence` and the
no-op-skip / read-only sites no longer journal; success is journalled at
the dispatch layer (`spawn_dsl_dispatch` / `run_replay` / app-command
sites). The ring stays `Vec<String>`; `seed_history` / `ProjectSwitched`
are untouched. The vestigial worker `source` plumbing has since been
**fully unwound** (2026-06-14 follow-up): `_source` removed from
`finalize_persistence` / `do_rebuild_from_text`; the three read-only
`*_request` wrappers inlined and deleted; and — because the cascade ran
deeper than first estimated — the now-dead `source` param dropped from
the ~30 worker handlers (leaf + composite) that only forwarded it, plus
the `source` field removed from the `DescribeTable` / `QueryData` /
`RunSelect` requests and the matching `DatabaseHandle` method parameters
(the ~164 call-site churn was mostly tests). The only `source` left in
the worker is the snapshot/undo label (`snapshot_then` /
`stage_pre_mutation` / `begin_batch`), passed at the match-arm level.
Purely mechanical, compiler-guided, no behaviour change.
- **App commands recall bare.** Because they are dispatched outside the
`ExecuteDsl`/spawn path, app commands journal **simple** (`advanced =
false`) at their own sites, and `submit` excludes them from the ring's
`advanced` flag (`!is_app_command`) — so `mode advanced` / `undo` recall
bare and run fine in simple mode, with no redundant `:`.
- **Journaling is now uniform (user-confirmed).** The spawn journals on
`outcome.is_ok()`, so **every** successful command is recorded — closing
a pre-existing gap where `show table` / `show data` / `select` journalled
but `show tables`/`show relationships`/`show indexes`, `show relationship
<name>`, and `explain` did **not** (their worker arms carried no
`source` / no journal call). The new behaviour matches ADR-0034 §1
("record every submitted command"); those reads are now recallable and
are re-run harmlessly on replay (`explain` never executes; shows produce
output, no state change). A DA finding, accepted as the more-correct
behaviour over re-adding command-outcome gating to preserve the old
inconsistency.
- **Replay re-journaling.** When `replay` re-dispatches a line, the
re-written record is tagged from how replay dispatched (mode-agnostic →
`ok`), so a replayed advanced command may be re-journalled without
`:adv`. Replay correctness of execution is unchanged (it already parses
mode-agnostically); this only affects the *tag* of the re-written line.
Noted; not addressed here (replay's own mode-fidelity is out of scope).
## Tests
- **Tier-1 (`app.rs`):** an advanced one-shot / persistent-advanced
submission is stored `: `-prefixed; it recalls as `: …` in simple mode
and bare in advanced mode; a simple entry recalls bare in both; a bare
`:` is not stored; a parse-failure is still recallable; dedup/cap hold.
- **Tier-1 (`history.rs`):** the writer emits `ok:adv`/`err:adv`;
`read_recent_sources` reconstructs the `: `-prefix for `:adv` records
and leaves `ok`/`err` records bare (so old logs read as simple);
`status_is_ok` is true for `ok` and `ok:adv`.
- **Tier-3 (`iteration6_resume_history` / it):** the headline
**regression** — type a `:`-one-shot advanced command, journal +
hydrate, and assert it recalls **with** `:` in simple mode (fails on
current code); plus a persistent-advanced command round-tripping to a
`: …` recall.
## Out of scope
- Replay re-journaling mode-fidelity (above).
- Special-casing app commands to avoid the redundant recall `: `.
- Distinguishing one-shot from persistent advanced on recall (both are
simply "advanced" — the `:` is what simple mode needs either way).
- A format version marker / pipe-escaping in `source` (unneeded — the
status-tag approach keeps `source` last and canonical).
@@ -0,0 +1,428 @@
# ADR-0053: Contextual `hint` — F1 live-input keybinding + `hint` command, with a tier-3 teaching corpus (H2)
## Status
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
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 (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
implemented except `hint`, which ADR-0003's command table listed as
*"Request a hint for the current input (ADR pending)."* This ADR is that
pending decision. Tracked as **H2** in `docs/requirements.md`.
References ADR-0003 (app-command set + the `:` escape), ADR-0019 (the
friendly error layer / H1), ADR-0021 (per-command usage templates / H1a),
ADR-0022 (ambient typing assistance — colour + hint panel + completion),
ADR-0027 (input validity indicator), ADR-0046 (sidebar navigation +
responsive input hint), ADR-0049 (input-field readline keymap), and
ADR-0051 (context/state-aware keybinding strip).
## Context
`hint` is the only unbuilt app command. The naive reading — "show a hint" —
hides a real subtlety, and a real cost.
**The subtlety: a submitted `hint` command cannot see live input.** App
commands are submitted with Enter, which empties the input buffer. By the
time `hint` dispatches, the partial command it was meant to help with is
gone. So "a hint for the current input" cannot be served by a submitted
command alone — it needs a *keybinding* that acts on the live buffer
without submitting. ADR-0003 said "current input"; `requirements.md`
broadened it to "current input **or the most recent error**." Both are
wanted; they map to two different trigger surfaces.
**The cost: the value of `hint` is content, not plumbing.** The app
already carries two tiers of contextual text:
- **Tier 1** — terse, always-on: syntax colour (ADR-0022); the error
*headline* alone (ADR-0019, when `messages_verbosity: Short`).
- **Tier 2** — short contextual lines: the ambient typing prose /
`expected` set, shown live while typing (ADR-0022, catalogue
`hint.ambient_*` / `hint.value_slot_*`); and the error `hint:` field —
which, because `Verbosity::Verbose` is the **default**
(`src/friendly/translate.rs:46`), is shown **by default** beneath every
error headline (`messages short` is the opt-*out*, not `messages
verbose` the opt-in).
So the verbose error hint is **already on screen by default**. If `hint`
merely re-showed it, it would duplicate what the user can already see (and
the ambient panel). To justify itself, `hint` must add a **tier 3**: a
genuinely deeper, *teaching*-grade explanation — what the command/error
means, a worked example, and the underlying relational concept. That
corpus does not exist yet, and
authoring it (to the standard of a teaching tool, where "pedagogy wins
ties") is the bulk of the work.
The mechanism is small and reuses everything already present: the command
REGISTRY (`src/dsl/grammar/mod.rs`), the `AppCommand` enum
(`src/dsl/command.rs`), key dispatch (`App::handle_key`,
`src/app.rs:1155`), the `note_help`/`note_help_topic` renderers
(`src/app.rs:2982`/`3021`), the parser/walker expected-set
(`ParseError.expected`, `WalkResult.tail_expected`), the friendly
catalogue + `t!` macro + `keys.rs` validation, and the output styling
vocabulary (`OutputStyleClass::Hint`).
## Decision
### D1 — Two surfaces, no topic argument
`hint` is delivered through **two complementary surfaces**:
1. **F1 keybinding → live input.** Pressing **F1** while typing renders a
tier-3 hint for the command currently in the buffer, into the output
panel, **without submitting or altering the buffer**. This is the
primary, most-valuable path (it serves the literal "current input").
2. **`hint` command → most recent error.** Submitting `hint` renders the
tier-3 expansion of the most recent error. This is why the command
exists despite the empty-buffer problem: the thing it helps with is
the *last thing you tried*, not the now-empty buffer.
`hint` takes **no topic argument**. Explicit per-command reference is
already `help <topic>` (H3); `hint` is purely *contextual*, which keeps
the two cleanly distinct (`hint` = "help me with what I'm doing right
now"; `help insert` = "show me the insert reference").
F1 is a **read-only overlay**: it never alters the input buffer, the
cursor, or the live completion memo (ADR-0022) — it only emits a block
into the output journal. (It must therefore be handled in `handle_key`
*before* the "any other key clears the memo" fall-through.)
### D2 — Trigger matrix
| Trigger | Buffer / state | Result |
|---|---|---|
| **F1** | non-empty input | tier-3 hint for the command being typed. (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) |
| **`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.
**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.<class>` 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.
### D3 — The tier-3 content model
Tier-3 blocks live in the friendly catalogue under the existing `hint:`
top-level namespace (where tier-2 ambient strings already live), in two
new sub-namespaces:
- **`hint.cmd.<hint_id>`** — one per command **form**, keyed by a **new
`hint_ids: &'static [&'static str]`** field on `CommandNode`
(`src/dsl/grammar/mod.rs:512`), **mirroring the existing `usage_ids`**.
The F1 live-input path resolves the current input to its form's hint key
via `hint_key_for_input_in_mode`, which reuses the same form-word
disambiguation as `usage_key_for_input_in_mode`.
**Why an array mirroring `usage_ids`, not a per-node `hint_id`**
*(`/runda`/implementation revision, 2026-06-15)*: a single per-node key
is too coarse. Several entry words are **one node spanning many forms**
`add` (column/relationship/index/constraint), `drop` (table/column/
relationship/index), `show` (data/table/tables/relationships/indexes),
`create` (table/index). A live-input hint for `add 1:n relationship` is
only useful if it is *specific to relationships*, so the content must be
**per form**, not per node. The project already solved exactly this for
usage templates (`usage_ids` is a per-form array, disambiguated by the
form word), so `hint_ids` mirrors it. Single-form nodes carry one entry;
multi-form nodes carry one per form. This also covers the advanced-SQL
forms whose `usage_ids` are empty (`SQL_INSERT/UPDATE/DELETE`,
`EXPLAIN_SQL`) — they get their own `hint_ids` directly, independent of
usage, with mode-correct SQL examples. (The `help`-list collapse of
advanced-SQL forms is a separate gap — issue #36.)
**Deferred extension — clause-concept hints** (issue #37): per-form is
the right granularity for tier-3 *teaching* (position-awareness within a
form is owned by tier-2 ambient + the live `Next:` line, D4). But some
**concepts live inside a clause**, not a form — `… on delete ⟨cascade|
set null|restrict⟩` (referential actions), the `create table` constraint
slots (`primary`/`unique`/`check`/`foreign`), `with pk`, `1:n`/`m:n`
cardinality. A learner parked in such a clause may want teaching deeper
than tier-2's candidate list but narrower than the whole-form block. v1
does **not** build this (it would multiply content for points whose value
we can't yet measure, and we don't expect to accumulate usage statistics
to drive it empirically — it will be tackled as a deliberate follow-up
job). The keying does not lock it out: a later `hint.concept.<topic>`
namespace can be surfaced when the cursor sits in a recognized clause,
layered on top of the per-form block.
- **`hint.err.<class>`** — one per error/diagnostic class, keyed by the
friendly error/diagnostic key (e.g. `hint.err.foreign_key.child_side`,
`hint.err.type_mismatch`, `hint.err.insert_arity_mismatch`). Used by
both error routes (D2).
Each tier-3 block is a **structured entry with three labelled parts**, so
the voice stays consistent and the renderer can style them uniformly:
```yaml
hint.cmd.dsl.insert:
what: "Add one or more rows to a table."
example: "insert into Customers values ('Ann', 'ann@x.io')"
concept: "A row is one record; each value lines up with a column, in
order. Columns typed `serial`/`shortid` fill themselves — leave them out."
```
- **`what`** — one or two plain sentences: what this command does / what
this error means.
- **`example`** — a single concrete, copyable line (rendered neutral, not
muted, so it stands out as runnable).
- **`concept`** — the underlying relational idea, in teaching voice; the
part that makes this tier-3 rather than tier-2.
`concept` is optional where there is genuinely no concept beyond the
mechanics (e.g. `quit`); `what` + `example` are always present.
### D4 — Rendering
Both surfaces render through 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
The **runtime-error route** (submitted `hint`, and empty-input F1) needs
to map the last runtime error back to its `hint.err.<class>` key. Runtime
errors today live only as rendered text in the `output` buffer. We add a
single small piece of `App` state — **`last_error_hint_key:
Option<String>`** — set at the `translate_error` call sites
(`runtime.rs:2615`, `app.rs:2424`) when a friendly error is rendered,
cleared when a later command succeeds. Absent → the "getting started"
pointer.
The **pre-submit-diagnostic route** (the F1 live-input path reading the
under-cursor diagnostic) is **deferred** — see the scope note in D6.
### D6 — Content scope for v1
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.<hint_id>` block (app + DSL + DDL + advanced-mode SQL forms),
each with a **mode-correct example** (the advanced-SQL forms show SQL
syntax, their simple siblings show DSL — no sharing).
- **9 runtime error classes** — `unique`, `foreign_key` (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.<class>` 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).
**Fallback (safety net):** if a tier-3 key is ever missing at runtime,
the surface degrades to tier 2 — the ambient prose for the command path,
or the verbose error `hint:` for the error path — never to a blank or an
error. The `keys.rs` build-time validation keeps the corpus honest, so a
missing key is caught in tests, not in front of a student.
### D7 — Authoring process: exemplars-first
Because the corpus is large and its *voice* is a pedagogical decision the
maintainer owns, content is produced in two stages:
1. This ADR carries **23 worked exemplars** (below) as the canonical
style reference. The `/runda` review of this ADR is where the voice and
depth are approved.
2. Once approved, the remaining blocks are authored to that template in
**reviewable batches** (grouped by area: DDL, DML, app commands,
error classes), not one monolithic drop.
### Exemplars (the style reference; shipped as the rendered format)
**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
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.
```
**Error (`hint` command), foreign-key child-side violation:**
```
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, so 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
What: Link two tables so a parent row can own many child rows.
Example: add 1:n relationship from Customers.id to Orders.customer_id
Concept: The "1:n" means one parent, many children. The child column
holds the foreign key; `--create-fk` adds it for you if it
doesn't exist yet.
```
## Forks (all user-chosen, 2026-06-14)
- **Trigger model:** both a keybinding (live input) and a submitted
command (last error), rather than command-only or keybinding-only — the
live-input path is the most useful, but the command completes the A1
slot and serves the error case.
- **Keybinding = F1:** the universal help convention; the key is
genuinely free (no `KeyCode::F(1)` binding exists today — the `"F1"`
strings in `input_render.rs`/tests are scenario labels, not the key, and
ADR-0022 uses no `F1` requirement label). No collision with the ADR-0049
readline keys, `Ctrl-O` (ADR-0046), `Esc`-clear, or the reserved
`Ctrl-C` cancel (I5). Rejected: `?` (a typeable character — fiddly
position-dependent handling) and a Ctrl/Alt chord (less discoverable, no
advantage).
- **No topic argument:** contextual only; `help <topic>` already owns
explicit reference lookup.
- **Comprehensive content for v1:** the full inventory, not a starter
subset.
- **Exemplars-first authoring:** lock the voice on a few blocks, then
mass-author to template.
## Consequences
- **A1 closes.** With `hint` registered and built, all 15 canonical
app-level commands exist in both modes.
- **A third contextual tier exists.** Students get on-demand, teaching-
grade guidance that is deeper than the always-on colour, the headline,
the ambient one-liner, and the verbose error hint — without cluttering
those terse defaults.
- **One new keybinding (F1)** joins the keymap and the ADR-0051 strip.
- **A new `hint_ids: &[&str]` field on `CommandNode`** (mirroring
`usage_ids`) + a `hint_key_for_input_in_mode` lookup (reusing the
`usage_key_for_input_in_mode` form-disambiguation), one new field of
`App` state (`last_error_hint_key`), and one new renderer family
(`note_hint*`); the `AppCommand` enum gains `Hint`, the grammar a `HINT`
node, the REGISTRY one entry.
- **A 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). (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 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
- **Per-topic `hint <topic>`** — OOS (rejected): `help <topic>` already
serves explicit lookup; a topic arg would overlap it and double the
content-authoring surface.
- **Re-showing tier-3 inline as the always-on ambient hint** — OOS
(rejected): the ambient panel stays terse by design (ADR-0022); tier-3
is on-demand. Promoting it would defeat the tiering.
- **Localised tier-3 content beyond `en-US`** — OOS (deferred): the
catalogue is structured for i18n (ADR-0019), but additional locales
follow the project's English-only-for-v1 stance (requirements X2).
- **`hint` for a *successful* command's deeper teaching** (e.g. "you just
created a table — here's what an index would add") — OOS (deferred): a
plausible future tier-3 use, but v1 scopes the command path to errors
and the F1 path to in-progress input.
- **Clause-concept hints** (`… on delete ⟨action⟩`, constraint slots,
`with pk`, cardinality) — OOS (deferred, issue #37): a
`hint.concept.<topic>` layer surfaced when the cursor sits in a
recognized clause, deeper than tier-2's candidate list but narrower than
the per-form block. Per-form keying (D3) does not lock it out. To be
tackled as a deliberate follow-up job, not gated on usage statistics.
- **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)
The implementation plan enumerates and checks off every block:
- **`hint.cmd.<hint_id>`** — one per distinct `REGISTRY` node (~37), each
with its own `hint_id` and a mode-correct example: app (`save`, `save
as`, `load`, `new`, `rebuild`, `export`, `import`, `replay`, `undo`,
`redo`, `mode`, `messages`, `copy`, `help`, `hint`, `quit`); DDL
(`create table`, `create m:n`, `add column`/`relationship`/`index`,
`drop`, `rename`, `change column`); DML (`insert`, `update`, `delete`,
`show`, `seed`, `explain`, `select`/`with`). The **7 advanced-mode SQL
forms** (`SQL CREATE TABLE`, `ALTER TABLE`, `CREATE/DROP INDEX`, `DROP
TABLE`, `SQL INSERT/UPDATE/DELETE`, `EXPLAIN SQL`, raw `SELECT`/`WITH`)
each get their **own** block with SQL syntax — they do **not** reuse
their simple sibling's (this is the `/runda` correction; the parallel
`help`-side gap is issue #36).
- **`hint.err.*`** — one per runtime error class (`unique`,
`foreign_key.{child,parent}_side`, `not_null`, `check`,
`type_mismatch`, `not_found`, `already_exists`, `generic`,
`invalid_value`). The `diagnostic.*` pre-submit classes are **deferred**
(D6 / issue #38).
@@ -0,0 +1,73 @@
# ADR-0054: Release versioning policy + version surfaces (`--version` / `version`)
## Status
Accepted — **implemented 2026-06-16** (plan:
`docs/plans/20260616-public-availability.md`, step 1). First step on the
road to public availability. Adds a `--version` / `-V` CLI flag and an
in-app `version` command, both reporting `CARGO_PKG_VERSION`, plus a
release-CI guard that the `v*` tag equals that version. No prior issue or
`requirements.md` item existed for this — it was an untracked gap.
## Context
Before this, `Cargo.toml` carried `version = "0.1.0"`, the binary exposed
**no** way to report its version, and `release.yaml` named assets from the
**git tag** (`rdbms-playground-<tag>-<target>`) while building from
`Cargo.toml`. Tag and crate version were **decoupled**: tagging `v0.2.0`
would publish an asset named `…-v0.2.0-…` containing a binary that (had it
been able to say) reported `0.1.0`. On the way to public availability —
where users download a versioned artifact and file bug reports against "the
version I'm running" — that drift is a correctness problem.
## Decision
1. **`Cargo.toml` `version` is the single source of truth.** This is the
idiomatic Rust position and avoids making `Cargo.toml` lie. The version
is read at compile time via `env!("CARGO_PKG_VERSION")`; no build-time
injection of the tag into the crate.
2. **Two user-facing surfaces, one source:**
- **`--version` / `-V`** — CLI flag (hand-rolled parser, mirrors
`--help`): prints and exits before any other work (`main.rs`).
- **`version`** — an in-app app command (REGISTRY node `app::VERSION`,
`AppCommand::Version`), emitting the same line into the output panel.
Both go through `cli::version_text()` → the catalog key
`cli.version_line` (`"rdbms-playground {version}"`), so there is exactly
one rendered string and one version source.
3. **Release-CI discipline.** `release.yaml`'s pre-build `test` job gains a
**version guard**: it reads the `[package]` version directly from
`Cargo.toml` (`grep -m1 '^version = '` — toolchain-free; an earlier
`cargo metadata | node` form broke on the flake devShell's stdout banner)
and **fails the release** unless the pushed tag equals `v<that version>`.
So `--version`, the release name, and the downloaded asset are always in
lockstep — enforced by the machine, not by memory.
4. **The release ritual:** bump `Cargo.toml` → commit → tag `v<that
number>` → push the tag. The guard rejects any deviation.
### Rejected / deferred
- **Inject the tag into the build** (tag as source of truth): fiddly with
cargo and makes `Cargo.toml` a placeholder/lie. Rejected.
- **Git-hash + build-date enrichment** (a `build.rs` so dev builds read
`0.2.0 (a1b2c3d)`): useful for bug reports, but not needed for the
tag↔release↔`--version` consistency this ADR is about. Deferred; can be
added behind the same `version_text()` seam without changing the policy.
- **UI placement beyond the `version` command** (status-bar string, etc.):
the command + `help` listing is enough for now (user decision).
## Consequences
- A release can no longer ship a binary whose self-reported version
disagrees with its tag/filename.
- Cutting a release now *requires* a `Cargo.toml` bump commit — a small,
deliberate step (and a natural place to update a changelog later).
- New keys: `cli.version_line` (+ `help.app.version`, `parse.usage.version`,
`hint.cmd.version.what`/`.example`); a new REGISTRY command means the
comprehensiveness coverage test now also requires `hint.cmd.version`,
which is supplied. Tested: CLI flag parse (`--version`/`-V`/default-off),
`version_text()` carries `CARGO_PKG_VERSION`, the in-app command parses to
`AppCommand::Version` and emits the version.
- This is step 1 of `docs/plans/20260616-public-availability.md`; the
installer (`install.sh`) and package-manager work (D3) build on top.
+86
View File
@@ -0,0 +1,86 @@
# ADR-0055: `curl | sh` install script (`scripts/install.sh`)
## Status
Accepted — **implemented 2026-06-17** (plan:
`docs/plans/20260616-public-availability.md`, step 2). Step 2 on the road
to public availability, building on ADR-0054 (versioned releases) and
ADR-ci-001/003 (the Gitea releases it downloads from). Tracked by the
plan + this ADR (no Gitea issue — user decision, 2026-06-17).
## Context
Until now, installing meant: find the releases page, work out which of
the six assets matches your machine, download it, `chmod +x`, move it
onto `PATH`, and (on macOS) wonder about Gatekeeper. That is too much
friction for a teaching tool aimed at beginners. The Gitea releases are
**publicly downloadable** (confirmed), with deterministic asset names
(`rdbms-playground-<tag>-<target>[.exe]`) and `.sha256` sidecars
(ADR-ci-003), and a `releases/latest` API — enough to script a one-liner
install.
## Decision
Ship **`scripts/install.sh`**, run as
`curl -fsSL <gitea-raw>/scripts/install.sh | sh`:
- **POSIX `sh`** (no bashisms) — it runs under the `sh` of `curl | sh`;
kept **shellcheck-clean** (`-s sh`).
- **Platform detection** from `uname` → target triple: Linux →
`<arch>-unknown-linux-musl` (the fully-static build — one universal
Linux artifact, no glibc/version coupling), macOS → `<arch>-apple-darwin`;
`x86_64`/`amd64` and `aarch64`/`arm64` both map. **Windows is rejected**
with a pointer to Scoop/winget/the releases page (the binary is a `.exe`,
not a `curl|sh` target).
- **Version:** the `releases/latest` API tag by default; `RDBMS_VERSION`
pins a specific tag.
- **Integrity:** always download the `.sha256` sidecar and **verify**
(`sha256sum`/`shasum -a 256`); a mismatch aborts the install. HTTPS only.
- **Install location:** `~/.local/bin` by default (user-writable, no
sudo), overridable via `RDBMS_INSTALL_DIR`; prints a PATH hint if the
dir isn't on `PATH`.
- **macOS note:** a `curl` download is **not** Gatekeeper-quarantined, so
the binary runs as-is even while it is only ad-hoc-signed; proper
Developer-ID signing + notarization (for *browser* downloads) is a
separate, postponed task (see the plan's signing item).
- **Testing seams:** `RDBMS_OS`/`RDBMS_ARCH` force detection and
`--print-target` prints the resolved triple and exits — so the mapping
is checkable without a download.
### Rejected / deferred
- **Hosting the script on the website domain** (Cloudflare): nicer URL,
but adds a moving part; the **Gitea repo raw URL** is simplest and the
binaries live there anyway (user decision). The website may later
*reference* the same command.
- **Uploading `install.sh` as a release asset** for a stable link:
optional; the branch raw URL is fine for now.
## Amendment 1 — `install.ps1` (Windows) added (2026-06-17)
Windows was originally deferred to Scoop/winget; the user opted for **both**
a PowerShell one-liner now *and* package managers later. Added
**`scripts/install.ps1`** (`irm <url> | iex`): maps the host CPU to our
`*-windows-gnu`/`-gnullvm` `.exe`, resolves the latest release (or
`-Version`/`RDBMS_VERSION`), downloads + **SHA-256-verifies**, installs to
`%LOCALAPPDATA%\Programs\rdbms-playground` (`-InstallDir`/`RDBMS_INSTALL_DIR`
override), and adds that dir to the **user PATH**. **Caveat:** unlike
`install.sh` (verified end-to-end), this was **written but not tested from
this environment** (no PowerShell available) — validate on a real Windows
host. Scoop/winget (D3) remain the idiomatic package-manager routes.
## Consequences
- A first-time user runs one line and gets a checksum-verified binary on
`PATH`. The website's install copy (website branch, separate agent) can
point at this command.
- **Verified end-to-end** (2026-06-17) against the live public `v0.1.0`:
all four Linux/macOS platform mappings + Windows/unknown-arch rejection;
pinned and latest paths; checksum verification incl. a tamper-rejection
check; install + run on Linux x86_64. (The installed `v0.1.0` predates
`--version`, ADR-0054 — a non-issue, and the reason to cut a new
release.)
- **No automated regression guard in CI yet:** shellcheck isn't in the
flake, and there's no shell-test harness here (no bats). Recommended
follow-up: add a `shellcheck scripts/*.sh` gate (touches ADR-ci-002 —
needs shellcheck in the devShell). For now the guard is local
shellcheck + the documented end-to-end verification.
@@ -0,0 +1,88 @@
# ADR-0056: crates.io publish-readiness + `cargo binstall` metadata (D3)
## Status
Accepted — **prepared 2026-06-17** (plan:
`docs/plans/20260616-public-availability.md`, step 3a). The crate is made
**ready to publish** and carries `cargo-binstall` metadata. The actual
`cargo publish` is a gated maintainer step (see Ordering). First D3
package-manager mechanism; builds on ADR-0054 (versioned releases),
ADR-0055 (installer), ADR-ci-003 (release assets). Tracked by plan + ADR
(no Gitea issue — user decision).
## Context
`cargo binstall rdbms-playground` (and `cargo install`) need the crate on
**crates.io** (user decision, 2026-06-17). The manifest had
`publish = false`, a `readme = "README.md"` pointing at a **missing**
file, and no `keywords`/`categories`. Our release assets are **bare
binaries** (not archives) named `rdbms-playground-v<version>-<target>`
(`.exe` on Windows) with `.sha256` sidecars (ADR-ci-003); critically the
**release target triples differ from users' host triples** — we ship the
static `*-linux-musl` build (hosts are `*-linux-gnu`) and
`*-windows-gnu`/`-gnullvm` (hosts are `*-msvc`); only macOS matches.
## Decision
**Publish-readiness (this change):**
- Drop `publish = false`; add `homepage = "https://relplay.org"`,
`keywords`, `categories = ["command-line-utilities", "database"]`, and
an `exclude` (`/website`, `/docs`, `/.gitea`, `/.codegraph`) so the
published crate is code-only (585 files/8.3 MiB → 353/913 KiB
compressed).
- Author **`README.md`** (the `readme` target + crates.io front page;
engine-neutral and "simple/advanced mode" wording per ADR-0002 / the
website copy rules), with install instructions (curl|sh, binstall,
source, prebuilt).
- Add **`LICENSE-MIT`** and **`LICENSE-APACHE`** (the latter the verbatim
canonical text, added by the maintainer; both © Lazy Evaluation Ltd —
the publication entity), and a **`CONTRIBUTING.md`** stating the
"inbound = outbound" dual-license arrangement (so Apache-2.0 §5 makes
the §3 patent grant explicit on the self-hosted forge). Dual license
kept (not MIT-only) — user decision after reviewing the patent-grant
rationale.
**`cargo binstall` metadata** (`[package.metadata.binstall]`, syntax
verified against cargo-binstall SUPPORT.md):
- `pkg-fmt = "bin"` (bare binary), `bin-dir = "{ bin }{ binary-ext }"`,
and a base `pkg-url` using `v{ version }` (the `{ version }` placeholder
excludes the leading `v`).
- **Per-target `overrides`** mapping the common host triples to the asset
we actually publish: `x86_64`/`aarch64-unknown-linux-gnu` → the `-musl`
asset; `x86_64`/`aarch64-pc-windows-msvc` → the `-gnu`/`-gnullvm`
`.exe`. macOS needs no override (host triple == asset triple). The docs
do **not** promise automatic musl/gnu or msvc/gnu fallback, hence
explicit overrides.
**Ordering / gating (important):**
- `cargo publish` is **irreversible** (needs the crates.io token; a
version can't be un-published, only yanked) — a deliberate **maintainer
step**, not done here.
- binstall's `pkg-url` resolves to a **tagged release's** assets, so
publish **at a new tagged version whose release already exists**, and
publish **after** that release is built. **Do not publish `0.1.0`** — it
would diverge from the already-released `0.1.0` binaries (which predate
`--version`, ADR-0054). The clean path: bump → tag → release builds →
`cargo publish`.
## Verification
- `cargo publish --dry-run --allow-dirty` packages + verify-builds cleanly
(353 files, 913 KiB compressed; no metadata errors).
- `cargo metadata` confirms the `binstall` block + all four `overrides`
parse.
- **Unverified:** an actual `cargo binstall` run — cargo-binstall isn't a
dependency and nothing is on crates.io yet. **Validate at the first
publish + matching release** (especially the windows-msvc→gnu and
linux-gnu→musl overrides).
## Consequences
- The crate can be published at the next tagged release with `cargo
publish` (+ the token); `cargo install rdbms-playground` and `cargo
binstall rdbms-playground` then work.
- Remaining D3: Scoop, Homebrew (`lazyeval` tap), winget (komac/manual) —
each a manifest + a per-release bump, tracked in the plan.
- Remaining follow-up: run the real `cargo binstall` validation at the
first publish + matching release (the license files, © holder, and
CONTRIBUTING are now in place).
+14 -1
View File
File diff suppressed because one or more lines are too long
+195
View File
@@ -0,0 +1,195 @@
# 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").
## Amendment — 2026-06-13: D1 matrix (non-macOS)
§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
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/<owner>/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_<target>` + `CARGO_TARGET_<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:** **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
tiers 13 only, so **TT5** ("CI runs all tiers on Linux/macOS/Windows") is
partially met — Linux, tiers 13.
- **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, 13),
**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).
+145
View File
@@ -0,0 +1,145 @@
# ADR-ci-002: 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
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
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 **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).
## Amendment 1 — 2026-06-17: `fmt` gate enabled (issue #35)
The deferred "revisit on `main`" is done. With the CI + website branches
merged and before the first public release, the tree was reformatted once
with **stock `cargo fmt`** (no `rustfmt.toml` — stable rustfmt supports no
meaningful customisation, and the pinned 1.95.0 toolchain makes
`fmt --check` deterministic) in a single mechanical commit (`41b7e9a`,
102 files, behaviour-preserving; recorded in `.git-blame-ignore-revs`).
`ci.yaml`'s gate is now **`fmt --check` + clippy + test**. Closes **#35**.
+206
View File
@@ -0,0 +1,206 @@
# 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).
## Amendment 2 — 2026-06-16: CI on `main`; `release-macos` dispatched + verified
The CI branch is **merged to `main`**, so `release-macos.yaml`
(`workflow_dispatch`, Gitea-default-branch-only) is now triggerable. It
has been **dispatched and verified end-to-end**: both `*-apple-darwin`
targets build on the Tart runner, the de-nix/re-sign step runs, the
assets upload to the tagged release, and the binaries launch. macOS is
therefore runtime-verified (alongside the original Linux x86_64 +
Windows aarch64); only **Linux aarch64** and **Windows x86_64** remain
link-clean / valid-format without a runtime smoke-test.
## 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
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 <triple>`, 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** (~24 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-<tag>-<target>[.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)~~**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.
- **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).
+23
View File
@@ -0,0 +1,23 @@
# 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 `<date>-adr-ci-<NNN>.md` and referenced in
prose as `ADR-ci-NNN`. The `<date>` (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:** 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). **Amendment 1 (2026-06-17, issue #35):** the deferred `fmt` gate is enabled — the tree was reformatted once with **stock `cargo fmt`** (no `rustfmt.toml`; pinned toolchain makes `fmt --check` deterministic) in a single mechanical commit (`41b7e9a`, 102 files, behaviour-preserving, in `.git-blame-ignore-revs`), and `ci.yaml`'s gate is now **`fmt --check` + clippy + test**.
- [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), 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). **Amendment 2 (2026-06-16):** CI is **merged to `main`**, so `release-macos` is now triggerable (`workflow_dispatch` is default-branch-only) and has been **dispatched + verified end-to-end** (build → de-nix/re-sign → upload, binaries launch). Runtime-verified by the user: Linux x86_64, Windows aarch64, **and both macOS targets**; Linux aarch64 + Windows x86_64 are the outstanding runtime checks. Builds on ADR-ci-002 (flake) and fills in ADR-ci-001 §3 (Release).
+104
View File
@@ -0,0 +1,104 @@
# CI subproject handoff — 2026-06-15 (ci-01)
First handover for the **CI / release subproject** (the `ci` branch). Kept in
`docs/ci/handoff/`, a namespace separate from the project's global
`docs/handoff/` session sequence so it can't collide with `main`'s numbering —
the same split as `docs/ci/adr/`, and needed for the same reason: `main`
independently wrote its own **handoff-70** this same day (just as it took
**ADR-0049**), which would have collided.
A dedicated infrastructure session 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 the sibling ADR namespace
**`docs/ci/adr/`** (ADR-ci-001/002/003).
## §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-<tag>-<target>`). `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.
+21
View File
@@ -0,0 +1,21 @@
# CI / Build subproject — session handoffs
Handover notes for the **CI / release pipeline** work (the Gitea Actions
workflows under `.gitea/`, the nix flake, the release tooling). Kept in their
own namespace, separate from the project-wide session handoffs in
[`docs/handoff/`](../../handoff/), so a CI-branch handoff never competes with
`main`'s global handoff sequence for numbers — the same split the CI ADRs use
([`docs/ci/adr/`](../adr/README.md)). This is not hypothetical: `main`
independently wrote a `handoff-70` the same day this subproject's first handoff
was drafted.
**Numbering.** Files are named `<date>-handoff-ci-<NN>.md` and referenced in
prose as `handoff-ci-NN`. Assign the next free `NN` from this index.
## Index
- [handoff-ci-01 — the CI/release pipeline build-out](20260615-handoff-ci-01.md)
— Gitea Actions gate (clippy + test) + a six-target release (four via
`cargo-zigbuild` on a `v*` tag, two macOS via dispatch on a Tart runner), all
on a nix flake; decisions in `docs/ci/adr/`. Built on the `ci` branch, merged
`main` in, gate green (2488 tests), ready to promote to `main`.
+181
View File
@@ -0,0 +1,181 @@
# Website-branch handoff — 2026-06-10 (website-1)
First handoff for the **website** work. This is a **separate sequence** from
`main`'s `YYYYMMDD-handoff-NN.md` files — branch-scoped name on purpose, so
the two don't collide. Continue numbering these `…-website-handoff-N.md`.
## State
- **Branch:** `website`. **HEAD `936d925`.** Not pushed (push is the user's
step). Working tree clean.
- The website lives in **`website/`** (monorepo; the playground crate is at
the repo root). Decisions: **ADR-0044**
(`docs/adr/0044-public-website-and-documentation-site.md`). Implementation
plan: **`docs/plans/20260604-adr-0044-website.md`**. Living style guide:
**`website/STYLE.md`** (read this first — it has the binding conventions
and an open-decisions log).
## Stack & layout
- **Astro 6 + Starlight + Tailwind v4**, all under `website/`.
- `website/astro.config.mjs` — Starlight config (title, 5-section sidebar,
Expressive Code `langs`, `server.host: '127.0.0.1'`).
- `website/src/grammars/rdbms.mjs` — custom Shiki grammar, **two language
ids**: `rdbms` (real commands) and `rdbms-syntax` (abstract templates).
- `website/src/styles/global.css` — Starlight↔Tailwind bridge + the `> `
command prompt + copy-button hiding (CSS `:has()`).
- `website/src/content/docs/` — the five sections.
## Commands
```sh
cd website
pnpm install # node_modules is gitignored — reinstall on a fresh checkout
pnpm dev # serves http://127.0.0.1:4321 (see dev-server gotcha below)
pnpm build # static dist/, 24 pages, Pagefind search index
```
**Dev-server gotcha (already fixed, don't re-break):** Astro/Vite's default
`localhost` bind resolves to IPv6 `::1` here, which breaks SSH
`-L 4321:127.0.0.1:4321` tunnels. `server.host: '127.0.0.1'` in the config
pins IPv4. Tunnel with `ssh -L 4321:127.0.0.1:4321 <host>`.
**Verify after changes:** `pnpm build` clean; then from the repo root
`grep -rniE '\b(DSL|SQLite|STRICT|rusqlite|PRAGMA)\b' website/src/content/`
must be empty; internal links should resolve (build doesn't fail on broken
links — no validator installed — so sanity-check by hand or with a small
script).
## Documentation structure (5 sections, autogenerated per directory)
1. **Getting started** — install, first-project, modes, example-library. **(real)**
2. **Using the playground** — command-line-options, the-assistive-editor,
the-output-pane, projects, undo-and-history, export-and-import,
copy-to-clipboard, getting-help. **(real, grounded)** *This is "the app you
drive", distinct from the database-language Reference.*
3. **Guides** — build-the-library **(DRAFT — marked; to be iterated for
teaching quality before publication)**.
4. **Reference** — types **(real)**, tables **(real)**; columns,
relationships, indexes, constraints, inserting-and-editing-data,
querying-and-inspecting **(STUBS — real syntax synopsis + an "In progress"
note; THEY NEED WORKED EXAMPLES — this is the main remaining bulk)**.
5. **Concepts** — projects-and-storage **(real)**.
- Landing: `index.mdx` splash (feature cards incl. the assistive editor +
start-here links).
## Binding conventions (STYLE.md / ADR-0044 §7)
- **No "DSL"** in user-facing copy → "simple mode" / "advanced mode".
- **No engine name** (SQLite/STRICT/rusqlite/PRAGMA) → "the database" / "the
engine".
- **Code fences:** simple-mode commands → ` ```rdbms ` (highlighted; gets a
decorative copy-safe `> ` prompt via CSS). Abstract syntax templates →
` ```rdbms-syntax ` (highlighted, **no** prompt, no copy button). Advanced
SQL → ` ```sql `. Shell → ` ```sh `.
- **One command per line** in `rdbms` blocks. A multi-line *single* statement
(advanced `CREATE TABLE`) goes in ` ```sql `.
- **Copy button** is hidden on multi-line `rdbms` blocks and on
`rdbms-syntax` (the app input is single-line — a multi-command paste isn't
runnable); kept on single-command `rdbms`, and all `sql`/`sh`.
- Unshipped features → `:::caution[Planned]` aside; never presented as
shipped.
- **Ground every page in source**`parse.usage.*` / `help.*` in
`src/friendly/strings/en-US.yaml`, `src/dsl/command.rs`, `src/dsl/types.rs`,
the ADRs. **Do not** trust `requirements.md` markers (handoff-59 found
~46% mis-marked; it now uses a `[/]` partial legend) — verify against code.
## Canonical example database (use in every example)
A small **library**: `authors`(author_id serial pk, name text, birth_year
int) · `books`(book_id serial pk, title text, author_id int→authors,
published int, isbn text unique) · `members`(member_id serial pk, name text,
joined date) · `loans`(loan_id serial pk, book_id int→books, member_id
int→members, loaned_on date, returned_on date). 1:n author→books; m:n
books↔members via loans. **Simplest examples lead with bare `with pk`** (a
default `id` key); the library build uses **named** keys (`author_id`, …) so
relationships read clearly.
## Verified syntax cheat-sheet (don't re-derive)
- Simple create: `create table <T> with pk [<col>(<type>)[, ...]]` (bare =
default `id`); `add column [to] [table] <T>: <name> (<type>)`;
`rename column [in] [table] <T>: <old> to <new>`; `change column … (<type>)
[--force-conversion|--dont-convert]`; `drop column [from] [table] <T>: <col>
[--cascade]`.
- Advanced create: `create table T (id serial primary key, …, col int
references parent(col))` (verified against `tests/it/sql_create_table.rs`).
- Relationship: `add 1:n relationship [as <name>] from <P>.<col> to <C>.<col>
[on delete <action>] [on update <action>] [--create-fk]`;
compound: `from <P>.(a, b) to <C>.(x, y)`; drop by name or by endpoints.
- Data: `insert into T [(cols)] [values] (vals)`; `update T set … (where … |
--all-rows)`; `delete from T (where … | --all-rows)`.
- Inspect: `show data <T> [where …] [limit n]`, `show table <T>`, `show
tables|relationships|indexes`, `show relationship|index <name>`.
- `explain <…>` (query plan; safe — never executes). Advanced `select …`.
- 10 types: text int real decimal bool date datetime blob serial shortid;
advanced-mode SQL aliases (integer/varchar/timestamp/numeric/…).
- App commands: save / save as / new / load / rebuild / export [path] /
import <zip> [as <t>] / undo / redo / replay <path> / mode / help
[<command>] / help types / copy [all|last] / quit. (`hint` and `seed` are
NOT implemented — mark planned/omit.)
## Next work (priority order)
1. **Fill the 6 Reference stubs** with worked examples on the library schema
(the remaining bulk). Each has a syntax synopsis + an "In progress" note —
expand to full content: worked example(s), both simple + advanced forms
where both apply, cross-links. This is the biggest chunk.
2. **Iterate the Guides** for teaching quality; add guides (model a 1:n / m:n,
querying with joins). The user flagged guides as the most important
didactic content, to be polished before publication.
3. **Phase B — landing polish:** use the `frontend-design` skill; set
Starlight `site` (production URL) once the domain is known (enables sitemap
+ OG/SEO); add a logo + favicon (small Starlight config). Branding palette
when the user wants it (staying on Starlight; community themes were
surveyed — see ADR-0044 / chat).
4. **asciinema casts — DEFERRED until the app is final** (ADR-0044 §2). When
starting, settle STYLE.md open-decision #9: scripted-input driver
(`asciinema-automation` vs `autocast` — prove with a throwaway test run),
`.cast` script format + repo location, terminal geometry, light/dark
player theme, file naming. The **assistive editor** is prime cast material
(completion / `[ERR]`/`[WRN]` indicator are motion a still block can't
show) — earmark a cast for it on the landing + the assistive-editor page.
5. Remaining STYLE.md open decisions: **versioning** (leaning single-version
for launch) and **SEO/meta** (settle with Phase B + the `site` URL).
## Process pins
- **Commits:** user-confirmed (show the message first), **no AI attribution**,
**append-only** (no amend/rebase/force-push). Push is the user's step.
- **ADR numbering:** assigned at merge-to-`main` (ADR-0000 "Numbering
discipline", added this branch). The website ADR is **0044** — renumbered
from 0042 on the `main` merge, because `main` had independently used 0042
(H1a) and 0043 (compound-FK).
- **Issues:** Gitea via `tea` (repo `oli/rdbms-playground` on
`git.lazyeval.net`); append `< /dev/null` + `timeout 30`; never raw API.
- **Escalate genuine forks**, declare epistemic status, write down the DA pass
(`/runda`) on non-trivial plans.
## Commit history on this branch (newest first)
```
936d925 feat: add "Using the playground" section + Reference skeleton
44390e7 feat: simple-mode code-block highlighting, prompt, and copy rules
995c0ba docs: reconcile website doc inventory with merged main scope
c72c624 chore: bind website dev/preview server to IPv4 loopback (127.0.0.1)
9e774b2 docs: ADR numbering discipline — assign numbers at merge-to-main
40de389 Merge branch 'main' into website (Gitea migration + ADR renumber)
0fcb7b1 docs: website docs structure + first content pages
cea99e8 chore: scaffold website (Astro 6 + Starlight + Tailwind v4)
1fad29c docs: ADR-0042 — public website + documentation site plan (now ADR-0044)
```
## Review status (what the user has signed off)
Highlighting / `> ` prompt / copy behavior — good. Voice, altitude,
terminology — good. Responsive layout (checked in Polypane) — good. Locked
decisions: bare `with pk` leads the simplest examples; copy hidden on
multi-command blocks (not per-command copy); the 5-section structure with
"Using the playground" near the top; assistive editor surfaced on
landing + Getting started. **The 6 Reference stubs have not been reviewed for
content** — only their syntax synopses exist.
+205
View File
@@ -0,0 +1,205 @@
# Website-branch handoff — 2026-06-11 (website-2)
Second handoff for the **website** work (separate sequence from `main`'s
`YYYYMMDD-handoff-NN.md`). Read `website-1` (2026-06-10) first for the
original scaffolding context; this note covers everything since.
## State
- **Branch:** `website`. **HEAD `e782a28`.** Working tree clean. Pushed
through an earlier point; currently **ahead of `origin/website`** (push is
the user's step — never push).
- The website lives in **`website/`** (monorepo; the playground crate is at
the repo root). Living style guide + binding conventions:
**`website/STYLE.md`** (read first). Website decisions:
**`docs/website/adr/20260604-adr-website-001.md`** (the website ADR namespace
— see below). Plan: `docs/website/plans/20260604-website-implementation-plan.md`.
## What changed since website-1 (commit highlights, newest first)
```
e782a28 feat(website): projects cast (vi-nav load picker) + --demo on all casts
927e6b2 Merge branch 'main' (m:n, logging, UI nav, demo overlays, vi-nav)
52860c3 feat(website): casts for first-project/modes/undo-redo; quit via Ctrl-C
ce153bd docs(website): add SQL queries reference page (advanced query surface)
302329d docs(website): record the cast-placement policy in STYLE.md
bb7887e feat(website): relationship-diagram cast on the relationships page
65a48fa feat(website): joins cast on the querying-with-joins guide
c0cc92a docs(website): rewrite Build the library + add Querying with joins guide
10655e4 docs(website): fill the six Reference stubs with worked examples + output
a8f84c9 feat(website): refine casts — trim shell, autoplay+loop landing, cap size
… (cast pipeline, the astro/starlight upgrade, the ADR-namespace move)
```
## ADR namespace (important — avoids the recurring collision)
Website decision records live in their **own namespace**:
`docs/website/adr/` (`<date>-adr-website-<NNN>.md`, id `ADR-website-NNN`),
indexed by `docs/website/adr/README.md`. They do **not** draw from `main`'s
global ADR integer pool, so a `main` ADR and a website ADR can never collide
again (this is why the latest merge of `main`'s ADR-0045/0046/0047 was
conflict-free). Recorded in ADR-0000 "Numbering discipline". The main
`docs/adr/README.md` intro carries a pointer to the website namespace.
## Content status
**Done & verified (build clean, grounded in source, forbidden-terms clean):**
- **Getting started** — installation, first-project, modes, example-library.
- **Using the playground** — command-line-options, the-assistive-editor,
the-output-pane, projects, undo-and-history, export-and-import,
copy-to-clipboard, getting-help.
- **Guides** — build-the-library (full 4-table build, 1:n + m:n bridge),
querying-with-joins.
- **Reference** — types, tables, columns, relationships, indexes, constraints,
inserting-and-editing-data, querying-and-inspecting, **sql-queries** (the
advanced SELECT surface: DISTINCT, GROUP BY/HAVING, set ops, subqueries,
CTEs, CASE/CAST/functions, with a "supported subset" boundary note).
- **Concepts** — projects-and-storage.
- Landing `index.mdx` splash with the quickstart cast.
**26 pages build clean.** Only expected warning: sitemap needs `site` (Phase B).
## asciinema casts — the pipeline + the 7 casts
Pipeline (STYLE.md "asciinema casts", ADR-website-001 §2):
- **Driver: `autocast`** (chosen by spike; `asciinema-automation` can't drive a
full-screen TUI). Sources in `website/casts-src/casts.mjs`; `pnpm casts`
runs `casts-src/generate.mjs` → autocast YAML → `public/casts/<name>.cast`.
Command-lists are the durable source; **`.cast` files are regenerable** —
re-run `pnpm casts` (needs a `cargo build` binary at `../target/debug`).
- **Components:** `Cast.astro` (asciinema-player island) wrapped by
`Demo.astro` (the WASM-swap seam, ADR-website-001 §3). Embed via `<Demo
src="/casts/NAME.cast" … />` in an **.mdx** page (md can't import).
- **Generator features:** trims each cast to the in-app region (drops shell
launch + the return-to-shell); ends casts with **Ctrl-C** (`{ key: 'CtrlC' }`)
not a typed `quit` (invisible, so the cast ends on the last content frame);
per-cast `holdEnd`, `dataDir` (isolated data root, wiped per run), and
**`--demo` is default-on** (opt out with `demo:false`).
- **The 7 casts:** quickstart (landing, autoplay+loop), assistive-editor,
relationship-diagram, joins, modes, undo-redo, **projects** (the new one:
save as → new → load via the picker, navigated with `j`).
### `--demo` (#22 / ADR-0047) is on for ALL casts
The demonstration overlay shows a **badge** for special keystrokes
(`[ENTER]`, `[TAB]`, arrows, etc.) — plain characters are NOT badged. This
makes e.g. the assistive-editor's Tab completion visible (`[TAB]`), and the
projects cast's `j`/`k` picker navigation stays *un*surfaced (plain chars) by
design. Captions exist too (a stealth `Ctrl+]`-delimited banner) — usable in
casts for neutral "point something out" labels, not yet used.
### ⚠️ Cast tooling limits (don't rediscover these)
- autocast can only send **single characters, ASCII control codes (`^X`), and
waits** — **NO arrows / PageUp/PageDown / Home/End / function keys** (those
are escape sequences; the per-key delay makes the terminal read a lone Esc —
verified). So any interaction we want to demo must be reachable via typeable
keys. This is why #24 (vi `j/k` in the picker) was needed for the projects
cast, and why **output-pane scrolling has no cast** (needs PgUp/PgDn).
- `Ctrl+]` (caption toggle) and `Ctrl-C` (quit) ARE sendable (`^]`, `^C`).
### ⚠️ No-advertising constraint (user, 2026-06-11)
The docs must **NOT** advertise that the load picker supports **vi keys**, nor
that **`Ctrl+]`** is the caption/banner trigger. The `--demo` flag itself MAY
be documented lightly as "a teaching helper that shows special keystrokes" —
and nothing more. Casts may *use* vi-nav and captions (the viewer sees only the
result/banner, not the keystroke), but cast captions must not name `j/k` or
`Ctrl+]`.
## NEXT WORK — priority order
### 1. Document the features the `main` merge brought (the biggest gap)
The merge (`927e6b2`) added app features that are **not yet documented**:
- **m:n convenience command**`create m:n relationship …` (C4, **ADR-0045**).
The relationships page currently models m:n only via the manual loans-bridge.
Document the convenience command (it auto-generates the junction table).
Ground in ADR-0045 + `tests/it/m2n.rs` + `tests/typing_surface/create_m2n.rs`
for exact syntax. Likely a new section on the **relationships** reference
page and/or a mention in the build-the-library guide.
- **`--demo` flag** — document on **command-line-options** as a teaching helper
that "shows special keystrokes" (per the no-advertising constraint above —
do NOT mention badges-for-vi or captions/`Ctrl+]`).
- **ADR-0046 UI** — the **schema sidebar** (auto-shows on wide terminals,
`Ctrl-O` navigation mode to peek/expand), **responsive two-row input** +
horizontal scroll, and the geometry-fixed hint panel. Decide where in *Using
the playground* (a new "the schema sidebar" page, or fold into the-output-pane
/ the-assistive-editor). Ground in ADR-0046.
- FK-message fixes + the X1 logging sweep: **no user-doc impact** (note only).
Whenever output changed because of the merge, **re-verify any affected static
output blocks** (capture-harness recipe below).
### 2. Consider a final cast re-record sweep + optional captions
- All casts re-record with `pnpm casts` once the app is "final".
- **Chase up two pacing/clarity guidelines across the existing casts** (added
to STYLE.md "Cast pacing & clarity" 2026-06-11; the projects cast already
follows them):
1. **Don't submit a command too fast** — a typed-and-`Enter`ed-in-one-instant
command vanishes before the viewer reads it. Re-review each cast for
type-then-instant-Enter (especially modal confirms / short commands) and
add a pause before `Enter` (split `type` and `key:'Enter'` steps).
2. **Show state where the sidebar would** — at 90 cols the schema sidebar is
hidden (ADR-0046), so insert `show tables` / `show table` / `show data`
where state changes, so the viewer can follow what happened.
- **Review whether caption banners would improve the existing casts.** The
demo overlay can show a neutral step **caption** (the stealth `Ctrl+]`
banner) to label or narrate a moment — e.g. marking the phases of the
build-the-library/projects casts, or calling out the relationship diagram /
the teaching echo in the modes cast. Go cast-by-cast and decide where a
caption adds clarity vs. adds noise. Constraint: caption **text must not name
keys** (no `j/k`, no `Ctrl+]`); it narrates *what* is happening, not *how* it
was typed. (Captions are wired but not yet used in any cast.)
- Output-pane scrolling cast remains blocked (PgUp/PgDn unsendable). If desired
later, it needs an app-side typeable scroll key (file an enhancement like #24)
— otherwise leave it to static docs.
### 3. Phase B — landing/site polish
- Set Starlight **`site`** (production URL) → clears the sitemap warning,
enables sitemap + canonical/OG. Then SEO/meta conventions (STYLE #8).
- Logo + favicon; branding palette (staying on Starlight).
- **Light/dark player theme**: the asciinema player theme is currently fixed
(`asciinema`); sync it to the Starlight theme toggle (folded into STYLE #8).
- Use the `frontend-design` skill.
### 4. Open STYLE.md decisions
- **#7 Versioning** (leaning single-version for launch).
- **#8 SEO/meta** + the player light/dark theme.
## Capture-harness recipe (how to get accurate static output for new pages)
Output blocks must be **captured from the real app, never hand-drawn**
(STYLE.md). Pattern used throughout:
- For `pub` render fns (`render_data_table`, `render_explain_plan`) + the DB
worker API: a throwaway **external** test `tests/doc_capture.rs` that builds
the library schema via `Database` and prints rendered output; run
`cargo test --test doc_capture -- --nocapture --ignored`; paste verbatim
(trim trailing spaces); delete the file.
- For `pub(crate)` fns (`render_structure_with_diagrams`,
`render_relationship_diagram`): an in-crate `#[ignore]` test in
`src/output_render.rs`'s test module instead.
- **Verify box-drawing integrity** after pasting (top `┬` count == bottom `┴`,
equal line lengths) — a mis-paste truncated a CTE border once and the check
caught it.
## Verify-after-changes checklist
```sh
cd website && pnpm build # clean; 26 pages; only the `site` warning
# from repo root — forbidden terms must be empty:
grep -rniE '\b(DSL|SQLite|STRICT|rusqlite|PRAGMA)\b' website/src/content/
# internal links + heading anchors: spot-check in dist/ (no link validator installed)
cd website && pnpm casts # regenerate all casts (needs target/debug binary)
```
Dev server + tunnel for visual checks (player playback, sizing, badges):
`cd website && pnpm dev` (binds 127.0.0.1:4321) then `ssh -L 4321:127.0.0.1:4321 <host>`.
**Visual playback of all 7 casts (now with `--demo` badges) is still pending a
tunnel check by the user.**
## Process pins
- **Commits:** user-confirmed (show the message first), **no AI attribution**,
**append-only** (no amend/rebase/force-push). Push is the user's step.
- **Ground every page in source** (`src/dsl/*`, `en-US.yaml`, the ADRs) — not
`requirements.md` markers. No engine name, no "DSL" in user-facing copy.
- **Issues** via `tea` (repo `oli/rdbms-playground` on `git.lazyeval.net`;
append `< /dev/null` + `timeout 30`). Open/related: **#22** (demo overlay,
implemented), **#24** (vi picker nav, implemented). Both merged via `927e6b2`.
- Escalate genuine forks; declare epistemic status; write down the `/runda` DA
pass on non-trivial plans.
+203
View File
@@ -0,0 +1,203 @@
# Session handoff — 2026-06-14 (69)
Sixty-ninth handover. Continues from handoff-68 (an issue-burndown that
closed #25/#26/#31/#32/#33/#34). This session **closed the four
remaining open issues** — #29, #28, #27, #30 — each landed with the full
phased workflow + `/runda` + Devil's-Advocate passes before commit, and
each producing a new ADR. Net: **four issues closed, four commits, four
new ADRs (00490052), +63 tests, zero regressions, the tracker is now
empty.**
The four interlock: **#29** added the input-field readline keys, **#27**
advertises them in a state-aware status strip, and **#30**'s history
recall now respects modes. **#30** also turned into a real architecture
change (journaling relocation) — read §2.4 carefully before touching that
area.
## §1. State at handoff
**Branch:** `main`. Working tree **clean**; all work committed. The two
most recent commits are local (normal working state — push is the user's
step).
**Tests: 2471 passing / 0 failing / 0 skipped / 1 ignored** (the
long-standing `friendly` doctest). **Clippy clean** (nursery, all
targets). Breakdown: 1771 lib + 500 integration (`it`) + 200
typing-surface-matrix. **+35 over handoff-68's 2436** (net: #29 +22, #28
+0, #27 +9, #30 +4 — its new history.rs/app.rs/iteration6 tests minus the
15 retired worker-journaling tests; trust the live `cargo test` count).
**Commits this session:**
```
4aeea55 feat(history): mode-tagged history + top-of-chain journaling (#30)
eceedc1 feat(ui): context- and state-aware bottom keybinding strip (#27)
8ac3537 feat(render): incidental-DDL confirmations show structure only, no relationships (#28)
66c8bda feat(input): readline keymap — Esc-clear + Ctrl-A/E/W/K/U (#29)
```
**Open Gitea issues: none.** `tea issues list --state open` is empty.
## §2. Issues closed this session (all committed, tested, `/runda`-reviewed)
Each closed on `git.lazyeval.net/oli/rdbms-playground` with a summary
comment.
### 2.1 — #29 (`66c8bda`) — input-field readline keymap (ADR-0049)
Implements the deferred **I1b** readline shortcuts: `Esc` clears a
partly-typed command (only when no completion memo is alive — the memo
wins first, ADR-0022); `Ctrl-A`/`Ctrl-E` = Home/End; `Ctrl-W` deletes
the previous word (readline-style, UTF-8 safe); `Ctrl-K`/`Ctrl-U` kill to
end/start. Cursor-only keys leave history nav intact; buffer-mutating
keys end it. **DA caught** the need for the `Ctrl-O`+`Esc` (sidebar
nav-exit) interaction not to clear the draft — locked with a regression
test. `requirements.md` I1b → `[x]`.
### 2.2 — #28 (`8ac3537`) — incidental-DDL confirmations: structure-only (ADR-0050)
Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/
`rename`/`change column`, `add`/`drop index`) now render **structure
only** — no `References:` / `Referenced by:` block. Relationship-subject
surfaces (`show table`, `add`/`drop relationship`) keep their ADR-0044
diagrams. The prose renderer (`relationship_prose_lines` + `cols_disp`)
was deleted. **Supersedes** ADR-0044 §1's incidental-DDL prose clause and
the relationship-block half of ADR-0016 §5 (both annotated).
### 2.3 — #27 (`eceedc1`) — context- and state-aware keybinding strip (ADR-0051)
The bottom status line is now keystrokes-only and **state-selected** by
priority (sidebar focus / completion-memo / history-nav / editing /
default). The editing state surfaces the #29 keys (closing ADR-0049's
deferred advertisement). Mode-switch advertisements left the strip; the
empty-input hint gained a simple-mode `` `mode advanced` for SQL `` pointer
(advanced mode shows none — user decision). New `App::is_browsing_history()`
exposes the private `history_cursor`. 15 full-panel snapshots re-accepted.
### 2.4 — #30 (`4aeea55`) — mode-tagged history + top-of-chain journaling (ADR-0052) **← read before touching journaling**
Closed both the feature (advanced history reusable in simple mode) and
the bug (the `:` one-shot prefix lost across sessions). Two halves:
1. **Mode-tagged history.** The `history.log` status token gains an
optional `:adv` suffix (`ok` / `ok:adv` / `err` / `err:adv`); `source`
stays last + canonical so replay is unaffected. The in-memory ring
(still `Vec<String>`) stores advanced entries in their `: `-prefixed
simple-mode runnable form; recall **strips the `:` in advanced mode**
and keeps it in simple; hydration reconstructs the prefix from the tag.
App commands journal simple and are excluded from the ring's advanced
flag, so they recall bare.
2. **Journaling relocation (the architecture change).** Success
journaling **moved out of the worker** to the dispatch layer
(`spawn_dsl_dispatch` / `run_replay` / the app-command sites), next to
the already-top-level failure journaling — so the submission mode is in
scope with no worker plumbing. `finalize_persistence` now writes only
the **state** sources (yaml/csv); the journal write is **best-effort**
(the command is already committed — consistent with the failure path).
**Amends ADR-0015 §6** (history.log out of the worker tx; commit-db-last
scopes yaml/csv/db only), **ADR-0034** (status tag + journaling
location), **ADR-0040** (journal-write best-effort, not fatal).
**Two DA findings, both resolved:** (a) the app-command `advanced` flag
must exclude app commands (else `: save as` diverges); (b) the spawn
journals on `outcome.is_ok()`, so journaling is now **uniform** — read
commands that didn't journal before (`show tables`/`show relationships`/
`show indexes`, `show relationship <name>`, `explain`) now do, matching
ADR-0034 §1. **User-confirmed** as the more-correct behaviour (harmless
on replay — reads/`explain` don't mutate).
**Test migration:** 15 worker-level journaling tests were retired (the
worker no longer journals — their yaml/csv/operation assertions were
kept) and re-covered at the new layer: `history.rs` status-tag +
`:`-reconstruct; `app.rs` recall matrix; the cross-session regression
`advanced_command_journalled_then_hydrated_recalls_with_colon_in_simple`
in `iteration6_resume_history`; the replay tests cover `run_replay`
journaling.
Plan: `docs/plans/20260613-issue-30-top-of-chain-journaling.md`.
## §3. Next session — start here
The user's stated plan for the next session, in order:
1. **Pick up the ADR-0052 follow-up** (below).
2. **Check for any newly-filed open issues** (`tea issues list --state
open`) — none at handoff, but check fresh.
3. **Then** take on remaining open tasks from the general requirements
(`docs/requirements.md`) — see §5.
### The ADR-0052 follow-up — unwind the vestigial worker `source` plumbing
When journaling moved out of the worker, the `source` that the worker
threaded purely for journaling became dead. To avoid orphaning the param
across ~28 handlers, the refactor **left it in place** as vestigial:
- `finalize_persistence(conn, persistence, _source, changes)` — the
`_source` param is now unused (kept so its ~28 callers still pass
`source`, which they otherwise also use for `snapshot_then`).
- `do_rebuild_from_text(conn, _persistence, _source, project_path)`
both `_persistence` and `_source` vestigial.
- Three thin read-only wrappers in `db.rs`
`do_describe_table_request`, `do_query_data_request`,
`do_run_select_request` — now just delegate to their non-`_request`
twin (`do_describe_table` / `do_query_data` / `do_run_select`) with
vestigial `_persistence` / `_source` params and one caller each
(`db.rs` Request arms ~2409 / ~2749 / ~2759).
**The cleanup:** remove `_source` from `finalize_persistence` + drop the
arg at its ~28 callers (the callers keep `source` for `snapshot_then`, so
only the `finalize_persistence(...)` call loses the arg); remove the
`_persistence`/`_source` params from `do_rebuild_from_text`; and inline
the three `*_request` wrappers at their single call sites (replace
`do_describe_table_request(conn, persistence, source, name)` with
`do_describe_table(conn, &name)`, etc.), deleting the wrappers. Purely
mechanical, compiler-guided, no behaviour change. Establish the green
baseline first (`cargo test`), then verify nothing moved.
## §4. Carried-over follow-up (website branch, not `main`)
- **Website `seed` cast re-record** (from #34, handoff-68 §4) — still
tracked on the `website` branch, not here. Likely redundant (full
re-record sweep before publication).
## §5. Remaining roadmap — `docs/requirements.md` (next session's §3-step 3)
With the issue tracker empty, the next work comes from the document-based
requirements. Open / partial items worth weighing (the user picks):
- **H2 `hint`** — the last A1 gap (contextual help for the current
command); its own ADR. (`requirements.md` H2.)
- **TT5 CI** — runs all tiers on Linux/macOS/Windows; no CI workflow yet
(a `ci` branch reportedly exists — check its state first). Couples with
**D1D3** (cross-platform prebuilt binaries + Homebrew/Scoop).
- **TT4 PTY (Tier-4)** — ADR-0008 specifies the PTY harness + four
critical flows; still not wired (no PTY deps/tests).
- **I1 multi-line input** (Ctrl-Enter submits, Enter inserts newline) and
**I5 / B3 in-flight cancellation** (Ctrl-C cancels a running command).
- **V4 session journal** — scrollable per-session log + Markdown export
(the bigger UX project; own ADR).
- **TU1 tutorial / lesson system** — design + ADR pending (acknowledged
in scope).
- Smaller partials: **C3a** modify relationship (drop+add covers it
today), **C4** m:n convenience, **V3** ER-diagram export, the **NFR-***
performance/visual targets (mostly unmeasured), **N4** global rolling
history (OOS for v1).
No strong ordering — these are the user's call. Several need a new ADR
(H2, V4, TU1); CI/release (TT5/D1D3) is the most "shippable-product"
track if that's the priority.
## §6. How to take over
1. Read handoffs 67 → 68 → 69, `CLAUDE.md`, `docs/requirements.md`.
2. Confirm green baseline: `cargo test` (expect **2471 pass / 1 ignored**)
+ `cargo clippy --all-targets` (clean).
3. `tea issues list --state open` — pick up anything new first.
4. Then the ADR-0052 follow-up (§3), then requirements (§5).
5. Follow the project workflow: phased (requirements → divergent → eval →
execute → verify), test-first, `/runda` + DA pass before every commit,
ADR amendment for any decided-area change + the README index-upkeep
rule, and confirm the commit message with the user before committing.
6. Consider a `cargo sweep` at this milestone (`target/` grows across
sessions; see CLAUDE.md "Build hygiene"). (`sweep.timestamp` was
removed this session.)
+165
View File
@@ -0,0 +1,165 @@
# Session handoff — 2026-06-15 (70)
Seventieth handover. Continues from handoff-69 (which closed the last
four Gitea issues and left the tracker empty). This session did the
**ADR-0052 follow-up** (unwinding vestigial worker `source` plumbing),
then **designed and fully implemented H2 — the contextual `hint`
command + F1 keybinding (ADR-0053)** end to end (Phases AD). The CI
branch was also merged into `main` mid-session (not my work — see §5).
Net: **2 feature areas shipped, 1 new ADR (0053) + 1 ADR amendment
(0052), 4 new Gitea issues (#35#38), the `hint` corpus (~57 teaching
blocks), and A1 + H2 closed in `requirements.md`.**
## §1. State at handoff
**Branch:** `main`. Working tree **clean**; all work committed. Commits
are local (push is the user's step).
**Tests: 2499 passing / 0 failing / 0 skipped / 1 ignored** (the
long-standing `friendly` doctest). **Clippy clean** (nursery, all
targets). Breakdown: 1799 lib + 500 `it` + 200 typing-surface-matrix.
**Open Gitea issues (4, all enhancement, all filed this session):**
- **#35** — enforce `cargo fmt` across the codebase (single reformat +
CI gate). The tree is *not* fmt-clean (~1800 pre-existing diffs); do it
once, coordinated with CI, before first publication.
- **#36** — `help` collapses advanced-SQL forms onto their simple sibling
(a `help`-list dedup artifact); they deserve distinct help content.
- **#37** — `hint` clause-concept hints (`on delete` actions, constraint
slots, `with pk`, cardinality) — a deferred `hint.concept.<topic>`
layer.
- **#38** — `hint` pre-submit-diagnostic route + the ~33 `diagnostic.*`
tier-3 blocks (deferred; `Diagnostic` carries no class key).
## §2. ADR-0052 follow-up — vestigial worker `source` unwind (`e8fa859`)
The first task from handoff-69 §3. ADR-0052 moved success-journaling out
of the worker, leaving the `source` that handlers threaded purely for the
old `history.log` write dead. **Bigger than the handoff estimated** (it
framed it as ~28 call-site edits): the cascade ran through ~30 worker
handlers + the `DescribeTable`/`QueryData`/`RunSelect` request fields +
their `DatabaseHandle` methods (~164 mostly-test call sites). Fully
unwound, compiler-guided, **no behaviour change** (journaling uses a
`source_for_journal` clone at the spawn, independent of the worker). The
only worker `source` left is the snapshot/undo label. Amended ADR-0052
*Consequences* + README. (Two scope forks escalated + user-approved.)
## §3. H2 — contextual `hint` (ADR-0053), Phases AD — **shipped**
The bulk of the session. ADR-0053 settles the `hint` slot ADR-0003 left
"ADR pending"; **closes A1** (all 15 app commands now exist) and
**requirements H2**. Read ADR-0053 before touching this area — it went
through three revisions and several user decisions.
### The design (all user-chosen)
- **Two surfaces:** an **F1 keybinding** → tier-3 hint for the *live*
partial input (read-only overlay — never touches buffer/cursor/memo);
a submitted **`hint` command** → expands on the *most recent runtime
error*. No topic arg (contextual only; `help <topic>` owns reference).
- **Tier-3 teaching layer** beneath the existing tier-1 (colour / error
headline) and tier-2 (ambient one-liner; the error `hint:` shown **by
default** since `Verbosity::Verbose` is the default). Each block is
`what` / `example` / `concept`, rendered as a `Hint` heading + aligned
labels.
- **Per-form keying** (Phase-B revision — the original per-node `hint_id`
was too coarse for multi-form commands like `add`/`drop`/`show`): a new
**`hint_ids: &[&str]`** field on `CommandNode` mirroring `usage_ids`,
resolved by `hint_key_for_input_in_mode` (reuses `usage_key`'s
form-word disambiguation + a mode-primary fallback for shared entry
words so advanced `insert``sql_insert`, simple → `insert`).
- **Comprehensive for v1 = command forms + 9 runtime error classes**
(the ~33 `diagnostic.*` classes were **deferred**, #38 — see §4).
### Key files
- `src/dsl/command.rs``AppCommand::Hint`.
- `src/dsl/grammar/app.rs``HINT` node + `build_hint`.
- `src/dsl/grammar/mod.rs` — the `hint_ids` field, `hint_key_for_input_in_mode`,
the factored `pick_form_key`, and the two **comprehensiveness coverage
tests** (every node has a resolving `hint.cmd.*`; every runtime error
class has a `hint.err.*`).
- `src/app.rs` — F1 arm in `handle_key` (read-only overlay, placed before
the completion-memo clear); `note_hint_for_input` / `note_hint_for_recent_error`
/ `note_getting_started` / `emit_tier3_block`; `last_error_hint_key`
state (set in `handle_dsl_failure`, cleared in `submit` for DSL
commands).
- `src/friendly/translate.rs``error_hint_class` (maps a `DbError` +
ctx to its `hint.err.<class>`; mirrors `translate`'s dispatch — keep in
sync, unit-tested).
- `src/friendly/strings/en-US.yaml` + `keys.rs` — the corpus under
`hint.cmd.<form>` / `hint.err.<class>` + `hint.block.*` labels +
`shortcut.hint`.
- `src/ui.rs` — ADR-0051 strip advertises **F1** (editing + default
states); 12 full-panel snapshots re-accepted.
### Phases (one commit each unless noted)
- **A** (`050b363`) skeleton + tier-2 fallback; **B** (`4a5fd1b`) per-form
keying + 3 exemplars; **C** content in 5 batches (`4bdfce6` app,
`6429b56` DDL, `9c4d520` DML, `97970f2` advanced-SQL, `b6b98ad` runtime
errors) + `417cbc8` diagnostic deferral; **D** (`447112b`) coverage gate
+ F1 strip + status flips; **/runda fix** (`329adfc`) — see §3.1.
### 3.1 — what the final `/runda` caught (don't skip)
Per-batch substring tests masked a **presentation gap**: `emit_tier3_block`
was emitting three *bare, unlabelled* lines, deviating from the approved
exemplar format. Fixed to render a `Hint` heading + aligned `What:` /
`Example:` / `Concept:` lines, **locked by an `insta` snapshot**
(`hint_block_insert`). Also confirmed the `Next:` line (ADR D2 exemplar)
is correctly **omitted** — tier-2 ambient already owns live
position-awareness. Lesson for the next content/UI work: **add a rendered
snapshot early**; substring asserts don't see layout.
## §4. Deferrals (all tracked, all user-confirmed)
- **#38 diagnostic route + `diagnostic.*` blocks** — `Diagnostic`
(`walker/outcome.rs`) carries only its rendered `message`, not a class
key, so the F1 diagnostic route would need a `class` field threaded
through every diagnostic site (broad) for marginal value (tier-2
already surfaces diagnostics; many duplicate runtime classes). F1 still
shows the useful command block when a diagnostic is present.
- **#37 clause-concept hints** — per-form is the right tier-3 granularity;
clause-level concepts are a separate `hint.concept.<topic>` layer for
later.
- **#36 `help` advanced-SQL** — out of H2's scope (touches shipped `help`).
## §5. CI branch merged into `main` (not my work)
Mid-session the **`ci` branch was merged** (commits `47a0816`, `138e766`
+ the `ci:`/`build:`/`docs(ci):` commits). `main` now carries a CI
pipeline, a nix flake, and **D1 cross-platform release builds** (matrix +
macOS), documented under a **new `docs/ci/adr/` namespace** (ci-001..003).
Implications for the roadmap: **D1 (cross-platform binaries) is now
substantially underway** — re-assess D1/D2/D3 status against what landed
before treating them as open. My H2 work is layered cleanly on top (all
green post-merge).
## §6. Next session — start here
1. **Push** (user step) — 30-odd local commits incl. the CI merge + all
of H2.
2. **Re-baseline the roadmap** against the merged CI work: D1/D2/D3 and
**TT5 CI** are partly/largely done now — read `docs/ci/adr/` and the
workflows before assuming they're open (handoff-69 §5 predates this).
3. **#35 (cargo fmt gate)** is the natural pairing with the now-merged CI
— the user wanted it done once, before first publication.
4. Other `requirements.md` open items (verify against CI merge first):
**TT4** PTY tier-4 (still unwired), **I1** multi-line input, **I5/B3**
in-flight cancellation, **V4** session journal (own ADR), **TU1**
tutorial system (own ADR). H2/A1 are now **done**.
5. The H2 deferrals (#36/#37/#38) are available if the user wants to
round out the hint/help surface.
## §7. How to take over
1. Read handoffs 68 → 69 → 70, `CLAUDE.md`, `docs/requirements.md`.
2. Confirm green: `cargo test` (expect **2499 pass / 1 ignored**) +
`cargo clippy --all-targets` (clean).
3. Read `docs/ci/adr/` (the merged CI work) before touching CI/release/D*.
4. For anything in the `hint` area, read **ADR-0053** first (3 revisions
+ deferrals #37/#38). For journaling, ADR-0052 (+ its 2026-06-14
follow-up note).
5. Project workflow unchanged: phased, test-first, `/runda` + DA before
commits, ADR amendment + README index-upkeep for decided-area changes,
confirm commit messages with the user.
6. Consider a `cargo sweep` at this milestone (`target/` grows; see
CLAUDE.md "Build hygiene").
+120
View File
@@ -0,0 +1,120 @@
# Session handoff — 2026-06-15 (71)
Short, focused handover. Continues immediately from handoff-70 (which
shipped H2 / the contextual `hint`, ADR-0053). **A user smoke-test
surfaced a correctness bug in the hint content, and it implicates the
whole corpus.** This handoff exists so the next session does a
**systematic semantic verification pass over every hint block** — context
ran too low to do it now.
## §1. State
**Branch:** `main`, clean, all committed (local; push pending). **2499
pass / 1 ignored, clippy clean.** Open issues: #35#38 (see handoff-70).
H2 / ADR-0053 is *functionally* complete; the **content is not
trustworthy** until the pass below is done.
## §2. The bug (confirmed)
`hint.cmd.create_table` (in `src/friendly/strings/en-US.yaml`) reads:
```
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.
```
**This is wrong.** In the DSL, **everything after `with pk` is the
primary-key column list** (a possibly *compound* PK, ADR-0005). So the
example does **not** create a table with `pk=id` plus regular columns
`name`/`email` — it creates a table whose **compound primary key is
(id, name, email)**. Non-key columns are added *separately* with
`add column`. The `what` ("its columns, their types") and the example
both mislead a learner badly.
- **Evidence:** real test usage is `create table Orders with pk
id(serial), CustId(int)` (a 2-column *compound PK*) and the common form
`create table X with pk id(int)` (single-column PK only). The usage
template `create table <Name> with pk [<col>(<type>)[, ...]]` is itself
misleading — the `[, ...]` is the PK list, not regular columns.
- **Correct mental model:** `create table <T> with pk <pk-cols…>` then
`add column <T>: <name> (<type>)` 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.<form>`** — for each, cross-check the example against the
matching `parse.usage.<form>` 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.<class>`** — 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), 00300036 (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.
+113
View File
@@ -0,0 +1,113 @@
# Session handoff — 2026-06-15 (72)
Short, focused handover. Continues from handoff-71, which asked the next
session to run a **systematic semantic verification pass over every
`hint` block** (handoff-70 shipped H2 / ADR-0053, but a user smoke-test
found a wrong hint and implicated the whole corpus). **That pass is now
done.** Four content errors fixed, a durable parse-guard added, two stale
docs corrected. Commit `5a37437`.
## §1. State
**Branch:** `main`, clean, all committed (local; **push pending** — your
step). **2500 pass / 0 fail / 1 ignored** (the long-standing `friendly`
doctest), **clippy clean** (nursery, all targets). The +1 vs handoff-71's
2499 is the new guard test. Open Gitea issues unchanged: **#35#38**.
## §2. The verification pass (commit `5a37437`)
Method: cross-checked every `hint.cmd.*` example against its
`parse.usage.*` template, ground-truthed every concept claim against the
authoritative ADR **and a named existing test** (not grep+extrapolation —
the trap handoff-71 §3 warned about), and parse-validated all 49 command
examples via a new guard.
### Four content errors fixed (`src/friendly/strings/en-US.yaml`)
| Block | Bug | Fix |
|---|---|---|
| `cmd.create_table` | Example `with pk id(serial), name(text), email(text)` declares a **3-column compound PK**, not a PK + regular columns. Every `with pk` column is a key member — confirmed by the grammar test comment *"Every `create table` column is a primary-key column"* (`ddl.rs`), ADR-0005. | Single-column PK + `add column` for the rest; `what`/`concept` aligned. |
| `cmd.save` | `save as my-shop` **does not parse**`build_save` yields `AppCommand::SaveAs` with **no inline name**; `save as` opens a path-entry modal (`iteration4b` tests). | Example → `save as`; `what` de-implied; added an accurate temp-vs-named-auto-save `concept`. |
| `cmd.import` | Target `shop-copy` **does not parse** — the `as <target>` slot is an `IdentSource::NewName` ident that tokenises only up to the hyphen. (The zip path is a BarePath and *does* accept hyphens, hence `export my-shop.zip` is fine.) | → `shop_copy`. |
| `err.foreign_key.child_side.concept` | Offered `on delete set null/cascade` as the remedy — but `error_hint_class` maps child_side to **insert/update** violations; `on delete` governs the **parent** direction. The tier-1 hint (line 64) correctly omits it. | Corrected: parent must exist first; clarified `on delete` is the *other* direction. |
### Durable guard added
`every_cmd_hint_example_parses_in_its_mode` (`src/dsl/grammar/mod.rs`,
in the `hint_key_tests` module). **Catalog-driven** — it iterates
`catalog().keys()` for `hint.cmd.*.example` rather than the REGISTRY, so
an orphaned/mis-keyed block can't slip past; floor-asserts ≥49 examples.
Each parses in its taught mode (advanced for the SQL surface, simple
otherwise). It caught the `save` and `import` errors **test-first** (red
before the YAML fix). Registered the new `hint.cmd.save.concept` key in
`keys.rs` (the `keys_validate_against_catalog` test requires every catalog
key be declared).
### Verified correct (not changed)
All other `cmd`/`err` blocks. Notably the guard-*concept* claims were each
confirmed against a named runtime test, not assumed:
`drop_column_refuses_primary_key` / `…_column_in_a_relationship`,
`drop_table_with_inbound_relationship_errors`,
`add_not_null_column_without_default_to_populated_table_is_refused`. The
corrected `create_table` story stays coherent with the `Customers`-
referencing examples (id serial PK → `add column` name/email → `insert`
skips the auto id).
## §3. Docs corrected (same commit)
Discovered while verifying `create_m2n` (which **is** implemented —
`db.rs::do_create_m2n_relationship` + `tests/it/m2n.rs`):
- **CLAUDE.md** carried two **stale "deferred" claims**, both already
implemented. Removed/updated: (a) the at-a-glance project-format line
said export/import (Iter 5) + `--resume`/input-history/migration (Iter
6) were "pending" — all `[x]` in `requirements.md` (ADR-0015); (b) the
"Things deliberately deferred" list still had the **m:n convenience
(C4)** bullet and the same project-storage bullet. `requirements.md`
was already correct (C4 done 2026-06-10, ADR-0045), so only a
verification-pass note was appended to its **H2** entry.
## §4. Scope note — what the guard does *not* do
The bug class here is **semantic** (an example that parses and runs but
misrepresents the prose — e.g. `create_table`). The guard enforces only
the **syntactic floor**: examples parse in their mode. It backstops
future typos/clause-drift but cannot police meaning. Semantic correctness
of the current corpus rests on this session's review (recorded in the
commit + requirements.md H2). A stronger-but-brittler option was offered
to the user and **not built pending their call**: per-form assertions
that each example resolves to the *expected command shape* (e.g.
create_table → single-column PK). `hint.err.*` examples are fix-recipe
prose, not runnable, so they're verified by review only — inherent.
## §5. Next session — start here
The hint corpus is now trustworthy. Open roadmap (verify against the CI
merge first, per handoff-70 §5):
1. **Push** (your step) — this commit + the still-unpushed backlog from
handoffs 70/71 (the CI merge + all of H2).
2. **#35 (cargo fmt gate)** — the natural pairing with the merged CI; the
user wanted it done once, before first publication. The tree is **not**
fmt-clean (~1800 pre-existing diffs).
3. Other `requirements.md` open items: **TT4** PTY tier-4 (unwired),
**I1** multi-line input, **I5/B3** in-flight cancellation, **V4**
session journal (own ADR), **TU1** tutorial system (own ADR).
4. Hint follow-ups if wanted: **#37** clause-concept hints, **#38**
diagnostic route + `diagnostic.*` blocks, **#36** `help` advanced-SQL.
## §6. How to take over
1. Read handoffs 70 → 71 → 72, `CLAUDE.md`, `docs/requirements.md`.
2. Confirm green: `cargo test` (**2500 / 1 ignored**) + `cargo clippy
--all-targets` (clean).
3. For anything in the `hint` area, read **ADR-0053** first. For the
corpus, `src/friendly/strings/en-US.yaml` (`hint.cmd.*` / `hint.err.*`)
is the content; the guard in `src/dsl/grammar/mod.rs` is the regression
net.
4. Workflow unchanged: phased, test-first, `/runda` + DA before commits,
ADR amendment + README index-upkeep for decided-area changes, confirm
commit messages with the user.
5. Consider a `cargo sweep` at this milestone (`target/` grows; see
CLAUDE.md "Build hygiene").
+112
View File
@@ -0,0 +1,112 @@
# Session handoff — 2026-06-16 (73)
Short, focused handover. Continues from handoff-72 (which completed the
H2 hint-corpus verification pass). This session shipped one small
feature — a **`Ctrl-G` demo-mode alias for F1** — plus follow-on doc
hygiene. Commit `4016c3e`.
## §1. State
**Branch:** `main`, clean, all committed (local; **push pending** — your
step; the backlog now spans the CI merge, H2, the hint-corpus fixes,
handoffs 71/72/73, and this Ctrl-G commit). **2503 pass / 0 fail / 1
ignored** (the long-standing `friendly` doctest), **clippy clean**
(nursery, all targets). Open Gitea issues unchanged: **#35#38**.
## §2. Why Ctrl-G (the problem)
Casts are recorded with **`autocast`** (ADR-0047 demo mode: `--demo` /
`RDBMS_PLAYGROUND_DEMO`). The contextual hint overlay (ADR-0053 / H2)
opens on **F1** — but F1 reaches the app only as a terminal **escape
sequence** (`\eOP` / `\e[11~`), and **autocast cannot emit escape
sequences**. So the single most teaching-relevant overlay was
unreachable in recordings (and in presenter/teacher sessions, which also
run `--demo`). Same wall that pushed step-captions onto `Ctrl+]` (a
single control byte) rather than `Ctrl+!`.
### Chord choice — why Ctrl-G, why not Ctrl-1
The user's first instinct was `Ctrl-1` (mnemonic, near F1). **Not
possible:** in a legacy terminal `Ctrl`+digit has no control byte —
`Ctrl-1` arrives as a bare `1` (would type "1" into the buffer). The
kitty keyboard protocol *would* encode it, but only as an escape
sequence (the very thing autocast can't send), and this app deliberately
does **not** push `KeyboardEnhancementFlags` (`runtime.rs` does only
`enable_raw_mode` + `EnterAlternateScreen`). So the usable space is
exactly **`Ctrl`+letter** (single legacy control bytes). After excluding
taken chords (`Ctrl-C` quit, `Ctrl-O` nav, `Ctrl+]` caption,
`Ctrl-A/E/W/K/U` readline per ADR-0049), byte-collisions
(`Ctrl-H/I/J/M/[`), flow-control (`Ctrl-S/Q`), and likely-future
(`Ctrl-R/P/N/Y/L/V`), **`Ctrl-G`** is the clean survivor (BEL/"abort" in
line editors — nothing destructive to shadow).
## §3. What shipped (commit `4016c3e`)
ADR-0047 **Amendment 1**. **In demo mode only**, `Ctrl-G` aliases F1:
- Runs the *exact* F1 hint logic (`hint_key` guard in
`App::handle_key`, `src/app.rs` ~line 1226 — `key.code == F(1) ||
(self.demo_mode && Ctrl-G)`), so live-input → form hint, empty input →
recent-error / getting-started.
- **Badges as `[F1]`** (not `[CTRL-G]`): `demo_badge_label` maps
`Ctrl-G → Some("[F1]")` (consulted only in demo mode — the caller
gates). So a recorded cast is **visually identical to a real F1
press**.
- **Demo-gated:** the shipped keymap stays F1-only. Outside demo mode
`Ctrl-G` falls through to the inert catch-all (the `Char(c)` insert arm
excludes CONTROL, so no `g` is typed).
- The keybinding strip (ADR-0051) is **not** changed — F1 stays the
advertised key; `Ctrl-G` is a recorder aid and the badge already reads
`[F1]`.
**Tests (test-first, `src/app.rs` Tier-1):** `ctrl_g_in_demo_mode_-
aliases_f1_on_input`, `…_on_empty_input`, `ctrl_g_outside_demo_mode_-
is_inert`, plus a `Ctrl-G → [F1]` assertion added to
`demo_badge_label_maps_the_invisible_keys`. All confirmed red→green; the
"inert" test passed on the pre-change code, proving the demo-gate.
### Using it in a cast
With `--demo` active, send **`Ctrl-G`** in the autocast script wherever
you want the hint overlay to appear; the viewer sees the `[F1]` badge.
## §4. Doc hygiene done alongside (same commit)
- **ADR-0047 file:** removed two stray `</content>` / `</invoke>` lines
(tool-call residue accidentally committed when the ADR was authored).
- **CLAUDE.md "Things deliberately deferred":** dropped three **stale**
entries — **I1b** readline shortcuts (done, ADR-0049), **I3** tab
completion, and **I4** syntax highlighting (both done; requirements.md
even carries a 2026-06-07 reconciliation note that I3/I4 were
"shipped but marked not yet"). Only **I1** (multi-line input) remains —
it is genuinely still open (`[ ]` in requirements.md). *(This follows
the handoff-72 cleanup that removed the equally-stale m:n/C4 +
project-storage entries.)*
## §5. Open / next (unchanged from handoff-72 §5)
The hint corpus is trustworthy and the cast-F1 gap is closed. Roadmap:
1. **Push** (your step).
2. **#35 (cargo fmt gate)** — precondition (CI merged) is met; the user
wants it done once, before first publication. Needs a `rustfmt.toml`-
vs-defaults decision first; tree is ~1800 hunks dirty.
3. Other open `requirements.md` items: **I1** multi-line input, **I5/B3**
in-flight cancellation, **TT4** PTY tier-4 (unwired), **DOC1**/**E2**
user docs (partial), **TT5** Windows-execution + Tier-4-in-CI, **D3**
packaging manifests. Design-first (`[~]`): **V4** session journal,
**TU1** tutorial, **C3a**, **V3**.
4. Hint follow-ups if wanted: **#36** `help` advanced-SQL, **#37** hint
clause-concepts, **#38** hint diagnostic route.
## §6. How to take over
1. Read handoffs 71 → 72 → 73, `CLAUDE.md`, `docs/requirements.md`.
2. Confirm green: `cargo test` (**2503 / 1 ignored**) + `cargo clippy
--all-targets` (clean).
3. For demo-mode / casting, read **ADR-0047** (+ its Amendment 1); for
the hint overlay it aliases, **ADR-0053**.
4. Workflow unchanged: phased, test-first, `/runda` + DA before commits,
ADR amendment + README index-upkeep for decided-area changes, confirm
commit messages with the user.
5. Consider a `cargo sweep` at this milestone (`target/` grows; see
CLAUDE.md "Build hygiene").
@@ -0,0 +1,247 @@
# Plan — issue #30: mode-tagged history + top-of-chain journaling
**Status:** draft for `/runda` review (2026-06-13).
**Issue:** #30 — advanced history reusable in simple mode (prepend `:`),
and the bug: the `:` one-shot prefix is lost across sessions.
**ADR:** ADR-0052 (new); amends ADR-0015 §6, ADR-0034, ADR-0040;
references ADR-0003.
## 1. Goal & root cause
Two coupled needs, one root cause — **history entries carry no mode**:
- **Bug:** the in-memory ring stores the raw `:select 1`, but the worker
journals the *stripped* `select 1`, so cross-session the `:` is lost
and the command recalls bare (unusable in simple mode).
- **Feature:** persistent-advanced commands (`select 1` typed in advanced
mode) can't be told apart from simple DSL, so they can't be offered
back with a `:` in simple mode.
Fix: **record the submission mode per entry** (status tag `:adv`), keep
the on-disk `source` canonical, and have **recall prepend/strip `:`** for
the current mode.
## 2. The architecture insight (why this plan is shaped this way)
Journaling **success** lives deep in the worker: `finalize_persistence`
(db.rs:3096-3099) writes `history.log` *inside the db transaction, before
`tx.commit()`*, alongside yaml/csv — plus four no-op-skip sites and three
read-only helpers. **Failure** journaling already lives at the top
(runtime.rs:484-495, best-effort). Threading the mode *down* to the
worker would mean ~30 `Request` variants + `Database` methods +
`execute_command_typed` arms — because the journal write is far from
where the mode is known.
So instead: **move success journaling up to the dispatch layer**, next to
where failure journaling already is and where mode + outcome + source are
all in scope. The mode then needs no plumbing. This is the correct
separation anyway — `history.log` is an append-only *journal of what was
typed*, not *state*; the state sources (yaml/csv/db) stay atomic in the
worker.
### Semantic changes this entails (must be vetted)
1. **history.log leaves the worker transaction** (amends ADR-0015 §6).
`commit-db-last` still governs yaml/csv/db (the state); the journal is
written *after* the worker replies (i.e. after `tx.commit`), at the
dispatch layer.
2. **Success-journal write failure: fatal → best-effort** (amends
ADR-0040). Today a failed `history.log` write on a *successful*
command rolls the command back and shows a fatal banner. After: the
command stays committed; the journal write is best-effort (logged +
ignored), exactly like the failure path already is. The two journal
paths become *consistent*.
3. **Consequence:** on a rare journal-write failure (disk full /
permissions) a successful command is applied but may be missing from
`history.log` — not recallable next session, not replayable. The state
(yaml/csv/db) is unaffected and consistent. This is a graceful
degradation, not corruption, and is logged. (Today the same disk-full
instead kills the app mid-command.)
**Open question for review/user:** is trading "fatal on journal-write
failure" for "best-effort, command still succeeds" acceptable? The plan
assumes **yes** (a journal is auxiliary; killing the app over it is worse
UX). If not, journaling must stay coupled in the worker and we pay the
~30-site mode plumbing instead.
## 3. On-disk format (mode tag in status — already chosen + partly built)
Record stays `<ts>|<status>|<source>`; the **status token** gains an
optional `:adv` suffix (ADR-0052). `source` stays canonical so replay is
unaffected.
| Submission | Success | Failure |
|---|---|---|
| Simple / app command | `ok` | `err` |
| Advanced (SQL, persistent or one-shot) | `ok:adv` | `err:adv` |
**Done already** (history.rs / mod.rs):
- `status_token(base, advanced)`, `parse_status(status) -> (is_ok, advanced)`.
- `parse_record_source` reconstructs `": {cmd}"` for `:adv` records.
- `parse_journal_record.status_is_ok` via `parse_status` (so `ok:adv` replays).
- `append_history(text, advanced)`, `append_history_failure(text, advanced)`.
Back-compat: old `ok`/`err` logs → simple; nothing migrates.
## 4. In-memory ring & recall (app.rs) — the #30 behaviour
The ring stays `Vec<String>`. An **advanced** entry is stored in its
`: `-prefixed simple-mode runnable form (matching the existing in-session
one-shot ring); a **simple** entry bare. A leading `:` unambiguously
marks advanced (simple DSL can never start with `:`).
- **`submit`** (app.rs:1704): compute `effective_input` + `submission_mode`,
parse once for the app-command check (already done at 1751), then build
the ring line. The **`advanced` flag excludes app commands** —
`advanced = submission_mode.is_advanced() && !is_app_command` — because
app commands (`undo`, `mode …`, `save as`, …) run in *any* mode and must
**not** get a `:` on recall. Ring line: `": " + effective_input` if
`advanced`, else `effective_input`; `push_history(&ring_line)`. (Today it
pushes the raw `trimmed` *before* stripping; the reorder also drops a
bare `:`, which executed nothing, and is what lets the app-command check
precede the push.) `ExecuteDsl.source` stays the **canonical**
`effective_input`.
- *Why the app-command exclusion matters (DA finding):* without it,
`: save as foo` (an app command via the one-shot) would store `: save
as foo` in the ring but journal `save as foo` (app commands journal
simple at their own sites, §5) — the very in-session-vs-cross-session
divergence #30 is fixing, re-introduced for app commands. Excluding
them keeps ring and disk agreeing (both bare).
- **`history_back` / `history_forward`**: after cloning the stored entry
into `self.input`, strip a leading `:` **iff `self.mode == Advanced`**
(so an advanced entry runs as bare SQL in advanced mode, and as `: …`
one-shot in simple mode). A small helper `recall_display(stored)`.
- `seed_history` / `ProjectSwitched` payload: **unchanged** (`Vec<String>`);
hydration already returns the `: `-prefixed form (§3).
Recall matrix:
| entry \ current mode | Simple | Advanced |
|---|---|---|
| advanced (`: select 1`) | `: select 1` (one-shot) | `select 1` (SQL) |
| simple (`create …`) | `create …` | `create …` |
## 5. Move success journaling worker → dispatch layer
**Remove** (worker stops journaling success):
- `finalize_persistence` history write (db.rs:3096-3099). Keep yaml/csv.
The now-unused `source` param: remove it + drop the arg at its ~30
callers (mechanical, compiler-guided). (Handlers keep their own
`source` for `snapshot_then`.)
- The 4 no-op-skip `append_history` (db.rs:2267, 2311, 2524, 2560) — these
outcomes (`SchemaSkipped` etc.) are `Ok` at the dispatch layer, so the
new top-level journal covers them.
- The 3 read-only helper `append_history` (db.rs:8372 show table, 9996
show data, 10014 select) — `Ok(Query)`/`Ok(ShowList)` at the top.
**Add** (dispatch-layer journaling, all best-effort + logged):
- **`spawn_dsl_dispatch`** (runtime.rs ~1433): pass `project_path` in;
after `execute_command_typed`, `if outcome.is_ok() {
Persistence::new(path).append_history(&source_for_journal,
submission_mode.is_advanced()) }`. (Failures stay in the existing path,
§6 — no double-journal, since Ok and Err are exclusive.)
- **`run_replay`** (runtime.rs ~2540): after each line's
`execute_command_typed`, `if outcome.is_ok() { append_history(
&command_text, false) }` — replay is mode-agnostic, journalled
**simple**. (Preserves ADR-0034 §3 "replayed sub-commands land in
history"; a replayed advanced command re-journals without `:adv` — a
documented OOS, not a regression: today it re-journals as plain `ok`.)
- **`spawn_rebuild`** (runtime.rs ~503): after a successful rebuild,
`append_history("rebuild"/source, false)`. (Rebuild journalled via
`finalize_persistence` today; that write is gone, so add it here.)
**Unchanged** (already at the dispatch layer, app commands):
- `perform_switch` (974: save-as/load/new) and `spawn_export` (1043) —
already best-effort `append_history(&source)`; add the new `advanced`
arg as `false` (app commands run in any mode → no `:` needed on recall;
this also fixes the would-be "redundant `: undo`" — app commands
journal **simple** because they're dispatched here, never via
`ExecuteDsl`/the spawn).
- `undo`/`redo`/`copy`/`help`/`quit`: not journalled today; unchanged.
- The **`replay` command itself**: dispatched as `Action::Replay`, never
reaches the spawn → not journalled (preserves the ADR-0034 §3 exclusion
without extra work); nested `replay` skip in `run_replay` unchanged.
### DA-confirmed design choice: split, don't unify
Success journals in the spawn (`Ok` arm); **all** failures stay in the
existing App→`JournalFailure`→runtime path (just gaining the mode).
Considered and rejected: moving worker-rejection failures into the spawn
too (to "unify"). It doesn't actually unify — parse failures never reach
the spawn, so they'd stay in the App path regardless — and it adds a
double-journal hazard (must also strip the App's `DslFailed`
`JournalFailure` emission). The split keeps the failure path **untouched
in structure** (lowest risk); `Ok`/`Err` are exclusive so there is no
double-journal. **Verified safe:** undo/redo never touches `history.log`
(the snapshot copies db+yaml+csv only, undo.rs:15-16), and `snapshot_then`'s
redo-clear keys on `source.is_some()`, independent of journaling — so
removing the worker journal write does not perturb undo/snapshot at all.
## 6. Failure journaling — add the mode (location unchanged)
Keep both failure origins where they are (best-effort, dispatch/App
layer); thread the mode so they tag `err:adv`:
- **`Action::JournalFailure`** (action.rs:42): add `advanced: bool` (or
`submission_mode`).
- **`AppEvent::DslFailed`** (event.rs): add `submission_mode` (the
worker-rejection path — the App can't recover the mode from an async
reply otherwise).
- **App**: the parse-failure path (`dispatch_dsl` Err arm) has
`submission_mode` directly; the `DslFailed` handler reads it off the
event. Both emit `JournalFailure { source, advanced }`.
- **runtime.rs:492**: `append_history_failure(&source, advanced)`.
## 7. Tests
- **history.rs (Tier-1):** `status_token`/`parse_status` round-trip;
`read_recent_sources` reconstructs `": …"` for `:adv` and leaves
`ok`/`err` bare; `status_is_ok` true for `ok` & `ok:adv`; old-log
back-compat.
- **app.rs (Tier-1):** advanced submission stored `: `-prefixed; recall
prepends in simple / strips in advanced; simple bare in both; bare `:`
not stored; a parse-failure is still recallable; dedup/cap hold.
- **iteration6_resume_history (Tier-3) — headline regression:** journal
an advanced command (`append_history(text, true)`), hydrate, recall in
simple → `: …`; and the full bug repro through `submit` + journal +
hydrate if feasible.
- **replay_command (Tier-3):** replayed commands still land in
history.log (now via `run_replay`'s call); the `replay`-self-exclusion
+ nested-skip still hold; advanced lines replay (status `ok:adv`
treated as ok).
- **Journaling relocation:** a success no longer fatals on a journal
write failure (best-effort) — if cheaply testable; at minimum a worker
test that previously asserted worker-side journaling is updated/removed.
- **Update mechanical call sites:** `append_history(_, advanced)` /
`append_history_failure(_, advanced)` at the db.rs inline tests
(8372/9996/10014/11324 — likely now removed with the production sites),
iteration6 (144-170), mod.rs (600).
## 8. ADR work
- **ADR-0052 (new):** the #30 feature + bug, the status-tag format, the
`: `-prefixed ring + recall, AND the journaling relocation (it's the
enabling refactor). Forks: status-tag format; unified scope;
dispatch-layer journaling (best-effort).
- **ADR-0015 §6 amendment:** history.log out of the worker transaction;
commit-db-last now scopes yaml/csv/db; journal is a dispatch-layer
best-effort side-record.
- **ADR-0034 amendment:** journaling location (dispatch layer);
status-field `:adv` extension (it already reserved the field).
- **ADR-0040 amendment:** a success-path journal-write failure is no
longer fatal — best-effort, consistent with the failure path.
- README index upkeep for every ADR touched.
## 9. Risks / watch-list
- **Double-journaling**: ensure Ok→spawn and Err→App-path stay exclusive;
do NOT also leave a worker journal.
- **Under/over-journaling vs today**: top-level "journal on every Ok"
must match today's "journal every command with a source" — verified:
reads + skips are Ok outcomes, internal ops never reach the spawn.
- **finalize_persistence source-param removal**: 30 mechanical call-site
edits; compiler-guided.
- **Replay re-journal mode fidelity**: replayed advanced commands
re-journal as simple (OOS, not a regression).
- **best-effort journal**: rare write-failure leaves a command unjournaled
(logged). User decision (§2 open question).
- **app-command mode**: journalled simple by construction (dispatched
outside the spawn) — this is correct (they run in any mode), and
resolves the earlier "redundant `: undo`" worry.
@@ -0,0 +1,243 @@
# Plan — ADR-0053: contextual `hint` command + F1 keybinding (H2)
Implements ADR-0053. Closes the last open piece of **A1** (the canonical
app-command set) and requirements **H2**. No Gitea issue — this is
requirements-driven work; any genuine "later" item found en route gets
its own issue (cf. #36, already filed for the parallel `help`-side gap).
## 1. Goal
Give learners on-demand, **teaching-grade** contextual help — a *third*
tier beneath the existing terse always-on text (tier 1) and the
short contextual lines that are already shown (tier 2: the live ambient
prose, and the error `hint:` which is on by default since
`Verbosity::Verbose` is the default). Two surfaces:
- **F1** (read-only overlay) → a tier-3 block for the **live partial
input**, or — on empty input — for the **most recent runtime error**.
- **`hint`** (submitted app command) → the tier-3 block for the **most
recent runtime error** (the buffer is empty post-submit, so it can only
act on recent context).
The mechanism is small; the **content corpus is the feature** (~80
blocks, comprehensive for v1, authored exemplars-first per ADR-0053 D7).
## 2. The shape of the work (why this order)
The mechanism and the content are separable, and the mechanism should
land first with **graceful tier-2 fallback** so every surface works
before any tier-3 text exists. That lets us:
- build + test the trigger matrix / routing / `:`-strip / read-only-
overlay behaviour against a skeleton (TDD), then
- pour in content in reviewable batches without re-touching the wiring,
- and turn on the **comprehensiveness coverage test** only once the
corpus is complete (it is red until then — by design).
Build order: **Phase A** (mechanism skeleton, falls back to tier-2) →
**Phase B** (catalogue structure + the three approved exemplars) →
**Phase C** (comprehensive content, batched) → **Phase D** (polish:
strip advertisement, snapshots, full green).
## 3. Grammar: the `hint_ids` field + the `HINT` node
### 3a. New `CommandNode.hint_ids` (per-form — revised in Phase B)
- Add `pub hint_ids: &'static [&'static str]` to `CommandNode`
(`src/dsl/grammar/mod.rs:512`, beside `help_id` / `usage_ids`),
**mirroring `usage_ids`***not* a per-node `Option<&str>`. The Phase-B
exemplar (`add 1:n relationship`) showed per-*node* keying is too coarse:
`add`/`drop`/`show`/`create` are each one node spanning many forms, and
a live-input hint must be specific to the typed form. Compiler forces
every node literal (~37, across `grammar/app.rs`, `data.rs`, `ddl.rs`) to
set it — Phase A/B leave most `&[]` (tier-2 fallback); Phase C fills them.
**Multi-form nodes list ALL their form keys** (e.g. `add`
`["add_column", "add_relationship", "add_index", "add_constraint"]`) so
the form-word disambiguation resolves correctly and unauthored forms fall
back at render rather than mis-resolving to a sibling.
- **Lookup:** `hint_key_for_input_in_mode(source, mode)` returns the single
typed form's hint stem, reusing `pick_form_key` (factored out of
`usage_key_for_input_in_mode` — shared digit/`m:n`/suffix disambiguation).
- **Why a new field, not `help_id`** (ADR-0053 D3): `help_id` is `None` on
the 7 advanced-SQL forms purely to dedup the `help` *list*; those forms
have distinct SQL syntax and need their own block. `hint_ids` is per
form. (The parallel `help`-side gap is issue #36; clause-concept hints
are deferred — issue #37.)
### 3b. `AppCommand::Hint` + the `HINT` node
- `AppCommand::Hint` variant (no fields — no topic arg) in
`src/dsl/command.rs:544`.
- `pub static HINT: CommandNode` in `grammar/app.rs` mirroring `HELP` but
with **no topic shape** (bare keyword, like `UNDO`): `entry:
Word::keyword("hint")`, `shape: EMPTY_SEQ` (as `UNDO`,
`grammar/app.rs:333`), `ast_builder:
build_hint` (returns `Command::App(AppCommand::Hint)`), `help_id:
Some("app.hint")`, `hint_id: Some("app.hint")`, `usage_ids:
&["parse.usage.hint"]`.
- Register `(&app::HINT, CommandCategory::Simple)` in `REGISTRY`
(`grammar/mod.rs`), beside `HELP`. (App commands are available in both
modes via the existing mechanism.)
## 4. Command identification (live-input → node)
The F1 live-input path needs "which command form is being typed." **The
lookup machinery already exists** — do not rebuild entry matching:
- `command_for_entry_word(word) -> Option<(usize, &'static CommandNode)>`
(`grammar/mod.rs:811`) returns the matched node for an entry word
(Simple-first; the caller extracts the first word of the input).
- `usage_keys_for_input_in_mode(source, mode)` (`grammar/mod.rs:564`)
already performs the **mode-aware** Simple/Advanced selection the hint
path needs (advanced `create` → the SQL nodes, simple → the DSL node) —
it just returns `usage_ids` rather than the node.
- **The only new bit:** a thin `hint_id_for_input_in_mode(source, mode)`
(or a node-returning sibling of `usage_keys_for_input_in_mode`) that
applies the same mode selection and returns the chosen node's
`hint_id`. Mirror the existing function; don't duplicate its matching.
- **`:`-strip:** in Simple mode, strip a leading `:` (one-shot escape,
ADR-0003) before identification so `: SELECT …` resolves to the
advanced `SELECT` node.
- No match (empty / unrecognised entry word) → the "getting started"
pointer (D2).
## 5. F1 keybinding (read-only overlay)
In `App::handle_key` (`src/app.rs:1155`):
- Add an F1 arm (`KeyCode::F(1)`) **after** the modal gate and the
sidebar-nav gate (inert there, per D2), and **before** the
"any other key clears the completion memo" fall-through (`_ =>
self.last_completion = None`, ~line 1228) — F1 must **not** clear the
memo or touch the buffer/cursor (D1).
- Behaviour (the trigger matrix, D2):
- non-empty input → `note_hint_for_input()` (the command's `hint.cmd`
block + the live "Next:" expected-set from the walker).
- empty input + `last_error_hint_key` set → `note_hint_for_error()`.
- empty input + no recent error → `note_getting_started()`.
- Returns `Vec::new()` (pure output emission, like `help`).
- `demo_badge_label` (`app.rs:520`) gains an `F1 → "[F1]"` entry so demo
mode surfaces it (ADR-0047).
## 6. The two error routes (D2 / D5)
- **Runtime errors:** add `last_error_hint_key: Option<String>` to `App`.
Set it where friendly errors are rendered (`runtime.rs:2615`,
`app.rs:2424`) from the error's class key; clear on the next successful
command. The `hint` command and empty-input F1 read it.
- **Pre-submit diagnostics:** the F1 live-input path, when the input
carries an under-cursor diagnostic, reads it straight from the walker
(`input_diagnostics_in_mode`, the same source the ambient panel uses)
and renders that diagnostic's `hint.err.<class>` block instead of (or
alongside) the command block. No stored state.
- Both render from `hint.err.*`.
## 7. Rendering: the `note_hint*` family (D4)
- New `App::note_hint_for_input`, `note_hint_for_error`,
`note_getting_started` (siblings of `note_help`/`note_help_topic`,
`app.rs:2982`/`3021`).
- A tier-3 block is **structured** (`what` / `example` / `concept`, plus
the live `Next:` line on the input path). The catalogue stores each part
under sub-keys (`hint.cmd.<id>.what`, `.example`, `.concept`); the
renderer fetches each via `t!` and lays them out as a small framed
block.
- Styling: `OutputKind::System`; `OutputStyleClass::Hint` (muted) on
`what`/`concept`/`Next`, `Neutral` on `example` so the runnable line
stands out. Reuse `OutputLine::styled` + `push_category_three_prose`
patterns (`app.rs:3121`).
- **Fallback:** if a node's `hint_id` is `None` or a key is missing,
degrade to tier-2 (ambient prose for the input path; the verbose error
`hint:` for the error path) — never blank.
## 8. Catalogue + `keys.rs`
- New sub-namespaces under the existing top-level `hint:` in
`src/friendly/strings/en-US.yaml`: `hint.cmd.<hint_id>.{what,example,
concept}` and `hint.err.<class>.{what,example,concept}`.
- Register every key + its placeholders in `src/friendly/keys.rs`
(`KEYS_AND_PLACEHOLDERS`) so the build-time validation covers them.
- `parse.usage.hint` + `help.app.hint` strings for the command itself.
## 9. Content (Phase C — the bulk, batched per D7)
Exemplars approved in the ADR (`insert` live-input, FK child-side error,
`add relationship`) are the template. Author in reviewable batches:
1. **App commands** (~16): save/save as/load/new/rebuild/export/import/
replay/undo/redo/mode/messages/copy/help/hint/quit.
2. **DDL** (simple): create table, create m:n, add column/relationship/
index, drop, rename, change column.
3. **DML** (simple): insert, update, delete, show, seed, explain,
select/with.
4. **Advanced-mode SQL forms** (7): SQL CREATE TABLE, ALTER TABLE,
CREATE/DROP INDEX, DROP TABLE, SQL INSERT/UPDATE/DELETE, EXPLAIN SQL —
**own blocks, SQL-syntax examples**.
5. **Runtime error classes** (9): unique, foreign_key ×{child,parent},
not_null, check, type_mismatch, not_found, already_exists, generic,
invalid_value.
6. **`diagnostic.*` classes** (~33): arity/type/unknown-table-column/etc.
Each block: `what` (12 sentences), `example` (one runnable line,
mode-correct), `concept` (the relational idea — the teaching part;
optional only where genuinely none, e.g. `quit`).
## 10. Tests
Written test-first against the Phase-A skeleton where possible.
- **Tier 1 (unit, `app.rs`):**
- trigger matrix: F1 non-empty → command block; F1 empty + recent error
→ error block; F1 empty + none → getting-started; `hint` command +
error → error block; `hint` + none → getting-started.
- `last_error_hint_key` set on a failing command, cleared on the next
success.
- routing: a pre-submit diagnostic on the input drives the diagnostic
`hint.err`; a runtime error drives the stored-key route.
- `:`-strip: `: SELECT …` in Simple mode resolves to the advanced node.
- **read-only overlay:** F1 leaves `input`, `input_cursor`, and
`last_completion` unchanged.
- tier-2 fallback when `hint_id`/key absent.
- **Tier 2 (`insta`):** snapshot a representative rendered tier-3 block
(the `insert` exemplar) so the framed layout + styling spans are locked.
- **Tier 3 (integration, `tests/it/`):** type a partial command → F1 →
block appears, buffer untouched; run a failing insert → `hint` → FK
error expansion.
- **Comprehensiveness coverage test** (enforces D6, the key one): iterate
`REGISTRY` and assert every node has a `hint_id` resolving to a
`hint.cmd.*` block; assert every runtime-error + `diagnostic.*` class
has a `hint.err.*` block. **Red until Phase C completes** — enable
(un-`ignore`) as the final gate.
- `keys.rs` validation continues to guarantee every *referenced* key
resolves.
## 11. Keybinding strip + discoverability (Phase D)
- The ADR-0051 bottom strip advertises **F1 = hint** in the editing/
typing state (and on the empty-input state, since F1 still does
something there). Re-accept the affected full-panel snapshots.
## 12. ADR / docs
- ADR-0053 is committed (`e16ad50`). On completion, flip its Status from
"implementation pending" to implemented (with date), and update the
README index entry + `requirements.md` **H2 → [x]** and **A1 → [x]**
(A1 closes when `hint` lands).
## 13. Risks / watch-list
- **Command-identification reuse.** The lookup exists
(`command_for_entry_word` + the mode-aware `usage_keys_for_input_in_mode`,
`grammar/mod.rs:811`/`564`); the only new code is a thin node/`hint_id`
variant that reuses their selection. Do **not** re-implement entry-word
matching — mirror the existing functions.
- **Structured-key ergonomics.** Three sub-keys per block × ~80 blocks is
~240 catalogue keys; keep the `keys.rs` registration generation tidy
(consider a helper that registers the `{what,example,concept}` triple
for an id).
- **Content voice drift across batches.** Re-check each batch against the
approved exemplars; the `concept` line is where drift (too terse / too
advanced) creeps in. Pedagogy wins ties.
- **F1 terminal capture.** A few terminals intercept F1; acceptable
(it's the convention) but note it if testing surfaces it.
- **Snapshot churn.** The strip change re-accepts ADR-0051 snapshots;
keep that diff isolated.
- **Coverage-test timing.** It is red through Phases AC; gate it so CI
isn't broken mid-stream (e.g. `#[ignore]` until the final batch), then
make passing it the completion criterion.
```
+193
View File
@@ -0,0 +1,193 @@
# Plan — road to public availability (versioning + install + packaging)
Status: **planning** (2026-06-16). Captures the decisions taken in
discussion plus the open questions, so the work can proceed in steps.
The versioning piece is being implemented first and is recorded as a
decision in **ADR-0054**; the install-script and package-manager pieces
are roadmap items here until each is built.
Scope note: this repo lives on the self-hosted Gitea at
`git.lazyeval.net` (`oli/rdbms-playground`); releases publish there
(ADR-ci-001/003). Publication branding uses the company name **Lazy
Evaluation Ltd → `lazyeval`**, not the personal `oli` username, for
anything user-facing (taps, buckets).
---
## 1. Versioning + version surfaces — DECIDED (→ ADR-0054, building now)
- **`Cargo.toml` `version` is the single source of truth.** `--version`
reads `env!("CARGO_PKG_VERSION")` (compile-time, no deps).
- **Two surfaces:** a `--version` / `-V` CLI flag (prints + exits, like
`--help`), and an in-app **`version`** app command.
- **CI discipline:** `release.yaml` gains a guard that asserts the pushed
tag `vX.Y.Z` equals `CARGO_PKG_VERSION`, and **fails the release on
mismatch** — so the binary's self-reported version, the release name,
and the downloaded asset always agree.
- **Release ritual:** bump `Cargo.toml` → commit → tag `v<that number>`
→ push tag. (The guard enforces it.)
- Optional enrichment (not decided): a `build.rs` baking a git short-hash
+ build date so non-tagged dev builds read `0.2.0 (a1b2c3d)`. Good for
bug reports; can be added later.
---
## 2. `install.sh` (curl | sh) — DONE 2026-06-17 (ADR-0055)
**Shipped** `scripts/install.sh` (POSIX sh, shellcheck-clean). Verified
end-to-end against the live public `v0.1.0` release: platform mappings
(Linux/macOS × x86_64/aarch64; Windows + unknown arch error cleanly),
pinned (`RDBMS_VERSION`) and latest (`releases/latest`) paths, SHA-256
verification (incl. a tamper-rejection check), install to
`~/.local/bin`, PATH hint. **`install.ps1` (Windows) added 2026-06-17**
(user chose both a one-liner *and* Scoop/winget; ADR-0055 Amendment 1):
`irm | iex`, maps host CPU → our `*-windows-gnu`/`-gnullvm` `.exe`,
SHA-256-verifies, installs to `%LOCALAPPDATA%\Programs\…` + user PATH —
**written but untested from this env** (no PowerShell; validate on
Windows). The website copy that references these commands is the
**website branch's** job (separate agent), later.
A **shellcheck CI gate** for `scripts/` is a recommended follow-up (not
added — shellcheck isn't in the flake yet; touches ADR-ci-002).
Original decided shape (for reference):
- **Hosted from the Gitea repo URL** on `git.lazyeval.net` (simplest):
`curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh`
(exact raw-URL form to confirm against the Gitea version).
- **Behaviour:** POSIX `sh`; detect `uname` OS+arch → target triple
(Linux → the **musl static** build, our universal Linux artifact);
query the latest release via the Gitea API
(`/repos/oli/rdbms-playground/releases/latest`) → tag → deterministic
asset name `rdbms-playground-<tag>-<target>`; download + **verify the
`.sha256`**; install to `~/.local/bin` (fallback `/usr/local/bin` with
a sudo prompt); `chmod +x`; print a PATH hint if needed.
- **macOS:** binaries are signed (see §4 signing note); a `curl`
download does **not** apply the Gatekeeper quarantine xattr, so the
installed binary runs without `xattr` faff.
- **Windows:** not `curl | sh` — provide a PowerShell `install.ps1`
(`irm … | iex`) and/or steer to Scoop/winget (§3).
- **Security posture:** HTTPS only; in-script checksum verification;
document the download-inspect-run alternative (`curl|sh` is a trust
tradeoff).
- **Deliverables we own now:** `scripts/install.sh` (+ later
`install.ps1`); confirm releases are **publicly downloadable**; decide
whether to also upload `install.sh` as a release asset for a stable
link. Website copy referencing the command is the **website branch's**
job (separate agent), later.
---
## 3. D3 — package managers (roadmap; each layers on the release assets)
Common thread: a manifest pointing at our checksummed assets + a
per-release step to bump it. Ordered cheapest → most gatekept.
### 3a. `cargo binstall` + crates.io — PREPARED 2026-06-17 (ADR-0056)
**Done (prep):** crate made publish-ready (dropped `publish = false`;
added `homepage`/`keywords`/`categories`/`exclude`; authored `README.md`
+ `LICENSE-MIT`); `[package.metadata.binstall]` added with per-target
overrides (linux-gnu→musl, windows-msvc→gnu/gnullvm; macOS direct);
`cargo publish --dry-run` clean (913 KiB compressed). Dual license kept;
`LICENSE-MIT` + `LICENSE-APACHE` (© Lazy Evaluation Ltd) + `CONTRIBUTING.md`
(inbound=outbound) all in place. **Gated / remaining:** the actual `cargo
publish` (token, irreversible) at a **new tagged release** (not 0.1.0); a
real `cargo binstall` validation.
- **Bootstrapping matters (user-flagged):** `binstall` is **not** a
built-in cargo subcommand — users must install **`cargo-binstall`**
first (its own `curl|sh`/PowerShell installer, or
`cargo install cargo-binstall`). **Our instructions must say this.**
- Add `[package.metadata.binstall]` to `Cargo.toml` (pkg-url template →
our Gitea release assets; our naming already fits).
- **DECIDED (2026-06-17): publish to crates.io** — so the frictionless
`cargo binstall rdbms-playground` resolves the crate, and the project
is discoverable there. (A crates.io publish is its own small task:
metadata completeness — description/license/repository/keywords/readme
— and `cargo publish`; the `[package.metadata.binstall]` URL template
points binstall at our Gitea release assets.) *(Verify current
cargo-binstall behaviour when wiring.)*
### 3b. Scoop (Windows)
- A **bucket** repo under `lazyeval` on Gitea with a JSON manifest
(`.exe` URL + hash + `autoupdate`). Release job commits the bump.
- Users: `scoop bucket add lazyeval <gitea-url>; scoop install rdbms-playground`.
### 3c. Homebrew (macOS/Linux)
- A **company-branded tap**`lazyeval/homebrew-tap` (on Gitea) — with a
Ruby formula (per-arch `url` + `sha256`). Release job commits the bump.
- Users: `brew tap lazyeval/tap https://git.lazyeval.net/lazyeval/homebrew-tap`
then `brew install lazyeval/tap/rdbms-playground` (the explicit-URL tap
form, since the `user/repo` shorthand assumes GitHub).
- *"Notability bars"* = the acceptance criteria for the default
**homebrew-core** tap (must be sufficiently popular/maintained). Our
own `lazyeval` tap sidesteps that entirely — no review gate.
### 3d. winget (Windows)
- Manifests are YAML PR'd to `microsoft/winget-pkgs` (reviewed by MS).
- **`wingetcreate` is Windows-only** (.NET) — no good without a Windows
runner. **Automatic path to evaluate first: `komac`** — a
cross-platform (Rust) winget manifest creator/submitter that runs on
our **Linux** CI. *(Verify komac's current capabilities/auth model.)*
- **Fallback:** a manual YAML PR per release — acceptable given releases
are infrequent (user-confirmed).
### Cross-cutting (3a3d)
- Two extra repos (tap + bucket) under `lazyeval`, with CI push
credentials — setup TBD (user: "we'll figure that out").
- **`cargo-dist`/"dist"** automates installers + Homebrew + CI, but is
**GitHub-Actions/Releases-centric**; on self-hosted Gitea it won't drop
in cleanly (installer-script generation might be reusable). Likely
hand-roll the manifests + a small "update on release" job instead.
*(Verify cargo-dist's current Gitea support before fully ruling out.)*
---
## Open decisions
1. **crates.io:** **RESOLVED 2026-06-17 — yes, publish.** (See §3a.)
2. **Tracking:** **RESOLVED 2026-06-17 — doc + ADR only, no Gitea
issues.**
3. **Release downloads public:** **CONFIRMED 2026-06-17** — the Gitea
releases are publicly downloadable (no auth); `install.sh` relies on
it and was verified against the live `v0.1.0`.
### Still open / postponed
- **macOS signing — CONFIRMED BUG (2026-06-16), POSTPONED by the user
(2026-06-17)** pending the correct signing ID. Details:
- `release-macos.yaml` does `codesign --force --sign -` (ad-hoc) and has
**no signing scaffolding at all** (no keychain import, no secrets) —
so a downloaded binary is *not* properly signed (user-verified).
- **The credential the user has is the wrong type:** `Apple Development:
Oliver Sturm (W687M898E4)` is a *development* cert (Gatekeeper won't
trust it for distribution). Distribution needs a **`Developer ID
Application`** cert (same format, different type). Signing under the
company name *"Lazy Evaluation Ltd"* would need an **Organization**
Apple Developer account; a personal account signs as "Oliver Sturm".
- **Notarization** (required with Developer ID for non-quarantined trust
on browser downloads): after signing, `xcrun notarytool submit`. Creds
= an **App Store Connect API key** (Issuer ID + Key ID + `.p8`,
recommended for CI) *or* Apple ID + app-specific password + Team ID.
A bare CLI binary can't be *stapled* (only bundles/dmg/pkg) — Gatekeeper
does an online check instead.
- **Urgency caveat:** the `curl|sh` path doesn't need any of this (curl
downloads aren't quarantined); signing matters for browser downloads
from the releases page. Fix when the right cert + creds exist; corrects
the ad-hoc docs once landed.
## Sequencing
1. ✅ **Version discipline** (ADR-0054) — `--version`/`-V` + `version`
command + CI tag-match guard + tests.
2. ✅ **`scripts/install.sh`** (ADR-0055) — built + verified against the
live public release.
3. Package managers, cheapest first:
- ✅ **`cargo binstall` + crates.io** — *prepared* (ADR-0056);
publish gated on a new tagged release + the token.
- **← next:** Scoop (`lazyeval` bucket) → Homebrew (`lazyeval` tap) →
winget (komac / manual). Two `lazyeval` repos (tap + bucket) + CI
push creds to set up.
4. **Cut a release at a new version** — bump `Cargo.toml` (0.1.0 →
0.1.1/0.2.0; the ADR-0054 guard checks the tag), tag, push; the four
Linux/Windows targets build immediately. (macOS leg awaits signing.)
+59 -12
View File
@@ -61,11 +61,35 @@ 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, and both
macOS targets (the `release-macos.yaml` dispatch — now triggerable
since CI is on `main` — was run end-to-end and the binaries launch);
Linux aarch64 + Windows x86_64 remain link-clean / valid-format
only.)*
- [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-<tag>-<target>` is already binstall-friendly.
Tracked under ADR-ci-003 "Deferred".)*
## TUI shell
@@ -250,16 +274,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 +814,21 @@ 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.<form>` / `hint.err.<class>` 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). **Content verified 2026-06-15 (handoff-71):**
a semantic pass over every `hint.cmd.*`/`hint.err.*` block fixed four
errors — `create_table` (compound-PK misread), `save` (no inline name),
`import` (hyphen-rejecting target), and `foreign_key.child_side` (wrong
`on delete` remedy) — and added a catalogue-driven guard test that parses
every command example in its taught mode.)*
- [x] **H3** `help` provides general reference and per-command
help.
*(Done 2026-06-07: the **general reference** is `help` (no arg) —
@@ -857,7 +891,10 @@ since ADR-0027.)
exists (~55 lines, covers the WHERE-expression and
table-creation boundaries). **Missing:** a DSL
command-surface reference and a standalone type-system
reference under `docs/`.)*
reference under `docs/`. **Note (2026-06-16):** the **canonical**
user docs now live on the **website** (ADR-website-001, deployed) —
it covers the full feature set; the in-repo `docs/` reference pieces
named here remain the outstanding part of DOC1.)*
## Testing (per ADR-0008)
@@ -878,8 +915,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 13) 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
@@ -0,0 +1,146 @@
# ADR-website-001: Public website and documentation site
## Status
Accepted (2026-06-04). Implementation plan:
[`docs/website/plans/20260604-website-implementation-plan.md`](../plans/20260604-website-implementation-plan.md).
> History: drafted as ADR-0042, renamed to ADR-0044 (each collided with a
> number `main` had independently assigned — H1a took 0042, compound-PK FK
> took 0043, then relationship-visualization took 0044). Moved to the
> website ADR namespace (`docs/website/adr/`, id **ADR-website-001**) on
> 2026-06-10 to end the recurring cross-branch number collision: website
> decision records now draw from their own dated sequence and never the
> main global ADR pool (see ADR-0000 "Numbering discipline"). Content is
> unchanged from the original draft.
## Context
RDBMS Playground is nearing its first public release and needs a public
website that does two jobs: **attract** — a landing page that shows the
playground in action — and **document** — a thorough, canonical reference
for everything the playground can do.
The documentation-heavy surface is already implemented and verified (full
simple- and advanced-mode command set, the ten-type vocabulary,
relationships, constraints, indexes, EXPLAIN, undo/history/replay, projects,
export/import, the teaching echo, clipboard, friendly errors, tab completion
and syntax highlighting; 2151 tests passing at the time of writing). The
site is therefore largely a presentation-and-writing effort, not a
wait-for-features one. A grounded inventory of what is shippable now lives
in the implementation plan.
Several choices here had no canonical default; they were settled during a
planning + `/runda` pass with the user and are recorded below.
## Decision
1. **Stack — Astro 6 + Starlight + Tailwind v4.** Astro's content-first,
zero-JS-by-default model with the Starlight docs theme fits a
marketing-landing-plus-heavy-docs site better than the alternative
considered, SvelteKit + Tailwind (the usual go-to here). Interactive
pieces are added as Astro islands (Svelte or vanilla), so Svelte is still
available where it earns its place. Tailwind v4 is wired via the official
`@tailwindcss/vite` plugin; the `@astrojs/starlight-tailwind` plugin
bridges Tailwind with Starlight's theming.
2. **Demo medium — asciinema.** Showcase sequences are recorded as
asciinema `.cast` files (text-based, small, faithful to the full
alternate-screen render) and embedded with `asciinema-player`. The same
casts are reused inline in the docs — one recording format serves both
the landing page and documentation enrichment. Recordings are produced by
a **scripted-input driver** that types commands into a real PTY with
viewer-friendly pacing; the app's own `history.log` **replay** (ADR-0034)
re-executes commands without typing animation or pacing and is therefore
suitable only for state-deterministic docs snippets, not the hero demo.
3. **In-page WASM playground — deferred** (OOS: **deferred**, not rejected).
A live, type-it-yourself playground compiled from the Rust app to
WebAssembly is desirable but is a multi-week sub-project, so it does not
block the site. The demo section is designed with a stable seam (a single
`Demo` component contract) so a WASM playground island can replace the
asciinema player later with no change to call sites. Recorded boundary
for that future work:
- **Portable core (runs on `wasm32-unknown-unknown` largely as-is):**
`src/dsl/*` (parser, types, grammar, walker), the pure `App::update()`,
`ui.rs`, `theme.rs`, `friendly/*`, output rendering; an in-memory DB
path already exists (`Connection::open_in_memory()`). `rusqlite`
compiles to the browser target via its `ffi-sqlite-wasm-rs` feature.
- **Native edge needing `cfg`-gated browser replacements:** the
multi-thread Tokio runtime + the dedicated DB **worker thread**
(ADR-0010) → current-thread/in-line async; `crossterm` terminal +
event-stream → a browser backend (e.g. Ratzilla's DOM/Canvas) + DOM
events; `arboard`, `zip`, file persistence (ADR-0015), file logging;
and the rusqlite **backup-API** undo (ADR-0006) → a SQL dump/restore.
When taken up, this becomes its own ADR + iteration plan.
4. **Hosting — portable static build; Cloudflare is the target (decided
2026-06-11).** Astro 6 builds to static HTML/CSS with no adapter, so the
output deploys equally to Cloudflare, Vercel, Netlify, or GitHub Pages — we
stay uncoupled from any one host. **Planned pipeline: Gitea Actions →
Cloudflare.** Cloudflare now steers new projects to **Workers (static
assets)** over Pages; either serves the static `dist/` and needs no Astro
adapter (the `@astrojs/cloudflare` adapter is only for SSR, which the site
does not use). The future in-page WASM playground (§3), if it needs
COOP/COEP headers, can get them from Cloudflare `_headers`. **CI implemented
2026-06-15** (`.gitea/workflows/website.yaml`): a push touching `website/**`
builds the static site with pnpm and deploys `dist/` to the Cloudflare Pages
project **`relplay`** via `wrangler` (Direct Upload — no Git integration).
The `--branch` label selects environment against the project's production
branch (`main`): **`main` → production (`relplay.org`)**, **`website`
preview (`website.relplay.pages.dev`)**, with `staging.relplay.org` attachable
to the `website` branch alias. The crate's CI gate (`ci.yaml`) skips
website-only pushes; the build is pure-Node (the `.cast` files are committed,
so no cargo). Secrets: `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID`.
5. **Repo topology — monorepo.** The site lives under `website/` in the
playground repo; the crate stays at the repo root. The repo as a whole
moves to its public home later; site and crate travel together.
6. **Canonical docs home — the website.** User-facing documentation lives on
the site. In-repo `docs/` keeps ADRs, handoffs, and development notes;
`docs/simple-mode-limitations.md` (requirement DOC1) was a development aid
and now *feeds* the site's content rather than competing with it. The
sharing recipes promised by requirement E2 become a docs page.
7. **Documentation scope and conventions.** Document the **full supported
feature set**. Any capability not yet fully implemented (a small minority
— e.g. multi-line input, query cancellation, `seed`, `m:n` convenience,
ER-diagram export, the `show tables`/`relationships`/`indexes` family) is
either omitted or carries a clear **"planned / not yet available"**
callout — never presented as shipped. Two wording rules bind all
user-facing copy:
- **No engine name** (SQLite/STRICT/rusqlite/PRAGMA) — continues the
user-facing posture of ADR-0002; copy says "the database"/"the engine".
- **No "DSL"** — it is internal jargon. The two input modes are **simple
mode** (the playground's keyword command language) and **advanced
mode** (SQL).
8. **Install documentation — two mechanisms.** The install page documents
**prebuilt release binaries** (self-hosted download — not GitHub
Releases, since the repo will move) and **package managers**. Both can be
written now against the planned mechanisms; concrete download URLs slot in
at release. (Distribution items D1D3 in `requirements.md` remain the
tracking home for the release tooling itself.)
## Consequences
- The site can ship on the strength of already-implemented features; it is
gated on writing and recording, not on finishing the app.
- One recording format (asciinema `.cast`) serves both marketing and docs,
and is reusable as the app evolves (re-run the script, re-record).
- The WASM playground is preserved as a real future option without holding
up launch; the demo seam keeps the upgrade cheap.
- A single canonical docs home removes the divergence risk of maintaining
user docs in two places.
- Website build choices (Decisions 1, 2, 4, 5) are recorded here for
traceability but do not, by themselves, warrant further ADRs; only
app-architecture decisions (notably the future WASM port) will.
## Out of scope
- **In-page WASM playground***deferred* (see Decision 3); revisit as its
own ADR + iteration plan.
- **Hosted/SaaS playground or a server-backed doc CMS***rejected*: a
static site fully satisfies the need, consistent with ADR-0007's
no-hosted-publishing stance. Revisit only if real demand emerges.
+19
View File
@@ -0,0 +1,19 @@
# Website Architecture Decision Records
Decision records for the **public website + documentation site** subproject
(the Astro/Starlight site under `website/`). These are kept in their own
namespace, separate from the project-wide ADRs in
[`docs/adr/`](../../adr/README.md), so website decisions never compete with
the main global ADR sequence for numbers — see
[ADR-0000 "Numbering discipline"](../../adr/0000-record-architecture-decisions.md).
**Numbering.** Files are named `<date>-adr-website-<NNN>.md` and referenced
in prose as `ADR-website-NNN`. The `<date>` (the ADR's accepted/created day,
`YYYYMMDD`) plus the `website` 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-website-001 — Public website and documentation site](20260604-adr-website-001.md) — **Accepted 2026-06-04** (formerly ADR-0044 in the main index; moved here 2026-06-10 to end recurring cross-branch number collisions). The first public website: a marketing landing page plus the **canonical** user docs. Stack **Astro 6 + Starlight + Tailwind v4** (chosen over SvelteKit + Tailwind for a docs-heavy + marketing site; interactive bits as Astro islands). Showcase demos are **asciinema** `.cast` recordings (scripted-input driver for paced, re-recordable sessions — *not* `history.log` replay), reused inline in docs. The **in-page WASM playground is deferred** (OOS: deferred) behind a stable `Demo` seam, with the portable-core vs native-edge boundary recorded for a future ADR + iteration plan. Portable **static build** (**Cloudflare** target via **Gitea Actions**, host-agnostic); **monorepo** (`website/`). **§4 update (CI implemented):** the static→Cloudflare Pages deploy now runs via Gitea Actions (`.gitea/workflows/website.yaml`; the crate gate is skipped for website-only changes); both `website` and `main` are merged and the site is **deployed**. Docs cover the **full supported feature set** with "planned" callouts for the unshipped minority; two wording rules bind user-facing copy — **no engine name** (continues ADR-0002) and **no "DSL"** ("simple mode" / "advanced mode"). Install docs cover **prebuilt binaries + package managers** (D1D3 track the release tooling). Plan: [`docs/website/plans/20260604-website-implementation-plan.md`](../plans/20260604-website-implementation-plan.md)
@@ -0,0 +1,198 @@
# Plan: public website and documentation site
**Date:** 2026-06-04 · **Status:** ready to build
Decisions for this work are recorded in
[ADR-website-001](../adr/20260604-adr-website-001.md): Astro 6 +
Starlight + Tailwind v4; asciinema demos reusable in docs; the in-page WASM
playground deferred behind a stable demo seam; portable static hosting
(Vercel target); monorepo (`website/`); website is the canonical docs home;
full-feature-set docs with "planned" callouts; install docs cover prebuilt
binaries + package managers. This plan is the *how*.
## Repository layout
The site lives under `website/` in this repo; the crate stays at the root.
```
website/
├── package.json # pnpm; astro, @astrojs/starlight, tailwind v4
├── astro.config.mjs # Starlight integration + sidebar nav
├── src/
│ ├── pages/index.astro # marketing landing (custom, not Starlight)
│ ├── components/
│ │ ├── Demo.astro # demo SLOT — the WASM-playground seam
│ │ └── Cast.astro # asciinema-player island wrapper
│ ├── content/docs/ # Starlight MDX docs (the bulk of the work)
│ └── styles/ # shared Tailwind + Starlight theme tokens
├── public/casts/ # recorded *.cast asciinema files
├── README.md # local dev + recording recipe
└── STYLE.md # living documentation style guide
```
Root `.gitignore` gains `website/node_modules`, `website/dist`,
`website/.astro`.
## Documentation inventory (grounded — drives Phase D scope)
Built from `docs/handoff/5559`, `docs/adr/*`, the command REGISTRY
(`src/dsl/grammar/mod.rs:603`, which also auto-assembles in-app `help`), the
`Command` enum (`src/dsl/command.rs:149`), and
`src/friendly/strings/en-US.yaml`**not** the coarse `requirements.md`
checkboxes (handoff-59 found those ~46% mis-marked; they now use a `[/]`
"partial" legend — trust the code, not the marker). Refreshed **2026-06-09
after merging `main`**, which added the show-list/detail, `help <command>`,
and compound-PK FK surface (see the dedicated bullet below). Test state:
**2193 passing, 0 failing, 1 ignored.**
**SHIPPED — document as-is (the doc core):**
- Input modes: simple, advanced (SQL), `:` one-shot escape, `mode` command,
per-project mode restore (ADR-0003/0015/0037).
- Full simple-mode command surface: create/drop table; add/drop/rename/
change column; add/drop 1:n relationship (named, ON DELETE/UPDATE
CASCADE/SET NULL/RESTRICT, `--create-fk`); add/drop index; insert/update/
delete (required WHERE + `--all-rows`; complex WHERE: AND/OR/NOT, LIKE,
IS NULL, IN, BETWEEN); show table/data (where/limit); add/drop constraint;
explain (ADR-0009/0013/0014/0025/0026/0028/0029).
- Full advanced-mode SQL: CREATE/DROP/ALTER TABLE (cols, constraints, inline
+ table FKs, rename, alter-column-type), CREATE [UNIQUE]/DROP INDEX; SELECT
(joins, GROUP BY/HAVING, ORDER BY, LIMIT/OFFSET, UNION/INTERSECT/EXCEPT,
WITH [RECURSIVE] CTEs); INSERT (multi-row, ON CONFLICT, RETURNING)/UPDATE/
DELETE; full expression grammar incl. CASE, CAST, curated functions;
EXPLAIN over SQL (ADR-00300039).
- Types: all ten, advanced-mode SQL aliases, serial/shortid auto-fill
(ADR-0005/0011/0017/0018). Constraints: PK incl. compound, NOT NULL,
UNIQUE, CHECK, DEFAULT, FK (ADR-0029/0013/0035).
- Undo/redo, history.log journal, replay, `--resume`, `--no-undo`
(ADR-0006/0034). Projects & storage: project.yaml + CSV + history.log,
save/save as/load/new/rebuild, temp projects, `--data-dir`
(ADR-0004/0015). Export/import (zip), clipboard copy/copy all/copy last
(ADR-0007/0041).
- Friendly errors (all five categories) + validity indicator
(ADR-0019/0027), DSL→SQL teaching echo (ADR-0038), EXPLAIN plan tree
(ADR-0028), box-drawing tables (ADR-0016), tab completion + syntax
highlighting + in-line editing (ADR-0022).
- **Added by the `main` merge (2026-06-09):** schema-inspection commands
`show tables` / `show relationships` / `show indexes` and the singular
`show relationship <name>` / `show index <name>` detail views (V5/V5a);
`help [<command>]` per-command detail + `help types` + general reference
(H3); **compound-primary-key foreign-key references** — DSL
`from <P>.(a, b) to <C>.(x, y)` and SQL `FOREIGN KEY (a, b) REFERENCES
P(x, y)` (single-column form unchanged) (ADR-0043, T3); friendlier
parse-error near-miss messaging (H1a, ADR-0042). These need coverage: a
schema-inspection page (the `show` family) and compound-FK examples on the
Relationships page.
**DOCUMENT WITH CAVEAT:** `add unique index` is advanced-only; simple-mode
table rename is intentionally absent (rename is `ALTER TABLE … RENAME TO`);
`hint` (H2) is still partial; a compound-FK *violation* message names only
the first column pair (enforcement is correct — a messaging-only residual).
**OMIT or MARK "planned":** multi-line input (I1), readline shortcuts (I1b),
in-flight cancellation / query timeout (I5/B3), `seed` (SD1), `m:n`
convenience (C4), one-step modify relationship (C3a), relationship line-art
(V1), ER-diagram export (V3), session-log + Markdown export (V4).
**Mine verbatim for docs:** `en-US.yaml` `help.app.*`, `help.ddl.*`,
`help.data.*`, `help.types_reference`, `parse.usage.*` (one-line syntax
templates), `hint.*` — keeps docs and in-app help consistent.
## Phases
### A — Scaffold
`pnpm create astro@latest` (Starlight template) in `website/`; `astro add
tailwind` (Tailwind v4 via `@tailwindcss/vite`); add
`@astrojs/starlight-tailwind`. Confirm `pnpm dev` serves and `pnpm build`
emits a static `dist/`. Echo build steps for traceability.
### B — Landing page
Custom `src/pages/index.astro` (Starlight owns `/docs/*`). Hero + value prop
("learn relational databases by doing"), feature highlights from the
inventory, an embedded demo cast above the fold. Use the `frontend-design`
skill to avoid generic AI aesthetics; honour NFR-4/5/7 (distinctive design,
meaningful colour, light/dark).
### C — asciinema recording workflow
Record real `rdbms-playground` sessions to `public/casts/*.cast` using a
**scripted-input driver** (e.g. `asciinema-automation`/autocast, or an
expect/doitlive script) for paced, re-recordable demos. Record at a fixed
sensible cols×rows; provide light + dark player themes. `Cast.astro` wraps
`asciinema-player` as a `client:visible` island; the same component embeds
casts inline in docs. Document the recipe in `website/README.md`.
(`asciinema` 2.4.0 is installed.)
### D — Documentation (the bulk)
**Five** top-level sidebar sections (autogenerated per directory). The key
split: *Using the playground* = the application you drive; *Reference* = the
database language you build with.
- **Getting started** — install (prebuilt binaries + package managers),
first project, simple vs. advanced mode, the example library.
- **Using the playground** — command-line options; the assistive editor
(completion, syntax highlighting, the `[ERR]`/`[WRN]` validity indicator,
hints, in-line editing); the output pane (PageUp/PageDown scrolling — the
fuller V4 session-log / Markdown export is *planned*, mark it); projects
(save / load / new / rebuild); undo, redo & history (+ replay); export &
import (E2 recipes); copy to clipboard; getting help (`help` /
`help <command>` / `hint`). (ADR-0003 "app-level commands" + ADR-0022/0027
typing assistance + the CLI.)
- **Guides** — task walkthroughs.
- **Reference** — the database language: Tables, Columns, Relationships,
Indexes, Constraints, Inserting & editing data, Querying & inspecting
(`show` / `select`), Types, Query plans (EXPLAIN), Errors explained, the
simple-command → SQL teaching echo.
- **Concepts** — the *why*: projects & storage model, the derived database,
how undo works.
**Surface the assistive editor prominently** — it is a differentiator and
most helps beginners: a landing-page card + a Getting-started mention, both
linking into *Using the playground*. It is prime asciinema-cast material
(completion / validity indicator are motion a still code block can't show).
Build order: Tier 1 simple-mode reference + types + constraints + input
modes + mined help/usage strings → Tier 2 advanced SQL + relationships +
project lifecycle + undo/history → Tier 3 teaching echo + EXPLAIN + errors +
completion/highlighting → Tier 4 clipboard + hints + editing.
Conventions live in the **living style guide** `website/STYLE.md` (binding
rules from ADR-website-001 §7 — no engine name, **no "DSL"**, "planned" callouts —
plus finer conventions and an open-decisions log for depth/splitting/example
dataset/etc. as they settle). Sources to mine: `src/dsl/command.rs`,
`src/dsl/grammar/*`, the REGISTRY, `en-US.yaml`, `docs/adr/*`,
`docs/simple-mode-limitations.md`.
### E — Hosting & portability
Keep the default static build (no adapter); `dist/` deploys to Vercel or any
static host. `website/README.md` notes the Vercel preset (root dir
`website/`) and the one-line `@astrojs/vercel` switch if SSR is ever needed.
## Demo seam (WASM hook)
`Demo.astro` exposes a stable contract (`{ src, title, height, autoplay }`).
At launch it renders `Cast.astro`; later a `Playground.astro` WASM island
swaps in behind the same props on the landing page and in docs, with zero
call-site changes. Boundary details are in ADR-website-001 §3.
## Verification
- `pnpm dev` renders landing + docs; `pnpm build` emits a clean static
`dist/` with no errors/warnings.
- Landing shows at least one playing `.cast`; the same component renders a
cast inline in a docs page (proves reuse).
- Starlight link-check passes (broken internal links fail the build).
- Docs grep clean of forbidden terms: **no "DSL"**, no engine name.
- A `dist/` static deploy works on Vercel (manual import) — confirms
portability. (No CI gate yet, per ADR-website-001 §4.)
## Notes / recommendations (non-blocking)
- **Doc drift:** consider generating the command reference from source (the
`help` REGISTRY / `en-US.yaml`) rather than hand-writing all of it.
- **Accessibility/SEO:** pair each hero `.cast` with a text transcript or the
equivalent docs snippet.
- **Branding/domain & analytics** unspecified — assume none until decided;
no third-party trackers without consent.
- Tailwind v4 + Starlight have occasional theme-token friction; the
`@astrojs/starlight-tailwind` plugin is the supported bridge.
- Starlight ships local search (Pagefind) by default.
- No `README.md` exists at the repo root yet — wanted for the destination
repo; out of this plan's core scope but flagged.
Generated
+82
View File
@@ -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
}
+98
View File
@@ -0,0 +1,98 @@
{
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 {
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).
# 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 ++ [
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
] ++ 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
];
shellHook = ''
echo "RDBMS Playground dev shell ($(uname -s))"
echo " rust: $(rustc --version | cut -d' ' -f1-2)"
echo " cargo: $(cargo --version | cut -d' ' -f1-2)"
'';
};
});
}
+26
View File
@@ -0,0 +1,26 @@
[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).
components = ["rustfmt", "clippy"]
# 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",
# 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",
]
+94
View File
@@ -0,0 +1,94 @@
<#
.SYNOPSIS
Download and install a prebuilt rdbms-playground binary (Windows).
.DESCRIPTION
The Windows counterpart of scripts/install.sh. Detects the CPU
architecture, downloads the matching release .exe from the Gitea
releases, verifies its SHA-256 checksum, installs it to
%LOCALAPPDATA%\Programs\rdbms-playground, and adds that directory to
your user PATH.
Quick start:
irm https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.ps1 | iex
We ship gnu / gnullvm Windows builds (x86_64 / aarch64); this maps the
host architecture to the right asset.
.PARAMETER Version
Install a specific tag (e.g. v0.2.0) instead of the latest release.
Defaults to $env:RDBMS_VERSION, else the latest release.
.PARAMETER InstallDir
Install directory. Defaults to $env:RDBMS_INSTALL_DIR, else
%LOCALAPPDATA%\Programs\rdbms-playground.
.NOTES
Written but NOT tested on Windows from this environment (no PowerShell
here) validate on a real Windows host. The verified sibling is
install.sh (Linux/macOS).
#>
[CmdletBinding()]
param(
[string]$Version = $env:RDBMS_VERSION,
[string]$InstallDir = $(if ($env:RDBMS_INSTALL_DIR) { $env:RDBMS_INSTALL_DIR } else { "$env:LOCALAPPDATA\Programs\rdbms-playground" })
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$Repo = 'https://git.lazyeval.net/oli/rdbms-playground'
$Api = 'https://git.lazyeval.net/api/v1/repos/oli/rdbms-playground'
$Bin = 'rdbms-playground'
# Map the host CPU to the target triple we publish for Windows.
$osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
switch ($osArch) {
'X64' { $target = 'x86_64-pc-windows-gnu' }
'Arm64' { $target = 'aarch64-pc-windows-gnullvm' }
default { throw "install: unsupported CPU architecture: $osArch" }
}
# Resolve the release tag (explicit -Version, else the latest release).
if (-not $Version) {
$Version = (Invoke-RestMethod -Uri "$Api/releases/latest").tag_name
if (-not $Version) { throw 'install: could not determine the latest release tag' }
}
$asset = "$Bin-$Version-$target.exe"
$url = "$Repo/releases/download/$Version/$asset"
$tmp = Join-Path $env:TEMP ([System.Guid]::NewGuid().ToString())
New-Item -ItemType Directory -Path $tmp -Force | Out-Null
try {
$exe = Join-Path $tmp "$Bin.exe"
$shaFile = "$exe.sha256"
Write-Host "downloading $asset ..."
Invoke-WebRequest -Uri $url -OutFile $exe
Invoke-WebRequest -Uri "$url.sha256" -OutFile $shaFile
# The sidecar is "<hash> <name>"; compare just the hash.
$expected = ((Get-Content -Raw $shaFile) -split '\s+')[0].ToLower()
$actual = (Get-FileHash -Algorithm SHA256 -Path $exe).Hash.ToLower()
if ($expected -ne $actual) {
throw "install: checksum mismatch (expected $expected, got $actual) — refusing to install"
}
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
$dest = Join-Path $InstallDir "$Bin.exe"
Move-Item -Path $exe -Destination $dest -Force
Write-Host "installed $Bin $Version -> $dest"
# Add the install dir to the user PATH (persistent) if it's not there.
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if (-not $userPath) { $userPath = '' }
if (($userPath -split ';') -notcontains $InstallDir) {
$newPath = if ($userPath) { "$userPath;$InstallDir" } else { $InstallDir }
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
Write-Host "added $InstallDir to your user PATH — restart your shell to pick it up"
}
}
finally {
Remove-Item -Path $tmp -Recurse -Force -ErrorAction SilentlyContinue
}
+166
View File
@@ -0,0 +1,166 @@
#!/bin/sh
# install.sh — download and install a prebuilt rdbms-playground binary.
#
# Quick start (Linux / macOS):
# curl -fsSL https://git.lazyeval.net/oli/rdbms-playground/raw/branch/main/scripts/install.sh | sh
#
# What it does: detects your OS + CPU, downloads the matching release binary
# from the Gitea releases (Linux uses the fully-static musl build), verifies
# its SHA-256 checksum, and installs it to ~/.local/bin.
#
# Environment overrides:
# RDBMS_VERSION install a specific tag (e.g. v0.2.0) instead of the latest
# RDBMS_INSTALL_DIR install directory (default: $HOME/.local/bin)
# RDBMS_OS force the OS (testing): Linux | Darwin
# RDBMS_ARCH force the CPU (testing): x86_64 | aarch64
#
# Flags:
# --print-target print the resolved target triple and exit (no download)
# -h, --help print this help and exit
#
# Notes:
# * Windows is not installable via this script (the binary is a .exe) —
# use Scoop/winget (planned) or download the .exe from the releases page.
# * macOS: a curl download is not quarantined by Gatekeeper, so the binary
# runs without extra steps. (Developer-ID signing + notarization is a
# separate, planned improvement for browser downloads.)
#
# POSIX sh — no bashisms, so it runs under the `sh` of `curl | sh`.
set -eu
REPO_BASE="https://git.lazyeval.net/oli/rdbms-playground"
API_BASE="https://git.lazyeval.net/api/v1/repos/oli/rdbms-playground"
BIN_NAME="rdbms-playground"
PRINT_TARGET=0
err() {
printf 'install: %s\n' "$1" >&2
exit 1
}
info() { printf '%s\n' "$1" >&2; }
usage() {
# Lines 2..(first blank) of this file are the human-readable header.
sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//'
}
# Resolve the Rust target triple for the current (or forced) platform.
# Linux -> <arch>-unknown-linux-musl (the fully-static build)
# macOS -> <arch>-apple-darwin
detect_target() {
os="${RDBMS_OS:-$(uname -s)}"
arch="${RDBMS_ARCH:-$(uname -m)}"
case "$os" in
Linux | linux) os_part="unknown-linux-musl" ;;
Darwin | darwin | macos | macOS) os_part="apple-darwin" ;;
MINGW* | MSYS* | CYGWIN* | *Windows* | *windows*)
err "Windows is not supported by this installer — use Scoop/winget (planned) or download the .exe from $REPO_BASE/releases" ;;
*) err "unsupported operating system: $os" ;;
esac
case "$arch" in
x86_64 | amd64) arch_part="x86_64" ;;
aarch64 | arm64) arch_part="aarch64" ;;
*) err "unsupported CPU architecture: $arch" ;;
esac
printf '%s-%s' "$arch_part" "$os_part"
}
# Download $1 to file $2 (curl or wget).
download() {
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$1" -o "$2"
elif command -v wget >/dev/null 2>&1; then
wget -qO "$2" "$1"
else
err "need either curl or wget on PATH"
fi
}
# Fetch $1 to stdout (curl or wget).
fetch() {
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$1"
elif command -v wget >/dev/null 2>&1; then
wget -qO- "$1"
else
err "need either curl or wget on PATH"
fi
}
# The release tag to install: $RDBMS_VERSION if set, else the latest release.
resolve_version() {
if [ -n "${RDBMS_VERSION:-}" ]; then
printf '%s' "$RDBMS_VERSION"
return
fi
json=$(fetch "$API_BASE/releases/latest") ||
err "could not query the latest release from $API_BASE"
# Portable JSON scrape (no jq): the latest-release object carries exactly
# one "tag_name": "<tag>" field.
tag=$(printf '%s' "$json" |
grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' |
head -1 | sed 's/.*"\([^"]*\)"$/\1/')
[ -n "$tag" ] || err "could not parse the latest release tag"
printf '%s' "$tag"
}
# Verify file $1 against sha256 sidecar $2 (format: "<hash> <name>").
verify_checksum() {
expected=$(awk '{print $1; exit}' "$2")
if command -v sha256sum >/dev/null 2>&1; then
actual=$(sha256sum "$1" | awk '{print $1}')
elif command -v shasum >/dev/null 2>&1; then
actual=$(shasum -a 256 "$1" | awk '{print $1}')
else
info "warning: no sha256 tool found — skipping checksum verification"
return 0
fi
[ "$expected" = "$actual" ] ||
err "checksum mismatch (expected $expected, got $actual) — refusing to install"
}
main() {
while [ $# -gt 0 ]; do
case "$1" in
--print-target) PRINT_TARGET=1 ;;
-h | --help)
usage
exit 0
;;
*) err "unknown argument: $1 (try --help)" ;;
esac
shift
done
target=$(detect_target)
if [ "$PRINT_TARGET" = "1" ]; then
printf '%s\n' "$target"
exit 0
fi
version=$(resolve_version)
asset="$BIN_NAME-$version-$target"
url="$REPO_BASE/releases/download/$version/$asset"
dir="${RDBMS_INSTALL_DIR:-$HOME/.local/bin}"
tmp=$(mktemp -d 2>/dev/null) || err "could not create a temporary directory"
trap 'rm -rf "$tmp"' EXIT INT TERM
info "downloading $asset ..."
download "$url" "$tmp/$BIN_NAME" || err "download failed: $url"
download "$url.sha256" "$tmp/$BIN_NAME.sha256" || err "checksum download failed: $url.sha256"
verify_checksum "$tmp/$BIN_NAME" "$tmp/$BIN_NAME.sha256"
mkdir -p "$dir" || err "could not create install directory: $dir"
chmod +x "$tmp/$BIN_NAME"
mv "$tmp/$BIN_NAME" "$dir/$BIN_NAME" || err "could not install to $dir"
info "installed $BIN_NAME $version -> $dir/$BIN_NAME"
case ":${PATH:-}:" in
*":$dir:"*) ;;
*) info "note: $dir is not on your PATH. Add it, e.g.: export PATH=\"$dir:\$PATH\"" ;;
esac
}
main "$@"
+5
View File
@@ -41,6 +41,11 @@ pub enum Action {
/// §4). `source` is the original user-typed text.
JournalFailure {
source: String,
/// Whether the failed submission was advanced (ADR-0052): tags the
/// `err` record `err:adv` so a failed advanced command hydrates in
/// its `:`-prefixed form, recallable in simple mode. App commands
/// (mode-agnostic) are `false`.
advanced: bool,
},
/// User issued the `rebuild` app-level command (ADR-0015
/// §7, §11). Runtime computes a summary from
+880 -203
View File
File diff suppressed because it is too large Load Diff
+37 -37
View File
@@ -30,9 +30,7 @@ use std::path::{Component, Path, PathBuf};
use tracing::{debug, info};
use zip::{CompressionMethod, ZipArchive, ZipWriter, write::SimpleFileOptions};
use crate::project::{
HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local,
};
use crate::project::{HISTORY_LOG, PLAYGROUND_DB, PROJECT_YAML, naming::today_local};
/// File names excluded from `export` zips. These are either
/// derived (`playground.db`), per-process (`.lock`),
@@ -118,20 +116,14 @@ impl std::fmt::Display for ArchiveError {
limit = format_args!("{limit:02}"),
))
}
Self::InvalidZip(detail) => f.write_str(&crate::t!(
"archive.invalid_zip",
detail = detail,
)),
Self::NotAProjectArchive => {
f.write_str(&crate::t!("archive.not_a_project_archive"))
Self::InvalidZip(detail) => {
f.write_str(&crate::t!("archive.invalid_zip", detail = detail,))
}
Self::MultipleTopFolders => {
f.write_str(&crate::t!("archive.multiple_top_folders"))
Self::NotAProjectArchive => f.write_str(&crate::t!("archive.not_a_project_archive")),
Self::MultipleTopFolders => f.write_str(&crate::t!("archive.multiple_top_folders")),
Self::UnsafeEntry(entry) => {
f.write_str(&crate::t!("archive.unsafe_entry", entry = entry,))
}
Self::UnsafeEntry(entry) => f.write_str(&crate::t!(
"archive.unsafe_entry",
entry = entry,
)),
}
}
}
@@ -216,13 +208,7 @@ pub fn export_project(
.unix_permissions(0o644);
add_directory_entry(&mut writer, project_name, dst_zip)?;
add_directory_recursive(
&mut writer,
project_path,
project_name,
&options,
dst_zip,
)?;
add_directory_recursive(&mut writer, project_path, project_name, &options, dst_zip)?;
writer.finish().map_err(|e| ArchiveError::Zip {
path: dst_zip.to_path_buf(),
@@ -392,10 +378,7 @@ pub struct ZipInspection {
///
/// Returns the resolved target path and the suffix that was
/// applied (0 if the original name was free, 2..=99 otherwise).
pub fn resolve_import_target(
parent: &Path,
name: &str,
) -> Result<(PathBuf, u32), ArchiveError> {
pub fn resolve_import_target(parent: &Path, name: &str) -> Result<(PathBuf, u32), ArchiveError> {
let direct = parent.join(name);
if !direct.exists() {
return Ok((direct, 0));
@@ -495,10 +478,12 @@ pub fn extract_into(
source,
})?;
let mut buf = Vec::with_capacity(entry.size() as usize);
entry.read_to_end(&mut buf).map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
})?;
entry
.read_to_end(&mut buf)
.map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
})?;
out.write_all(&buf).map_err(|source| ArchiveError::Io {
path: dst_path.clone(),
source,
@@ -523,7 +508,11 @@ mod tests {
fs::write(p.join(PROJECT_YAML), "version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n").unwrap();
fs::create_dir_all(p.join("data")).unwrap();
fs::write(p.join("data/Customers.csv"), "Name\nAlice\nBob\n").unwrap();
fs::write(p.join(HISTORY_LOG), "T|ok|create table Customers with pk id(serial)\n").unwrap();
fs::write(
p.join(HISTORY_LOG),
"T|ok|create table Customers with pk id(serial)\n",
)
.unwrap();
fs::write(p.join(PLAYGROUND_DB), [0u8; 32]).unwrap();
fs::write(p.join(GITIGNORE), "/playground.db\n").unwrap();
// Stray atomic-write staging file — must be excluded.
@@ -536,7 +525,9 @@ mod tests {
)
.unwrap();
fs::write(
p.join(crate::undo::SNAPSHOTS_DIR).join("0").join(PLAYGROUND_DB),
p.join(crate::undo::SNAPSHOTS_DIR)
.join("0")
.join(PLAYGROUND_DB),
[0u8; 16],
)
.unwrap();
@@ -618,11 +609,15 @@ mod tests {
let zip_path = tmp.path().join("notaproject.zip");
let f = fs::File::create(&zip_path).unwrap();
let mut w = ZipWriter::new(f);
w.start_file("foo/bar.txt", SimpleFileOptions::default()).unwrap();
w.start_file("foo/bar.txt", SimpleFileOptions::default())
.unwrap();
w.write_all(b"hi").unwrap();
w.finish().unwrap();
let err = inspect_zip(&zip_path).expect_err("must refuse");
assert!(matches!(err, ArchiveError::NotAProjectArchive), "got: {err:?}");
assert!(
matches!(err, ArchiveError::NotAProjectArchive),
"got: {err:?}"
);
}
#[test]
@@ -631,13 +626,18 @@ mod tests {
let zip_path = tmp.path().join("multi.zip");
let f = fs::File::create(&zip_path).unwrap();
let mut w = ZipWriter::new(f);
w.start_file("a/project.yaml", SimpleFileOptions::default()).unwrap();
w.start_file("a/project.yaml", SimpleFileOptions::default())
.unwrap();
w.write_all(b"x").unwrap();
w.start_file("b/project.yaml", SimpleFileOptions::default()).unwrap();
w.start_file("b/project.yaml", SimpleFileOptions::default())
.unwrap();
w.write_all(b"x").unwrap();
w.finish().unwrap();
let err = inspect_zip(&zip_path).expect_err("must refuse");
assert!(matches!(err, ArchiveError::MultipleTopFolders), "got: {err:?}");
assert!(
matches!(err, ArchiveError::MultipleTopFolders),
"got: {err:?}"
);
}
#[test]
+93 -24
View File
@@ -30,6 +30,10 @@ pub struct Args {
/// `--help` / `-h`: print usage to stdout and exit. The
/// runtime checks this flag before doing any other work.
pub help: bool,
/// `--version` / `-V`: print the version (`CARGO_PKG_VERSION`,
/// the single source of truth — ADR-0054) and exit. Checked
/// alongside `--help` before any other work.
pub version: bool,
/// `--no-undo`: disable the auto-snapshot / undo machinery for
/// this run (ADR-0006 Amendment 1). When set, no snapshots are
/// taken — zero per-command overhead — and `undo` / `redo`
@@ -62,6 +66,17 @@ pub fn help_text() -> String {
crate::t!("help.cli_banner")
}
/// Version line printed by `--version` / `-V` and the in-app `version`
/// command (ADR-0054).
///
/// `CARGO_PKG_VERSION` is the single source of truth — it equals the `v*`
/// release tag (the release CI guards that), so what the binary reports
/// always matches the downloaded artifact.
#[must_use]
pub fn version_text() -> String {
crate::t!("cli.version_line", version = env!("CARGO_PKG_VERSION"))
}
#[derive(Debug)]
pub enum ArgsError {
MissingValue(&'static str),
@@ -81,10 +96,7 @@ pub enum ArgsError {
impl std::fmt::Display for ArgsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingValue(flag) => f.write_str(&crate::t!(
"cli.missing_value",
flag = flag,
)),
Self::MissingValue(flag) => f.write_str(&crate::t!("cli.missing_value", flag = flag,)),
Self::InvalidValue {
flag,
value,
@@ -95,10 +107,7 @@ impl std::fmt::Display for ArgsError {
value = value,
expected = expected,
)),
Self::Unknown(arg) => f.write_str(&crate::t!(
"cli.unknown_argument",
arg = arg,
)),
Self::Unknown(arg) => f.write_str(&crate::t!("cli.unknown_argument", arg = arg,)),
Self::MultiplePaths { first, second } => f.write_str(&crate::t!(
"cli.multiple_paths",
first = first,
@@ -129,6 +138,7 @@ impl Args {
let mut project_path: Option<PathBuf> = None;
let mut resume = false;
let mut help = false;
let mut version = false;
let mut no_undo = false;
let mut mode: Option<Mode> = None;
// Demonstration mode (ADR-0047): the env var is the default,
@@ -143,6 +153,9 @@ impl Args {
"--help" | "-h" => {
help = true;
}
"--version" | "-V" => {
version = true;
}
"--resume" => {
resume = true;
}
@@ -208,6 +221,7 @@ impl Args {
project_path,
resume,
help,
version,
no_undo,
mode,
demo,
@@ -241,7 +255,11 @@ fn default_theme() -> Theme {
// Standard convention: 0..=6 and 8 are dark backgrounds,
// 7 and 9..=15 are light. ITerm emits 15 for white-ish.
let is_dark = matches!(code, 0..=6 | 8);
return if is_dark { Theme::dark() } else { Theme::light() };
return if is_dark {
Theme::dark()
} else {
Theme::light()
};
}
Theme::default()
}
@@ -294,10 +312,19 @@ mod tests {
#[test]
fn mode_flag_simple_and_advanced() {
assert_eq!(Args::parse(["--mode", "simple"]).unwrap().mode, Some(Mode::Simple));
assert_eq!(Args::parse(["--mode", "advanced"]).unwrap().mode, Some(Mode::Advanced));
assert_eq!(
Args::parse(["--mode", "simple"]).unwrap().mode,
Some(Mode::Simple)
);
assert_eq!(
Args::parse(["--mode", "advanced"]).unwrap().mode,
Some(Mode::Advanced)
);
// Case-insensitive, like the `mode` command.
assert_eq!(Args::parse(["--mode", "ADVANCED"]).unwrap().mode, Some(Mode::Advanced));
assert_eq!(
Args::parse(["--mode", "ADVANCED"]).unwrap().mode,
Some(Mode::Advanced)
);
}
#[test]
@@ -330,7 +357,10 @@ mod tests {
#[test]
fn data_dir_flag_parses() {
let args = Args::parse(["--data-dir", "/tmp/playground-data"]).unwrap();
assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/playground-data")));
assert_eq!(
args.data_dir.as_deref(),
Some(std::path::Path::new("/tmp/playground-data"))
);
}
#[test]
@@ -350,13 +380,11 @@ mod tests {
#[test]
fn data_dir_and_positional_can_coexist() {
let args = Args::parse([
"--data-dir",
"/tmp/data",
"/home/me/MyProject",
])
.unwrap();
assert_eq!(args.data_dir.as_deref(), Some(std::path::Path::new("/tmp/data")));
let args = Args::parse(["--data-dir", "/tmp/data", "/home/me/MyProject"]).unwrap();
assert_eq!(
args.data_dir.as_deref(),
Some(std::path::Path::new("/tmp/data"))
);
assert_eq!(
args.project_path.as_deref(),
Some(std::path::Path::new("/home/me/MyProject"))
@@ -366,7 +394,10 @@ mod tests {
#[test]
fn two_positional_paths_error() {
let err = Args::parse(["/a", "/b"]).unwrap_err();
assert!(matches!(err, ArgsError::MultiplePaths { .. }), "got: {err:?}");
assert!(
matches!(err, ArgsError::MultiplePaths { .. }),
"got: {err:?}"
);
}
#[test]
@@ -435,7 +466,10 @@ mod tests {
// Absent `--demo` (and absent env var in the test runner),
// demo mode is off — zero footprint for real users.
let args = Args::parse(std::iter::empty::<&str>()).unwrap();
assert!(!args.demo, "demo is off unless --demo or the env var is given");
assert!(
!args.demo,
"demo is off unless --demo or the env var is given"
);
}
#[test]
@@ -464,7 +498,10 @@ mod tests {
}
// Disabling values.
for v in ["", " ", "0", "false", "False", "no", "off", "OFF"] {
assert!(!demo_value_is_truthy(v), "{v:?} should not enable demo mode");
assert!(
!demo_value_is_truthy(v),
"{v:?} should not enable demo mode"
);
}
}
@@ -473,6 +510,38 @@ mod tests {
// Make sure the path-vs-flag distinction is robust:
// unknown flags don't get silently swallowed as paths.
let err = Args::parse(["--bogus", "/some/path"]).unwrap_err();
assert!(matches!(&err, ArgsError::Unknown(s) if s == "--bogus"), "got: {err:?}");
assert!(
matches!(&err, ArgsError::Unknown(s) if s == "--bogus"),
"got: {err:?}"
);
}
// ---- ADR-0054: --version / -V ----
#[test]
fn version_long_flag_parses() {
assert!(Args::parse(["--version"]).unwrap().version);
}
#[test]
fn version_short_flag_parses() {
assert!(Args::parse(["-V"]).unwrap().version);
}
#[test]
fn version_defaults_off() {
assert!(!Args::parse(std::iter::empty::<&str>()).unwrap().version);
}
#[test]
fn version_text_carries_the_cargo_version() {
// The binary's self-reported version IS Cargo.toml's (the
// single source of truth, ADR-0054) — and the release CI guards
// that the `v*` tag equals it.
let text = version_text();
assert!(
text.contains(env!("CARGO_PKG_VERSION")),
"version line should embed CARGO_PKG_VERSION; got {text:?}"
);
}
}
+254 -210
View File
@@ -15,10 +15,10 @@
//! `app.rs`; this module owns the candidate computation.
use crate::dsl::grammar::IdentSource;
use crate::dsl::parser::parse_command_with_schema_in_mode;
use crate::dsl::types::Type;
use crate::dsl::walker::outcome::Expectation;
use crate::dsl::{ParseError, parse_command};
use crate::dsl::parser::parse_command_with_schema_in_mode;
use crate::mode::Mode;
/// Composite literal candidates whose lexed shape is more than
@@ -275,11 +275,7 @@ pub struct Completion {
/// (case-insensitive starts-with), combined, sorted, and
/// deduplicated.
#[must_use]
pub fn candidates_at_cursor(
input: &str,
cursor: usize,
cache: &SchemaCache,
) -> Option<Completion> {
pub fn candidates_at_cursor(input: &str, cursor: usize, cache: &SchemaCache) -> Option<Completion> {
candidates_at_cursor_in_mode(input, cursor, cache, Mode::Advanced)
}
@@ -358,7 +354,11 @@ pub fn candidates_at_cursor_with_in_mode(
let word_boundary = run == 0 || bytes[run - 1].is_ascii_whitespace();
if run < cursor && bytes[run] == b'-' && word_boundary && run < start {
let pre = crate::dsl::walker::completion_probe_in_mode(&input[..run], cache, mode);
if pre.expected.iter().any(|e| matches!(e, Expectation::Flag(_))) {
if pre
.expected
.iter()
.any(|e| matches!(e, Expectation::Flag(_)))
{
start = run;
}
}
@@ -473,22 +473,19 @@ pub fn candidates_at_cursor_with_in_mode(
// walk's `current_table_columns`; fall back to "the union of
// the look-ahead from_scope's bindings' columns" when leading
// produced no in-scope columns. Phase-1 DSL paths unaffected.
let lookahead_union_columns: Vec<TableColumn> =
if probe.current_table_columns.is_none() {
let mut out: Vec<TableColumn> = Vec::new();
for binding in resolution_from_scope {
for col in &binding.columns {
if !out.iter().any(|c| {
c.name.eq_ignore_ascii_case(&col.name)
}) {
out.push(col.clone());
}
let lookahead_union_columns: Vec<TableColumn> = if probe.current_table_columns.is_none() {
let mut out: Vec<TableColumn> = Vec::new();
for binding in resolution_from_scope {
for col in &binding.columns {
if !out.iter().any(|c| c.name.eq_ignore_ascii_case(&col.name)) {
out.push(col.clone());
}
}
out
} else {
Vec::new()
};
}
out
} else {
Vec::new()
};
let lookahead_slice: Option<&[TableColumn]> = if lookahead_union_columns.is_empty() {
None
} else {
@@ -507,30 +504,23 @@ pub fn candidates_at_cursor_with_in_mode(
// column list (the structural error path surfaces the
// unresolved-prefix message).
let prefix_qualifier = peek_back_qualifier(input, start);
let qualified_columns: Option<Vec<String>> = prefix_qualifier
.as_ref()
.map(|q| {
// ADR-0033 §9: `excluded.|` inside an `INSERT … ON
// CONFLICT … DO UPDATE` completes to the target table's
// columns — `excluded` mirrors the would-be-inserted row.
// The target's columns are the INSERT's
// `current_table_columns` (set by the target-table slot).
// The diagnostic pass enforces the strict DO-UPDATE
// byte-range; completion is the softer surface and offers
// the columns whenever the INSERT target is in hand.
if q.eq_ignore_ascii_case("excluded")
&& let Some(cols) = current_table_columns
{
cols.iter().map(|c| c.name.clone()).collect()
} else {
resolve_qualifier_columns_in(
q,
resolution_from_scope,
resolution_cte_bindings,
cache,
)
}
});
let qualified_columns: Option<Vec<String>> = prefix_qualifier.as_ref().map(|q| {
// ADR-0033 §9: `excluded.|` inside an `INSERT … ON
// CONFLICT … DO UPDATE` completes to the target table's
// columns — `excluded` mirrors the would-be-inserted row.
// The target's columns are the INSERT's
// `current_table_columns` (set by the target-table slot).
// The diagnostic pass enforces the strict DO-UPDATE
// byte-range; completion is the softer surface and offers
// the columns whenever the INSERT target is in hand.
if q.eq_ignore_ascii_case("excluded")
&& let Some(cols) = current_table_columns
{
cols.iter().map(|c| c.name.clone()).collect()
} else {
resolve_qualifier_columns_in(q, resolution_from_scope, resolution_cte_bindings, cache)
}
});
let expected = if probe.expected.is_empty() {
expected_at(leading, mode)
@@ -574,8 +564,7 @@ pub fn candidates_at_cursor_with_in_mode(
Some(crate::dsl::grammar::HintMode::ProseOnly(_))
);
if partial_prefix.is_empty()
&& (prose_only_slot
|| (is_value_literal_signature(&expected) && !has_schema_ident))
&& (prose_only_slot || (is_value_literal_signature(&expected) && !has_schema_ident))
{
return None;
}
@@ -646,7 +635,13 @@ pub fn candidates_at_cursor_with_in_mode(
// shortid). The walker surfaces this as
// `Expectation::Ident { source: Types }`.
let type_names: Vec<String> = if expected.iter().any(|e| {
matches!(e, Expectation::Ident { source: IdentSource::Types, .. })
matches!(
e,
Expectation::Ident {
source: IdentSource::Types,
..
}
)
}) {
Type::all()
.iter()
@@ -725,7 +720,13 @@ pub fn candidates_at_cursor_with_in_mode(
// filtered like every other source; empty prefix offers the whole
// set. Tagged `CandidateKind::Function` for its own colour.
let has_sql_expr_slot = expected.iter().any(|e| {
matches!(e, Expectation::Ident { role: "sql_expr_ident", .. })
matches!(
e,
Expectation::Ident {
role: "sql_expr_ident",
..
}
)
});
let mut functions: Vec<String> = if has_sql_expr_slot {
crate::dsl::sql_functions::KNOWN_SQL_FUNCTIONS
@@ -741,9 +742,15 @@ pub fn candidates_at_cursor_with_in_mode(
// curated vocabulary is offered so a learner can discover `email` /
// `product` / … by Tab. Same `Function` kind / `tok_function` colour
// as SQL functions (no new theme colour — ADR-0048 §Grammar).
let has_generator_slot = expected
.iter()
.any(|e| matches!(e, Expectation::Ident { source: IdentSource::Generators, .. }));
let has_generator_slot = expected.iter().any(|e| {
matches!(
e,
Expectation::Ident {
source: IdentSource::Generators,
..
}
)
});
if has_generator_slot {
functions.extend(
crate::seed::KNOWN_GENERATORS
@@ -765,38 +772,36 @@ pub fn candidates_at_cursor_with_in_mode(
// (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<String> =
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<String> = 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 {
let alias_candidates: Vec<String> = 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<String> = 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.
@@ -811,9 +816,7 @@ pub fn candidates_at_cursor_with_in_mode(
let mut identifiers: Vec<String> = expected
.iter()
.filter_map(|e| match e {
Expectation::Ident { source, .. } if source.completes_from_schema() => {
Some(*source)
}
Expectation::Ident { source, .. } if source.completes_from_schema() => Some(*source),
_ => None,
})
.flat_map(|source| {
@@ -1007,11 +1010,7 @@ fn resolve_qualifier_columns_in(
.iter()
.find(|c| c.name.eq_ignore_ascii_case(&binding.table))
{
return cte
.columns
.iter()
.filter_map(|c| c.name.clone())
.collect();
return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
}
}
// Second: table-name match in the active from_scope.
@@ -1026,11 +1025,7 @@ fn resolve_qualifier_columns_in(
.iter()
.find(|c| c.name.eq_ignore_ascii_case(&binding.table))
{
return cte
.columns
.iter()
.filter_map(|c| c.name.clone())
.collect();
return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
}
}
// Third: direct cte_bindings match (cte_alias.|).
@@ -1038,11 +1033,7 @@ fn resolve_qualifier_columns_in(
.iter()
.find(|c| c.name.eq_ignore_ascii_case(qualifier))
{
return cte
.columns
.iter()
.filter_map(|c| c.name.clone())
.collect();
return cte.columns.iter().filter_map(|c| c.name.clone()).collect();
}
// Fourth: a bare table name from the schema cache — DSL
// paths reach this for `from <Table>.<col>` shapes where
@@ -1287,7 +1278,13 @@ pub fn invalid_ident_at_cursor_in_mode(
// column. So `select Agx` warns at typing time again, while
// `select sum` does not.
let has_sql_expr_slot = expected.iter().any(|e| {
matches!(e, Expectation::Ident { role: "sql_expr_ident", .. })
matches!(
e,
Expectation::Ident {
role: "sql_expr_ident",
..
}
)
});
if has_sql_expr_slot && crate::dsl::sql_functions::is_known_function_prefix(partial) {
return None;
@@ -1318,9 +1315,15 @@ pub fn invalid_ident_at_cursor_in_mode(
// schema-column check below would never see it. A partial that
// prefix-matches a known generator is an in-progress name; anything
// else is an unknown generator → flag it `[ERR]` while typing.
let has_generator_slot = expected
.iter()
.any(|e| matches!(e, Expectation::Ident { source: IdentSource::Generators, .. }));
let has_generator_slot = expected.iter().any(|e| {
matches!(
e,
Expectation::Ident {
source: IdentSource::Generators,
..
}
)
});
if has_generator_slot {
if crate::seed::is_known_generator_prefix(partial) {
return None;
@@ -1335,9 +1338,7 @@ pub fn invalid_ident_at_cursor_in_mode(
let sources: Vec<IdentSource> = expected
.iter()
.filter_map(|e| match e {
Expectation::Ident { source, .. } if source.completes_from_schema() => {
Some(*source)
}
Expectation::Ident { source, .. } if source.completes_from_schema() => Some(*source),
_ => None,
})
.collect();
@@ -1412,13 +1413,15 @@ mod tests {
use pretty_assertions::assert_eq;
fn cands(input: &str, cursor: usize) -> Vec<String> {
candidates_at_cursor(input, cursor, &SchemaCache::default())
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
candidates_at_cursor(input, cursor, &SchemaCache::default()).map_or_else(Vec::new, |c| {
c.candidates.into_iter().map(|c| c.text).collect()
})
}
fn cands_with(input: &str, cursor: usize, cache: &SchemaCache) -> Vec<String> {
candidates_at_cursor(input, cursor, cache)
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| {
c.candidates.into_iter().map(|c| c.text).collect()
})
}
/// Simple-mode completion candidates — the DSL surface
@@ -1429,7 +1432,9 @@ mod tests {
/// Advanced mode surfaces the SQL grammar's completions instead.
fn cands_simple(input: &str, cursor: usize) -> Vec<String> {
candidates_at_cursor_in_mode(input, cursor, &SchemaCache::default(), Mode::Simple)
.map_or_else(Vec::new, |c| c.candidates.into_iter().map(|c| c.text).collect())
.map_or_else(Vec::new, |c| {
c.candidates.into_iter().map(|c| c.text).collect()
})
}
fn cand_kinds_with(
@@ -1438,10 +1443,7 @@ mod tests {
cache: &SchemaCache,
) -> Vec<(String, CandidateKind)> {
candidates_at_cursor(input, cursor, cache).map_or_else(Vec::new, |c| {
c.candidates
.into_iter()
.map(|c| (c.text, c.kind))
.collect()
c.candidates.into_iter().map(|c| (c.text, c.kind)).collect()
})
}
@@ -1503,12 +1505,21 @@ mod tests {
// Simple-only (column, relationship, constraint).
let cs = cands("drop ", 5);
for kw in ["table", "index", "column", "relationship", "constraint"] {
assert!(cs.contains(&kw.to_string()), "`drop ` should offer `{kw}`; got {cs:?}");
assert!(
cs.contains(&kw.to_string()),
"`drop ` should offer `{kw}`; got {cs:?}"
);
}
// Both-mode continuations block before the simple-only ones.
let pos = |k: &str| cs.iter().position(|c| c == k).unwrap();
assert!(pos("table") < pos("column"), "Both block precedes Simple block: {cs:?}");
assert!(pos("index") < pos("relationship"), "Both block precedes Simple block: {cs:?}");
assert!(
pos("table") < pos("column"),
"Both block precedes Simple block: {cs:?}"
);
assert!(
pos("index") < pos("relationship"),
"Both block precedes Simple block: {cs:?}"
);
}
#[test]
@@ -1631,8 +1642,14 @@ mod tests {
let c = candidates_at_cursor(input, input.len(), &SchemaCache::default())
.expect("a `-` at a flag position offers candidates");
let texts: Vec<&str> = c.candidates.iter().map(|x| x.text.as_str()).collect();
assert!(texts.contains(&"--create-fk"), "should offer --create-fk: {texts:?}");
assert!(!texts.contains(&"on"), "must NOT offer `on` after a dash: {texts:?}");
assert!(
texts.contains(&"--create-fk"),
"should offer --create-fk: {texts:?}"
);
assert!(
!texts.contains(&"on"),
"must NOT offer `on` after a dash: {texts:?}"
);
assert_eq!(
c.replaced_range,
(input.len() - 1, input.len()),
@@ -1643,13 +1660,9 @@ mod tests {
#[test]
fn double_dash_replaces_both_dashes_on_accept() {
let input = "delete from T --";
let c = candidates_at_cursor_in_mode(
input,
input.len(),
&SchemaCache::default(),
Mode::Simple,
)
.expect("`--` offers the flag");
let c =
candidates_at_cursor_in_mode(input, input.len(), &SchemaCache::default(), Mode::Simple)
.expect("`--` offers the flag");
assert!(c.candidates.iter().any(|x| x.text == "--all-rows"));
assert_eq!(
c.replaced_range,
@@ -1668,9 +1681,7 @@ mod tests {
s.tables.push("T".into());
s.columns.push("x".into());
let input = "show data T where x = -5";
if let Some(c) =
candidates_at_cursor_in_mode(input, input.len(), &s, Mode::Simple)
{
if let Some(c) = candidates_at_cursor_in_mode(input, input.len(), &s, Mode::Simple) {
assert!(
!c.candidates.iter().any(|x| x.text.starts_with("--")),
"no flags at a value position: {:?}",
@@ -1715,8 +1726,8 @@ mod tests {
// App-lifecycle commands now appear alongside DSL
// commands in the entry-keyword set.
for expected in &[
"quit", "help", "rebuild", "save", "new", "load", "export",
"import", "mode", "messages", "undo", "redo", "copy",
"quit", "help", "rebuild", "save", "new", "load", "export", "import", "mode",
"messages", "undo", "redo", "copy",
] {
assert!(
cs.contains(&expected.to_string()),
@@ -1943,7 +1954,10 @@ mod tests {
// opening a sub-shape) becomes a Tab candidate.
let input = "add column to table T";
let cs = cands(input, input.len());
assert!(cs.is_empty(), "trailing-content punct should not surface: {cs:?}");
assert!(
cs.is_empty(),
"trailing-content punct should not surface: {cs:?}"
);
}
#[test]
@@ -1957,10 +1971,7 @@ mod tests {
assert!(cs.contains(&"(".to_string()), "got {cs:?}");
}
fn schema_with_table(
table: &str,
columns: &[(&str, crate::dsl::types::Type)],
) -> SchemaCache {
fn schema_with_table(table: &str, columns: &[(&str, crate::dsl::types::Type)]) -> SchemaCache {
let mut cache = SchemaCache::default();
cache.tables.push(table.to_string());
let cols: Vec<TableColumn> = columns
@@ -2002,8 +2013,14 @@ mod tests {
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:?}");
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]
@@ -2015,8 +2032,14 @@ mod tests {
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:?}");
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";
@@ -2046,19 +2069,20 @@ mod tests {
// SchemaCache.columns has columns from many tables, but
// at `update Customers set ` only Customers' columns
// should appear.
let mut cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
// Pretend the global flat list has columns from a second
// table that aren't in Customers.
cache.columns.push("OrderTotal".to_string());
cache.columns.push("Stock".to_string());
cache
.table_columns
.insert("Orders".to_string(), vec![
TableColumn { name: "OrderTotal".to_string(), user_type: Type::Real, not_null: false, has_default: false },
]);
cache.table_columns.insert(
"Orders".to_string(),
vec![TableColumn {
name: "OrderTotal".to_string(),
user_type: Type::Real,
not_null: false,
has_default: false,
}],
);
cache.tables.push("Orders".to_string());
let cs = cands_with("update Customers set ", 21, &cache);
// Customers's columns should appear:
@@ -2079,10 +2103,7 @@ mod tests {
// *before* ORDER BY (the FROM's JOIN options, WHERE /
// GROUP BY / HAVING, set-ops). Those used to shove the
// columns off-screen.
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
let input = "select Name from Things order by ";
let cs = cands_with(input, input.len(), &cache);
// The columns the user wants are offered:
@@ -2090,8 +2111,19 @@ mod tests {
assert!(cs.contains(&"Qty".to_string()), "got {cs:?}");
// Preceding-clause keywords must not leak in:
for kw in [
"where", "group", "having", "join", "union", "intersect",
"except", "left", "right", "full", "cross", "inner", "as",
"where",
"group",
"having",
"join",
"union",
"intersect",
"except",
"left",
"right",
"full",
"cross",
"inner",
"as",
] {
assert!(
!cs.contains(&kw.to_string()),
@@ -2108,10 +2140,7 @@ mod tests {
// sort item the direction keywords surface as
// continuations (previously discarded at the Repeated
// boundary, so completion offered neither).
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
let input = "select Name from Things order by Name ";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"asc".to_string()), "got {cs:?}");
@@ -2123,10 +2152,7 @@ mod tests {
use crate::dsl::types::Type;
// walk_repeated trailing-optional fix: after a complete
// projection item the `as` alias keyword surfaces.
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
let input = "select Name ";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"as".to_string()), "got {cs:?}");
@@ -2153,16 +2179,13 @@ mod tests {
// ADR-0022 Amendment 2: at an expression position offering
// both column names and keywords, every column precedes
// every keyword so the names stay visible by default.
let cache = schema_with_table(
"Things",
&[("Name", Type::Text), ("Qty", Type::Int)],
);
let cache = schema_with_table("Things", &[("Name", Type::Text), ("Qty", Type::Int)]);
let input = "select * from Things where ";
let cs = cands_with(input, input.len(), &cache);
let pos = |needle: &str| {
cs.iter().position(|c| c == needle).unwrap_or_else(|| {
panic!("{needle:?} not in candidates: {cs:?}")
})
cs.iter()
.position(|c| c == needle)
.unwrap_or_else(|| panic!("{needle:?} not in candidates: {cs:?}"))
};
// Both columns come before any expression-start keyword.
let last_ident = pos("Name").max(pos("Qty"));
@@ -2176,13 +2199,9 @@ mod tests {
#[test]
fn update_where_offers_only_current_table_columns() {
use crate::dsl::types::Type;
let mut cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
cache.columns.push("OrderTotal".to_string());
let cs =
cands_with("update Customers set Email='x' where ", 37, &cache);
let cs = cands_with("update Customers set Email='x' where ", 37, &cache);
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
@@ -2208,7 +2227,11 @@ mod tests {
use crate::dsl::types::Type;
let cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text), ("Name", Type::Text)],
&[
("id", Type::Int),
("Email", Type::Text),
("Name", Type::Text),
],
);
let cs = cands_with("insert into Customers (", 23, &cache);
// The user is at Form A's column-list position. All
@@ -2222,10 +2245,7 @@ mod tests {
#[test]
fn insert_into_open_paren_does_not_offer_unrelated_columns() {
use crate::dsl::types::Type;
let mut cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
cache.columns.push("OrderTotal".to_string());
let cs = cands_with("insert into Customers (", 23, &cache);
assert!(!cs.contains(&"OrderTotal".to_string()), "got {cs:?}");
@@ -2239,13 +2259,9 @@ mod tests {
// table's columns. `OrderTotal` belongs to no table in
// this cache's `table_columns`, so it must not leak.
use crate::dsl::types::Type;
let mut cache = schema_with_table(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let mut cache = schema_with_table("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
cache.columns.push("OrderTotal".to_string());
let cs =
cands_with("drop column from Customers: ", 28, &cache);
let cs = cands_with("drop column from Customers: ", 28, &cache);
assert!(cs.contains(&"Email".to_string()), "got {cs:?}");
assert!(cs.contains(&"id".to_string()), "got {cs:?}");
assert!(
@@ -2271,8 +2287,8 @@ mod tests {
#[test]
fn cursor_mid_keyword_replaces_only_the_partial_prefix() {
let comp = candidates_at_cursor("cre", 3, &SchemaCache::default())
.expect("some completion");
let comp =
candidates_at_cursor("cre", 3, &SchemaCache::default()).expect("some completion");
assert_eq!(comp.replaced_range, (0, 3));
assert_eq!(comp.partial_prefix, "cre");
assert_eq!(comp.candidates.len(), 1);
@@ -2282,8 +2298,8 @@ mod tests {
#[test]
fn cursor_at_word_boundary_has_empty_partial_prefix() {
let comp = candidates_at_cursor("create ", 7, &SchemaCache::default())
.expect("some completion");
let comp =
candidates_at_cursor("create ", 7, &SchemaCache::default()).expect("some completion");
assert_eq!(comp.replaced_range, (7, 7));
assert_eq!(comp.partial_prefix, "");
}
@@ -2517,8 +2533,8 @@ mod tests {
// inside `Name`, and substituting any name there
// produces a complete command. No useful "next after
// name" hint.
let t = typing_name_at_cursor("add column to table T: Name (text)", 27)
.expect("should fire");
let t =
typing_name_at_cursor("add column to table T: Name (text)", 27).expect("should fire");
assert_eq!(t.next_after_name, None);
}
@@ -2534,8 +2550,8 @@ mod tests {
assert!(invalid_ident_at_cursor("show data Cust", 14, &cache).is_none());
// `show data Cust` plus a typo: `show data Custp`. No
// table starts with "Custp" → invalid.
let invalid = invalid_ident_at_cursor("show data Custp", 15, &cache)
.expect("should be invalid");
let invalid =
invalid_ident_at_cursor("show data Custp", 15, &cache).expect("should be invalid");
assert_eq!(invalid.range, (10, 15));
assert_eq!(invalid.found, "Custp");
assert_eq!(invalid.source, IdentSource::Tables);
@@ -2600,7 +2616,11 @@ mod tests {
!cs.iter().any(|c| c == "Existing" || c == "AlsoExisting"),
"NewName slot must not surface schema candidates; got {cs:?}"
);
assert_eq!(cs, vec!["if".to_string()], "only the advanced IF NOT EXISTS keyword");
assert_eq!(
cs,
vec!["if".to_string()],
"only the advanced IF NOT EXISTS keyword"
);
}
fn keyword_cand(text: &str) -> Candidate {
@@ -2791,8 +2811,10 @@ mod tests {
let cands = candidates_at_cursor(input, input.len(), &cache)
.expect("some completion")
.candidates;
let count_entries: Vec<_> =
cands.iter().filter(|c| c.text.eq_ignore_ascii_case("count")).collect();
let count_entries: Vec<_> = cands
.iter()
.filter(|c| c.text.eq_ignore_ascii_case("count"))
.collect();
assert_eq!(
count_entries.len(),
1,
@@ -2805,7 +2827,9 @@ mod tests {
);
// A non-colliding function at the same slot is unaffected.
assert!(
cands.iter().any(|c| c.text == "coalesce" && c.kind == CandidateKind::Function),
cands
.iter()
.any(|c| c.text == "coalesce" && c.kind == CandidateKind::Function),
"non-colliding functions still surface; got {cands:?}",
);
}
@@ -2875,8 +2899,10 @@ mod tests {
let mut s = SchemaCache::default();
s.tables.push("OrderLines".into());
s.columns.push("count".into());
s.table_columns
.insert("OrderLines".into(), vec![TableColumn::new("count", Type::Int)]);
s.table_columns.insert(
"OrderLines".into(),
vec![TableColumn::new("count", Type::Int)],
);
let input = "select sum(ol.count) from OrderLines ol";
let cursor = input.find("ol.count").unwrap() + 2; // right after `ol`
assert!(
@@ -2938,15 +2964,35 @@ mod tests {
s.table_columns.insert(
"a".to_string(),
vec![
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
TableColumn { name: "name".to_string(), user_type: Type::Text, not_null: false, has_default: false },
TableColumn {
name: "id".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
TableColumn {
name: "name".to_string(),
user_type: Type::Text,
not_null: false,
has_default: false,
},
],
);
s.table_columns.insert(
"b".to_string(),
vec![
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
TableColumn { name: "total".to_string(), user_type: Type::Real, not_null: false, has_default: false },
TableColumn {
name: "id".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
TableColumn {
name: "total".to_string(),
user_type: Type::Real,
not_null: false,
has_default: false,
},
],
);
s
@@ -3191,5 +3237,3 @@ mod tests {
assert!(candidates_at_cursor_with("create ", 7, &cache, empty_ranker).is_none());
}
}
+1258 -1083
View File
File diff suppressed because it is too large Load Diff
+21 -11
View File
@@ -549,9 +549,16 @@ pub enum AppCommand {
/// word like `insert` / `create` / `show`, or `types`), the
/// focused detail for that command (or command group sharing
/// the entry word).
Help {
topic: Option<String>,
},
Help { topic: Option<String> },
/// Show a contextual tier-3 hint (H2 / ADR-0053). No argument:
/// when submitted, it expands on the most recent runtime error
/// (the buffer is empty post-submit). The live-input surface is
/// the F1 keybinding, handled in `App::handle_key`, not here.
Hint,
/// Print the application version (ADR-0054): the in-app twin of the
/// `--version` / `-V` CLI flag. Emits `CARGO_PKG_VERSION` — the same
/// single source of truth — into the output panel.
Version,
/// Rebuild `playground.db` from `project.yaml` + data/, with
/// confirmation modal.
Rebuild,
@@ -571,7 +578,10 @@ pub enum AppCommand {
/// Unpack a zip into a new project and switch to it.
/// `target` overrides the project name (default: taken from
/// the zip).
Import { path: String, target: Option<String> },
Import {
path: String,
target: Option<String>,
},
/// Switch the persistent input mode.
Mode { value: ModeValue },
/// Show or set the messages verbosity.
@@ -782,9 +792,7 @@ impl PartialEq for Operand {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Column { name: a, .. }, Self::Column { name: b, .. }) => a == b,
(Self::Literal { value: a, .. }, Self::Literal { value: b, .. }) => {
a == b
}
(Self::Literal { value: a, .. }, Self::Literal { value: b, .. }) => a == b,
_ => false,
}
}
@@ -808,7 +816,9 @@ pub enum CompareOp {
/// a single row in the metadata table.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelationshipSelector {
Named { name: String },
Named {
name: String,
},
Endpoints {
parent_table: String,
parent_column: String,
@@ -1013,6 +1023,8 @@ impl Command {
Self::App(app) => match app {
AppCommand::Quit => "quit",
AppCommand::Help { .. } => "help",
AppCommand::Hint => "hint",
AppCommand::Version => "version",
AppCommand::Rebuild => "rebuild",
AppCommand::Save => "save",
AppCommand::SaveAs => "save as",
@@ -1145,9 +1157,7 @@ impl Command {
parent_column,
child_table,
child_column,
} => format!(
"from {parent_table}.{parent_column} to {child_table}.{child_column}"
),
} => format!("from {parent_table}.{parent_column} to {child_table}.{child_column}"),
},
// A constraint command's subject is the dotted
// `<table>.<column>` it acts on (ADR-0029 §2.2).
+76 -28
View File
@@ -9,8 +9,7 @@
use crate::dsl::command::{AppCommand, Command, CopyScope, MessagesValue, ModeValue};
use crate::dsl::grammar::{
CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError,
Word,
CommandNode, HintMode, IdentSource, IdentValidator, Node, ValidationError, Word,
};
use crate::dsl::walker::outcome::{MatchedKind, MatchedPath};
@@ -60,19 +59,16 @@ const IMPORT_TARGET_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const IMPORT_TARGET: Node = Node::Hinted {
mode: HintMode::ForceProse("hint.ambient_typing_name"),
inner: &IMPORT_TARGET_IDENT,
};
const IMPORT_AS_TARGET: Node = Node::Seq(&[
Node::Word(Word::keyword("as")),
IMPORT_TARGET,
]);
const IMPORT_AS_TARGET: Node = Node::Seq(&[Node::Word(Word::keyword("as")), IMPORT_TARGET]);
const IMPORT_AS_TARGET_OPT: Node = Node::Optional(&IMPORT_AS_TARGET);
const IMPORT_PATH_AND_TARGET: Node = Node::Seq(&[Node::BarePath, IMPORT_AS_TARGET_OPT]);
@@ -101,9 +97,9 @@ const MODE_CHOICES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const MODE_VALUE: Node = Node::Choice(MODE_CHOICES);
@@ -119,9 +115,9 @@ const MESSAGES_CHOICES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const MESSAGES_VALUE: Node = Node::Choice(MESSAGES_CHOICES);
@@ -174,9 +170,16 @@ const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result<Command, Va
Ok(Command::App(AppCommand::Rebuild))
}
const fn build_version(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Version))
}
const fn build_undo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Undo))
}
const fn build_hint(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Hint))
}
const fn build_redo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Redo))
@@ -263,88 +266,133 @@ pub static QUIT: CommandNode = CommandNode {
shape: EMPTY_SEQ,
ast_builder: build_quit,
help_id: Some("app.quit"),
usage_ids: &["parse.usage.quit"],};
hint_ids: &["quit"],
usage_ids: &["parse.usage.quit"],
};
pub static HELP: CommandNode = CommandNode {
entry: Word::keyword("help"),
shape: HELP_TOPIC_OPT,
ast_builder: build_help,
help_id: Some("app.help"),
usage_ids: &["parse.usage.help"],};
hint_ids: &["help"],
usage_ids: &["parse.usage.help"],
};
pub static HINT: CommandNode = CommandNode {
entry: Word::keyword("hint"),
shape: EMPTY_SEQ,
ast_builder: build_hint,
help_id: Some("app.hint"),
// hint_id assigned in Phase C with the tier-3 corpus (ADR-0053).
hint_ids: &["hint"],
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"),
usage_ids: &["parse.usage.rebuild"],};
hint_ids: &["rebuild"],
usage_ids: &["parse.usage.rebuild"],
};
pub static VERSION: CommandNode = CommandNode {
entry: Word::keyword("version"),
shape: EMPTY_SEQ,
ast_builder: build_version,
help_id: Some("app.version"),
hint_ids: &["version"],
usage_ids: &["parse.usage.version"],
};
pub static SAVE: CommandNode = CommandNode {
entry: Word::keyword("save"),
shape: SAVE_AS_OPT,
ast_builder: build_save,
help_id: Some("app.save"),
usage_ids: &["parse.usage.save"],};
hint_ids: &["save"],
usage_ids: &["parse.usage.save"],
};
pub static NEW: CommandNode = CommandNode {
entry: Word::keyword("new"),
shape: EMPTY_SEQ,
ast_builder: build_new,
help_id: Some("app.new"),
usage_ids: &["parse.usage.new"],};
hint_ids: &["new"],
usage_ids: &["parse.usage.new"],
};
pub static LOAD: CommandNode = CommandNode {
entry: Word::keyword("load"),
shape: EMPTY_SEQ,
ast_builder: build_load,
help_id: Some("app.load"),
usage_ids: &["parse.usage.load"],};
hint_ids: &["load"],
usage_ids: &["parse.usage.load"],
};
pub static EXPORT: CommandNode = CommandNode {
entry: Word::keyword("export"),
shape: EXPORT_PATH_OPT,
ast_builder: build_export,
help_id: Some("app.export"),
usage_ids: &["parse.usage.export"],};
hint_ids: &["export"],
usage_ids: &["parse.usage.export"],
};
pub static IMPORT: CommandNode = CommandNode {
entry: Word::keyword("import"),
shape: IMPORT_BODY_OPT,
ast_builder: build_import,
help_id: Some("app.import"),
usage_ids: &["parse.usage.import"],};
hint_ids: &["import"],
usage_ids: &["parse.usage.import"],
};
pub static MODE: CommandNode = CommandNode {
entry: Word::keyword("mode"),
shape: MODE_VALUE,
ast_builder: build_mode,
help_id: Some("app.mode"),
usage_ids: &["parse.usage.mode"],};
hint_ids: &["mode"],
usage_ids: &["parse.usage.mode"],
};
pub static MESSAGES: CommandNode = CommandNode {
entry: Word::keyword("messages"),
shape: MESSAGES_VALUE_OPT,
ast_builder: build_messages,
help_id: Some("app.messages"),
usage_ids: &["parse.usage.messages"],};
hint_ids: &["messages"],
usage_ids: &["parse.usage.messages"],
};
pub static UNDO: CommandNode = CommandNode {
entry: Word::keyword("undo"),
shape: EMPTY_SEQ,
ast_builder: build_undo,
help_id: Some("app.undo"),
usage_ids: &["parse.usage.undo"],};
hint_ids: &["undo"],
usage_ids: &["parse.usage.undo"],
};
pub static REDO: CommandNode = CommandNode {
entry: Word::keyword("redo"),
shape: EMPTY_SEQ,
ast_builder: build_redo,
help_id: Some("app.redo"),
usage_ids: &["parse.usage.redo"],};
hint_ids: &["redo"],
usage_ids: &["parse.usage.redo"],
};
pub static COPY: CommandNode = CommandNode {
entry: Word::keyword("copy"),
shape: COPY_VALUE_OPT,
ast_builder: build_copy,
help_id: Some("app.copy"),
usage_ids: &["parse.usage.copy"],};
hint_ids: &["copy"],
usage_ids: &["parse.usage.copy"],
};
+121 -78
View File
@@ -24,19 +24,17 @@
//! later swap that capture for the same typed slots used here, adding
//! live hints/highlighting.
use crate::dsl::command::{
Command, Expr, RowFilter, SeedOverride, SeedOverrideKind, ShowListKind,
};
use crate::dsl::command::{Command, Expr, RowFilter, SeedOverride, SeedOverrideKind, ShowListKind};
use crate::dsl::grammar::{
CommandNode, IdentSource, Node, NumberValidator, ValidationError, Word, expr,
shared::{
FALLBACK_VALUE_LIST, column_value_list, count_tuple_values,
current_column_value, insert_target_columns,
FALLBACK_VALUE_LIST, column_value_list, count_tuple_values, current_column_value,
insert_target_columns,
},
sql_delete, sql_insert, sql_select, sql_update,
};
use crate::dsl::walker::context::WalkContext;
use crate::dsl::value::Value;
use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::outcome::{MatchedItem, MatchedKind, MatchedPath};
// =================================================================
@@ -56,10 +54,10 @@ const TABLE_NAME_EXISTING: Node = Node::Ident {
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
/// Table-name slot variant that populates
@@ -75,10 +73,10 @@ const TABLE_NAME_INSERT: Node = Node::Ident {
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
// =================================================================
@@ -95,10 +93,7 @@ const SHOW_DATA_NODES: &[Node] = &[
];
const SHOW_DATA: Node = Node::Seq(SHOW_DATA_NODES);
const SHOW_TABLE_NODES: &[Node] = &[
Node::Word(Word::keyword("table")),
TABLE_NAME_EXISTING,
];
const SHOW_TABLE_NODES: &[Node] = &[Node::Word(Word::keyword("table")), TABLE_NAME_EXISTING];
const SHOW_TABLE: Node = Node::Seq(SHOW_TABLE_NODES);
// `show tables` / `show relationships` / `show indexes` — the
@@ -144,8 +139,7 @@ const SHOW_INDEX_NAME: Node = Node::Ident {
writes_cte_name: false,
writes_projection_alias: false,
};
const SHOW_INDEX_NODES: &[Node] =
&[Node::Word(Word::keyword("index")), SHOW_INDEX_NAME];
const SHOW_INDEX_NODES: &[Node] = &[Node::Word(Word::keyword("index")), SHOW_INDEX_NAME];
const SHOW_INDEX: Node = Node::Seq(SHOW_INDEX_NODES);
const SHOW_CHOICES: &[Node] = &[
@@ -192,9 +186,9 @@ static FORM_A_COLUMN: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: true,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static INSERT_COMMA: Node = Node::Punct(',');
@@ -224,8 +218,7 @@ fn insert_first_paren(ctx: &WalkContext, source: &str, pos: usize) -> Node {
/// or an identifier-shaped token (a column name) returns false.
fn first_paren_item_is_value_literal(source: &str, pos: usize) -> bool {
use crate::dsl::walker::lex_helpers::{
consume_ident, consume_number_literal, consume_string_literal,
skip_whitespace,
consume_ident, consume_number_literal, consume_string_literal, skip_whitespace,
};
let p = skip_whitespace(source, pos);
if p >= source.len() {
@@ -281,7 +274,11 @@ fn dsl_insert_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node {
return FALLBACK_VALUE_LIST;
};
let (count, closed) = count_tuple_values(source, pos);
let arity_ok = if closed { count == cols.len() } else { count <= cols.len() };
let arity_ok = if closed {
count == cols.len()
} else {
count <= cols.len()
};
if arity_ok {
Node::DynamicSubgrammar(column_value_list)
} else {
@@ -320,8 +317,7 @@ const INSERT_VALUES_KEYWORD_FIRST_NODES: &[Node] = &[
];
const INSERT_VALUES_KEYWORD_FIRST: Node = Node::Seq(INSERT_VALUES_KEYWORD_FIRST_NODES);
const INSERT_AFTER_TABLE_CHOICES: &[Node] =
&[INSERT_VALUES_KEYWORD_FIRST, INSERT_PAREN_FIRST];
const INSERT_AFTER_TABLE_CHOICES: &[Node] = &[INSERT_VALUES_KEYWORD_FIRST, INSERT_PAREN_FIRST];
const INSERT_AFTER_TABLE: Node = Node::Choice(INSERT_AFTER_TABLE_CHOICES);
const INSERT_NODES: &[Node] = &[
@@ -349,10 +345,10 @@ const TABLE_NAME_WRITES: Node = Node::Ident {
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
/// Column-name slot in `set col = …` — resolves the column's
@@ -366,9 +362,9 @@ const SET_COLUMN: Node = Node::Ident {
writes_table: false,
writes_column: true,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
/// Value slot resolved at walk time from
@@ -376,11 +372,7 @@ writes_projection_alias: false,
/// value-literal choice when no current_column is bound.
const PER_COLUMN_VALUE: Node = Node::DynamicSubgrammar(current_column_value);
const UPDATE_ASSIGNMENT_NODES: &[Node] = &[
SET_COLUMN,
Node::Punct('='),
PER_COLUMN_VALUE,
];
const UPDATE_ASSIGNMENT_NODES: &[Node] = &[SET_COLUMN, Node::Punct('='), PER_COLUMN_VALUE];
const UPDATE_ASSIGNMENT: Node = Node::Seq(UPDATE_ASSIGNMENT_NODES);
const UPDATE_ASSIGNMENTS: Node = Node::Repeated {
inner: &UPDATE_ASSIGNMENT,
@@ -568,8 +560,7 @@ const SEED_OVERRIDES: Node = Node::Repeated {
separator: Some(&Node::Punct(',')),
min: 1,
};
const SEED_SET_CLAUSE_NODES: &[Node] =
&[Node::Word(Word::keyword("set")), SEED_OVERRIDES];
const SEED_SET_CLAUSE_NODES: &[Node] = &[Node::Word(Word::keyword("set")), SEED_OVERRIDES];
const SEED_SET_CLAUSE: Node = Node::Seq(SEED_SET_CLAUSE_NODES);
const SEED_NODES: &[Node] = &[
@@ -980,7 +971,10 @@ fn parse_seed_override_tail(
MatchedKind::Word("in") => {
*i += 1; // `in`
// `(`
if matches!(region.get(*i).map(|t| &t.kind), Some(MatchedKind::Punct('('))) {
if matches!(
region.get(*i).map(|t| &t.kind),
Some(MatchedKind::Punct('('))
) {
*i += 1;
}
let mut values = Vec::new();
@@ -1001,7 +995,10 @@ fn parse_seed_override_tail(
MatchedKind::Word("between") => {
*i += 1; // `between`
let low = seed_take_value(region, i, column)?;
if matches!(region.get(*i).map(|t| &t.kind), Some(MatchedKind::Word("and"))) {
if matches!(
region.get(*i).map(|t| &t.kind),
Some(MatchedKind::Word("and"))
) {
*i += 1;
}
let high = seed_take_value(region, i, column)?;
@@ -1011,7 +1008,15 @@ fn parse_seed_override_tail(
*i += 1; // `as`
let gen_item = region
.get(*i)
.filter(|t| matches!(t.kind, MatchedKind::Ident { role: "seed_generator", .. }))
.filter(|t| {
matches!(
t.kind,
MatchedKind::Ident {
role: "seed_generator",
..
}
)
})
.ok_or_else(|| seed_set_error(column))?;
*i += 1;
Ok(SeedOverrideKind::Generator(gen_item.text.clone()))
@@ -1085,7 +1090,15 @@ fn build_insert(path: &MatchedPath, _source: &str) -> Result<Command, Validation
let table_idx = path
.items
.iter()
.position(|i| matches!(&i.kind, MatchedKind::Ident { role: "table_name", .. }))
.position(|i| {
matches!(
&i.kind,
MatchedKind::Ident {
role: "table_name",
..
}
)
})
.ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "missing table".to_string())],
@@ -1141,7 +1154,10 @@ fn build_insert(path: &MatchedPath, _source: &str) -> Result<Command, Validation
if columns.is_empty() {
return Err(ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "expected column names in `insert into T (…)`".to_string())],
args: vec![(
"detail",
"expected column names in `insert into T (…)`".to_string(),
)],
});
}
// Find the `values` keyword and the next `(` — the values
@@ -1247,9 +1263,7 @@ fn build_update(path: &MatchedPath, _source: &str) -> Result<Command, Validation
})
}
fn collect_assignments(
path: &MatchedPath,
) -> Result<Vec<(String, Value)>, ValidationError> {
fn collect_assignments(path: &MatchedPath) -> Result<Vec<(String, Value)>, ValidationError> {
let mut out = Vec::new();
let mut iter = path.items.iter();
while let Some(item) = iter.next() {
@@ -1495,9 +1509,7 @@ fn build_sql_insert(path: &MatchedPath, source: &str) -> Result<Command, Validat
let row_source = path
.items
.iter()
.find(|item| {
matches!(item.kind, MatchedKind::Word("values" | "select" | "with"))
})
.find(|item| matches!(item.kind, MatchedKind::Word("values" | "select" | "with")))
.map(|item| {
let end = tail_start.unwrap_or(source.len());
source[item.span.0..end]
@@ -1790,6 +1802,13 @@ pub static SHOW: CommandNode = CommandNode {
shape: SHOW_SHAPE,
ast_builder: build_show,
help_id: Some("data.show"),
hint_ids: &[
"show_data",
"show_table",
"show_tables",
"show_relationships",
"show_indexes",
],
usage_ids: &[
"parse.usage.show_data",
"parse.usage.show_table",
@@ -1798,13 +1817,15 @@ pub static SHOW: CommandNode = CommandNode {
"parse.usage.show_indexes",
"parse.usage.show_relationship",
"parse.usage.show_index",
],};
],
};
pub static SEED: CommandNode = CommandNode {
entry: Word::keyword("seed"),
shape: SEED_SHAPE,
ast_builder: build_seed,
help_id: Some("data.seed"),
hint_ids: &["seed"],
usage_ids: &["parse.usage.seed"],
};
@@ -1813,35 +1834,46 @@ pub static INSERT: CommandNode = CommandNode {
shape: INSERT_SHAPE,
ast_builder: build_insert,
help_id: Some("data.insert"),
usage_ids: &["parse.usage.insert"],};
// ADR-0053 Phase-B exemplar.
hint_ids: &["insert"],
usage_ids: &["parse.usage.insert"],
};
pub static UPDATE: CommandNode = CommandNode {
entry: Word::keyword("update"),
shape: UPDATE_SHAPE,
ast_builder: build_update,
help_id: Some("data.update"),
usage_ids: &["parse.usage.update"],};
hint_ids: &["update"],
usage_ids: &["parse.usage.update"],
};
pub static DELETE: CommandNode = CommandNode {
entry: Word::keyword("delete"),
shape: DELETE_SHAPE,
ast_builder: build_delete,
help_id: Some("data.delete"),
usage_ids: &["parse.usage.delete"],};
hint_ids: &["delete"],
usage_ids: &["parse.usage.delete"],
};
pub static REPLAY: CommandNode = CommandNode {
entry: Word::keyword("replay"),
shape: REPLAY_PATH,
ast_builder: build_replay,
help_id: Some("data.replay"),
usage_ids: &["parse.usage.replay"],};
hint_ids: &["replay"],
usage_ids: &["parse.usage.replay"],
};
pub static EXPLAIN: CommandNode = CommandNode {
entry: Word::keyword("explain"),
shape: EXPLAIN_SHAPE,
ast_builder: build_explain,
help_id: Some("data.explain"),
usage_ids: &["parse.usage.explain"],};
hint_ids: &["explain"],
usage_ids: &["parse.usage.explain"],
};
/// `explain` over advanced-mode SQL (ADR-0039).
///
@@ -1860,7 +1892,9 @@ 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,
usage_ids: &[],};
hint_ids: &["explain_sql"],
usage_ids: &[],
};
/// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032).
///
@@ -1875,7 +1909,9 @@ pub static SELECT: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL),
ast_builder: build_select,
help_id: None,
usage_ids: &["parse.usage.select"],};
hint_ids: &["select"],
usage_ids: &["parse.usage.select"],
};
/// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c).
///
@@ -1889,7 +1925,9 @@ pub static WITH: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL),
ast_builder: build_select,
help_id: None,
usage_ids: &["parse.usage.with"],};
hint_ids: &["with"],
usage_ids: &["parse.usage.with"],
};
/// SQL `INSERT` — the `Advanced`-category node of the shared
/// `insert` entry word (ADR-0033 §2, Amendment 1, sub-phase 3j).
@@ -1906,6 +1944,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: &["sql_insert"],
usage_ids: &[],
};
@@ -1919,6 +1958,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: &["sql_update"],
usage_ids: &[],
};
@@ -1934,6 +1974,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: &["sql_delete"],
usage_ids: &[],
};
@@ -1973,7 +2014,11 @@ mod explain_tests {
#[test]
fn explain_show_data_carries_where_and_limit_through() {
match explain_inner("explain show data Customers where id = 1 limit 5") {
Command::ShowData { name, filter, limit } => {
Command::ShowData {
name,
filter,
limit,
} => {
assert_eq!(name, "Customers");
assert!(filter.is_some(), "where clause should survive");
assert_eq!(limit, Some(5));
@@ -2032,9 +2077,7 @@ mod explain_tests {
/// Advanced-mode counterpart of `explain_inner`.
fn explain_inner_adv(input: &str) -> Command {
match parse_command_in_mode(input, Mode::Advanced)
.expect("advanced explain should parse")
{
match parse_command_in_mode(input, Mode::Advanced).expect("advanced explain should parse") {
Command::Explain { query } => *query,
other => panic!("expected Command::Explain, got {other:?}"),
}
@@ -2065,7 +2108,9 @@ mod explain_tests {
#[test]
fn explain_sql_insert_wraps_a_sql_insert() {
match explain_inner_adv("explain insert into Customers values (1, 'Bo')") {
Command::SqlInsert { sql, target_table, .. } => {
Command::SqlInsert {
sql, target_table, ..
} => {
assert_eq!(target_table, "Customers");
assert_eq!(sql, "insert into Customers values (1, 'Bo')");
}
@@ -2076,7 +2121,9 @@ mod explain_tests {
#[test]
fn explain_sql_update_wraps_a_sql_update_with_clean_sql() {
match explain_inner_adv("explain update Customers set Name = 'Bo' where id = 1") {
Command::SqlUpdate { sql, target_table, .. } => {
Command::SqlUpdate {
sql, target_table, ..
} => {
assert_eq!(target_table, "Customers");
assert_eq!(sql, "update Customers set Name = 'Bo' where id = 1");
}
@@ -2087,7 +2134,9 @@ mod explain_tests {
#[test]
fn explain_sql_delete_wraps_a_sql_delete() {
match explain_inner_adv("explain delete from Customers where id = 1") {
Command::SqlDelete { sql, target_table, .. } => {
Command::SqlDelete {
sql, target_table, ..
} => {
assert_eq!(target_table, "Customers");
assert_eq!(sql, "delete from Customers where id = 1");
}
@@ -2128,11 +2177,7 @@ mod explain_tests {
fn explain_does_not_cover_ddl() {
// EXPLAIN QUERY PLAN applies to DML/queries only (ADR-0039
// out of scope); there is no SQL DDL branch under explain.
assert!(parse_command_in_mode(
"explain create table T (id int)",
Mode::Advanced,
)
.is_err());
assert!(parse_command_in_mode("explain create table T (id int)", Mode::Advanced,).is_err());
}
#[test]
@@ -2145,9 +2190,8 @@ mod explain_tests {
use crate::completion::candidates_at_cursor_in_mode;
let schema = crate::completion::SchemaCache::default();
let input = "explain ";
let completion =
candidates_at_cursor_in_mode(input, input.len(), &schema, Mode::Advanced)
.expect("explain offers candidates");
let completion = candidates_at_cursor_in_mode(input, input.len(), &schema, Mode::Advanced)
.expect("explain offers candidates");
let names: Vec<&str> = completion
.candidates
.iter()
@@ -2158,4 +2202,3 @@ mod explain_tests {
}
}
}
+272 -166
View File
@@ -16,11 +16,11 @@ use crate::dsl::command::{
AlterTableAction, ChangeColumnMode, ColumnSpec, Command, Constraint, ConstraintKind, Expr,
IndexSelector, RelationshipSelector, SqlForeignKey, TableConstraint,
};
use crate::dsl::value::Value;
use crate::dsl::grammar::{
CommandNode, HighlightClass, HintMode, IdentSource, Node, ValidationError, Word,
shared::{REFERENTIAL_CLAUSES, TYPE_SLOT, TYPE_VALIDATOR},
};
use crate::dsl::value::Value;
/// `HintMode` annotation shared by every `NewName` ident slot:
/// the user is inventing a name, so the hint panel forces the
@@ -39,12 +39,12 @@ const TABLE_NAME_NEW_IDENT: Node = Node::Ident {
role: "table_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const TABLE_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -63,12 +63,12 @@ const TABLE_NAME_EXISTING: Node = Node::Ident {
role: "table_name",
validator: None,
highlight_override: None,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const COLUMN_NAME: Node = Node::Ident {
@@ -76,12 +76,12 @@ const COLUMN_NAME: Node = Node::Ident {
role: "column_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
@@ -89,12 +89,12 @@ const COLUMN_NAME_NEW_IDENT: Node = Node::Ident {
role: "column_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const COLUMN_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -106,12 +106,12 @@ const RELATIONSHIP_NAME: Node = Node::Ident {
role: "relationship_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
@@ -119,12 +119,12 @@ const RELATIONSHIP_NAME_NEW_IDENT: Node = Node::Ident {
role: "relationship_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const RELATIONSHIP_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -139,9 +139,9 @@ const INDEX_NAME_EXISTING: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
@@ -152,9 +152,9 @@ const INDEX_NAME_NEW_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const INDEX_NAME_NEW: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -181,10 +181,7 @@ const TABLE_OPT: Node = Node::Optional(&Node::Word(Word::keyword("table")));
// drop_table — `drop table <T>`
// =================================================================
const DROP_TABLE_NODES: &[Node] = &[
Node::Word(Word::keyword("table")),
TABLE_NAME_EXISTING,
];
const DROP_TABLE_NODES: &[Node] = &[Node::Word(Word::keyword("table")), TABLE_NAME_EXISTING];
const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
// Advanced-mode SQL `DROP TABLE [IF EXISTS] <name> [;]` (ADR-0035 §4,
@@ -192,8 +189,10 @@ const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES);
// plus the optional `IF EXISTS` no-op-with-note. The leading concrete
// `table` keyword (not the Optional) keeps the element/dispatch
// matching honest.
static SQL_DROP_IF_EXISTS_NODES: &[Node] =
&[Node::Word(Word::keyword("if")), Node::Word(Word::keyword("exists"))];
static SQL_DROP_IF_EXISTS_NODES: &[Node] = &[
Node::Word(Word::keyword("if")),
Node::Word(Word::keyword("exists")),
];
const SQL_DROP_IF_EXISTS_OPT: Node = Node::Optional(&Node::Seq(SQL_DROP_IF_EXISTS_NODES));
static SQL_DROP_TABLE_SHAPE_NODES: &[Node] = &[
Node::Word(Word::keyword("table")),
@@ -257,9 +256,9 @@ const DR_PARENT_NODES: &[Node] = &[
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct('.'),
Node::Ident {
@@ -270,9 +269,9 @@ const DR_PARENT_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const DR_PARENT: Node = Node::Seq(DR_PARENT_NODES);
@@ -286,9 +285,9 @@ const DR_CHILD_NODES: &[Node] = &[
writes_table: true,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct('.'),
Node::Ident {
@@ -299,9 +298,9 @@ const DR_CHILD_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
const DR_CHILD: Node = Node::Seq(DR_CHILD_NODES);
@@ -317,10 +316,7 @@ const DR_ENDPOINTS: Node = Node::Seq(DR_ENDPOINTS_NODES);
const DR_SELECTOR_CHOICES: &[Node] = &[DR_ENDPOINTS, RELATIONSHIP_NAME];
const DR_SELECTOR: Node = Node::Choice(DR_SELECTOR_CHOICES);
const DROP_RELATIONSHIP_NODES: &[Node] = &[
Node::Word(Word::keyword("relationship")),
DR_SELECTOR,
];
const DROP_RELATIONSHIP_NODES: &[Node] = &[Node::Word(Word::keyword("relationship")), DR_SELECTOR];
const DROP_RELATIONSHIP: Node = Node::Seq(DROP_RELATIONSHIP_NODES);
// =================================================================
@@ -341,18 +337,20 @@ const DI_POSITIONAL: Node = Node::Seq(DI_POSITIONAL_NODES);
const DI_SELECTOR_CHOICES: &[Node] = &[DI_POSITIONAL, INDEX_NAME_EXISTING];
const DI_SELECTOR: Node = Node::Choice(DI_SELECTOR_CHOICES);
const DROP_INDEX_NODES: &[Node] = &[
Node::Word(Word::keyword("index")),
DI_SELECTOR,
];
const DROP_INDEX_NODES: &[Node] = &[Node::Word(Word::keyword("index")), DI_SELECTOR];
const DROP_INDEX: Node = Node::Seq(DROP_INDEX_NODES);
// =================================================================
// drop entry — `drop (table|column|relationship|index) ...`
// =================================================================
const DROP_CHOICES: &[Node] =
&[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE, DROP_INDEX, DROP_CONSTRAINT];
const DROP_CHOICES: &[Node] = &[
DROP_COLUMN,
DROP_RELATIONSHIP,
DROP_TABLE,
DROP_INDEX,
DROP_CONSTRAINT,
];
const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES);
// =================================================================
@@ -450,8 +448,7 @@ const AR_CHILD_COL_LIST: Node = Node::Repeated {
separator: Some(&Node::Punct(',')),
min: 1,
};
const AR_CHILD_COLS_PAREN_NODES: &[Node] =
&[Node::Punct('('), AR_CHILD_COL_LIST, Node::Punct(')')];
const AR_CHILD_COLS_PAREN_NODES: &[Node] = &[Node::Punct('('), AR_CHILD_COL_LIST, Node::Punct(')')];
const AR_CHILD_COLS_PAREN: Node = Node::Seq(AR_CHILD_COLS_PAREN_NODES);
const AR_CHILD_COLS_CHOICES: &[Node] = &[AR_CHILD_COLS_PAREN, AR_CHILD_COL];
const AR_CHILD_COLS: Node = Node::Choice(AR_CHILD_COLS_CHOICES);
@@ -474,10 +471,7 @@ const AR_CHILD_NODES: &[Node] = &[
];
const AR_CHILD: Node = Node::Seq(AR_CHILD_NODES);
const AR_AS_NAME_NODES: &[Node] = &[
Node::Word(Word::keyword("as")),
RELATIONSHIP_NAME_NEW,
];
const AR_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), RELATIONSHIP_NAME_NEW];
const AR_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AR_AS_NAME_NODES));
const AR_CREATE_FK_OPT: Node = Node::Optional(&Node::Flag("create-fk"));
@@ -501,10 +495,7 @@ const ADD_RELATIONSHIP: Node = Node::Seq(ADD_RELATIONSHIP_NODES);
// add_index — `add index [as <name>] on <T> (<col>, …)`
// =================================================================
const AI_AS_NAME_NODES: &[Node] = &[
Node::Word(Word::keyword("as")),
INDEX_NAME_NEW,
];
const AI_AS_NAME_NODES: &[Node] = &[Node::Word(Word::keyword("as")), INDEX_NAME_NEW];
const AI_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AI_AS_NAME_NODES));
const ADD_INDEX_NODES: &[Node] = &[
@@ -537,9 +528,9 @@ const NEW_COLUMN_NAME_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const NEW_COLUMN_NAME: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -563,10 +554,7 @@ const RENAME_COLUMN: Node = Node::Seq(RENAME_COLUMN_NODES);
// ( <type> ) [--force-conversion | --dont-convert]`
// =================================================================
const CHANGE_FLAG_CHOICES: &[Node] = &[
Node::Flag("force-conversion"),
Node::Flag("dont-convert"),
];
const CHANGE_FLAG_CHOICES: &[Node] = &[Node::Flag("force-conversion"), Node::Flag("dont-convert")];
const CHANGE_FLAG_OPT: Node = Node::Repeated {
inner: &Node::Choice(CHANGE_FLAG_CHOICES),
separator: None,
@@ -732,8 +720,7 @@ fn build_add(path: &MatchedPath, _source: &str) -> Result<Command, ValidationErr
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?;
let (not_null, unique, default, check) =
collect_column_constraints(path)?;
let (not_null, unique, default, check) = collect_column_constraints(path)?;
Ok(Command::AddColumn {
table: require_ident(path, "table_name")?,
column: require_ident(path, "column_name")?,
@@ -949,7 +936,10 @@ fn build_drop_constraint(path: &MatchedPath, _source: &str) -> Result<Command, V
} else {
return Err(ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "drop constraint needs a constraint kind".to_string())],
args: vec![(
"detail",
"drop constraint needs a constraint kind".to_string(),
)],
});
};
Ok(Command::DropConstraint {
@@ -968,39 +958,62 @@ pub static DROP: CommandNode = CommandNode {
shape: DROP_SHAPE,
ast_builder: build_drop,
help_id: Some("ddl.drop"),
hint_ids: &[
"drop_table",
"drop_column",
"drop_relationship",
"drop_index",
"drop_constraint",
],
usage_ids: &[
"parse.usage.drop_table",
"parse.usage.drop_column",
"parse.usage.drop_relationship",
"parse.usage.drop_index",
"parse.usage.drop_constraint",
],};
],
};
pub static ADD: CommandNode = CommandNode {
entry: Word::keyword("add"),
shape: ADD_SHAPE,
ast_builder: build_add,
help_id: Some("ddl.add"),
// Per-form (ADR-0053 D3): every form is listed so the form-word
// disambiguation resolves correctly; forms without an authored
// block yet fall back to tier-2 at render. `add_relationship` is
// authored as a Phase-B exemplar.
hint_ids: &[
"add_column",
"add_relationship",
"add_index",
"add_constraint",
],
usage_ids: &[
"parse.usage.add_column",
"parse.usage.add_relationship",
"parse.usage.add_index",
"parse.usage.add_constraint",
],};
],
};
pub static RENAME: CommandNode = CommandNode {
entry: Word::keyword("rename"),
shape: RENAME_COLUMN,
ast_builder: build_rename_column,
help_id: Some("ddl.rename"),
usage_ids: &["parse.usage.rename_column"],};
hint_ids: &["rename_column"],
usage_ids: &["parse.usage.rename_column"],
};
pub static CHANGE: CommandNode = CommandNode {
entry: Word::keyword("change"),
shape: CHANGE_COLUMN,
ast_builder: build_change_column,
help_id: Some("ddl.change"),
usage_ids: &["parse.usage.change_column"],};
hint_ids: &["change_column"],
usage_ids: &["parse.usage.change_column"],
};
// =================================================================
// create_table — `create table <Name> [with pk [<col>(<type>)[, ...]]]`
@@ -1015,9 +1028,9 @@ const COL_NAME_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
const COL_NAME: Node = Node::Hinted {
mode: NEW_NAME_HINT,
@@ -1055,8 +1068,12 @@ const CHECK_CONSTRAINT_NODES: &[Node] = &[
];
const CHECK_CONSTRAINT: Node = Node::Seq(CHECK_CONSTRAINT_NODES);
const COLUMN_CONSTRAINT_CHOICES: &[Node] =
&[NOT_NULL_CONSTRAINT, UNIQUE_CONSTRAINT, DEFAULT_CONSTRAINT, CHECK_CONSTRAINT];
const COLUMN_CONSTRAINT_CHOICES: &[Node] = &[
NOT_NULL_CONSTRAINT,
UNIQUE_CONSTRAINT,
DEFAULT_CONSTRAINT,
CHECK_CONSTRAINT,
];
const COLUMN_CONSTRAINT: Node = Node::Choice(COLUMN_CONSTRAINT_CHOICES);
/// Zero-or-more constraints — the suffix after a column's
@@ -1095,8 +1112,7 @@ const DROP_CONSTRAINT_KIND: Node = Node::Choice(DROP_CONSTRAINT_KIND_CHOICES);
// `writes_table: true` on the table ident (via `TABLE_NAME_
// EXISTING`) narrows the `.<column>` slot's completion
// candidates to that table's columns.
const CONSTRAINT_TARGET_NODES: &[Node] =
&[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME];
const CONSTRAINT_TARGET_NODES: &[Node] = &[TABLE_NAME_EXISTING, Node::Punct('.'), COLUMN_NAME];
const CONSTRAINT_TARGET: Node = Node::Seq(CONSTRAINT_TARGET_NODES);
const ADD_CONSTRAINT_NODES: &[Node] = &[
@@ -1126,9 +1142,9 @@ const COL_SPEC_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct(')'),
COLUMN_CONSTRAINT_SUFFIX,
@@ -1256,10 +1272,14 @@ fn build_create_table(path: &MatchedPath, _source: &str) -> Result<Command, Vali
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
MatchedKind::Ident { role: "col_name", .. } => {
MatchedKind::Ident {
role: "col_name", ..
} => {
pending_name = Some(item.text.clone());
}
MatchedKind::Ident { role: "col_type", .. } => {
MatchedKind::Ident {
role: "col_type", ..
} => {
let ty = item.text.parse::<Type>().map_err(|_| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
@@ -1360,7 +1380,9 @@ pub static CREATE: CommandNode = CommandNode {
shape: CREATE_TABLE,
ast_builder: build_create_table,
help_id: Some("ddl.create"),
usage_ids: &["parse.usage.create_table"],};
hint_ids: &["create_table"],
usage_ids: &["parse.usage.create_table"],
};
// =================================================================
// create_m2n — `create m:n relationship from <T1> to <T2> [as <name>]`
@@ -1428,6 +1450,7 @@ pub static CREATE_M2N: CommandNode = CommandNode {
shape: CREATE_M2N_SHAPE,
ast_builder: build_create_m2n,
help_id: Some("ddl.create_m2n"),
hint_ids: &["create_m2n"],
usage_ids: &["parse.usage.create_m2n"],
};
@@ -1485,11 +1508,15 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
while let Some(item) = items.next() {
match &item.kind {
// A column name stashes until its type finalises the spec.
MatchedKind::Ident { role: "col_name", .. } => {
MatchedKind::Ident {
role: "col_name", ..
} => {
pending_name = Some(item.text.clone());
}
// Single-word type — resolve through the SQL alias map.
MatchedKind::Ident { role: "col_type", .. } => {
MatchedKind::Ident {
role: "col_type", ..
} => {
let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
@@ -1512,7 +1539,9 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
column_open = true;
}
// A table-level `PRIMARY KEY (col, …)` column reference.
MatchedKind::Ident { role: "pk_column", .. } => {
MatchedKind::Ident {
role: "pk_column", ..
} => {
primary_key.push(item.text.clone());
}
// `not null` column constraint (only once a column exists;
@@ -1536,7 +1565,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
let mut cols: Vec<String> = Vec::new();
while let Some(it) = items.peek() {
match &it.kind {
MatchedKind::Ident { role: "unique_column", .. } => {
MatchedKind::Ident {
role: "unique_column",
..
} => {
cols.push(it.text.clone());
items.next();
}
@@ -1554,7 +1586,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
// column's flag (round-trips via the single-column
// path); composite (or a name not among the
// columns) becomes a constraint.
match columns.iter_mut().find(|c| cols.len() == 1 && c.name == cols[0]) {
match columns
.iter_mut()
.find(|c| cols.len() == 1 && c.name == cols[0])
{
Some(c) => c.unique = true,
None if !cols.is_empty() => unique_constraints.push(cols),
None => {}
@@ -1567,16 +1602,17 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
// the most recent column) or the table-level clause (whose
// `pk_column` idents follow and are collected above).
MatchedKind::Word("primary") => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("key"))
) {
items.next();
// Table-level `PRIMARY KEY (…)` is followed by `(`
// (then `pk_column` idents, collected above);
// column-level `PRIMARY KEY` is not, and marks the
// most-recent column.
let table_level = matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Punct('('))
);
let table_level =
matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('(')));
if !table_level && let Some(last) = columns.last() {
primary_key.push(last.name.clone());
}
@@ -1626,12 +1662,20 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
// Inline FK is single-column (the column it sits on);
// a compound FK uses the table-level form (ADR-0043 D4).
let child_column = columns.last().map_or_else(String::new, |c| c.name.clone());
foreign_keys.push(consume_fk_reference(&mut items, None, vec![child_column], true));
foreign_keys.push(consume_fk_reference(
&mut items,
None,
vec![child_column],
true,
));
}
// Table-level `[constraint <name>] foreign key (<col>)
// references <parent> [(<col>)] [on …]` (ADR-0035 §5, 4b).
MatchedKind::Word("foreign") => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("key"))
) {
items.next(); // `key`
}
// `( <child column> [, <child column>]* )` — a compound
@@ -1653,7 +1697,10 @@ fn build_sql_create_table(path: &MatchedPath, source: &str) -> Result<Command, V
items.next();
}
// `references <parent> …`
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("references"))
) {
items.next();
}
let fk =
@@ -1838,13 +1885,19 @@ where
Some(MatchedKind::Word("cascade")) => ReferentialAction::Cascade,
Some(MatchedKind::Word("restrict")) => ReferentialAction::Restrict,
Some(MatchedKind::Word("set")) => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("null"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("null"))
) {
items.next();
}
ReferentialAction::SetNull
}
Some(MatchedKind::Word("no")) => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("action"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("action"))
) {
items.next();
}
ReferentialAction::NoAction
@@ -1858,6 +1911,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: &["sql_create_table"],
usage_ids: &["parse.usage.sql_create_table"],
};
@@ -1877,6 +1931,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: &["sql_drop_table"],
usage_ids: &["parse.usage.sql_drop_table"],
};
@@ -1896,6 +1951,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: &["sql_drop_index"],
usage_ids: &["parse.usage.sql_drop_index"],
};
@@ -1909,11 +1965,12 @@ pub static SQL_DROP_INDEX: CommandNode = CommandNode {
// concrete keyword (`unique index` | `index`) — the trap-safe form (the
// §3 rule forbids a leading *Optional*, not a leading `Choice`). The
// builder reads `unique` presence via `contains_word("unique")`.
static SQL_CI_UNIQUE_INDEX_NODES: &[Node] =
&[Node::Word(Word::keyword("unique")), Node::Word(Word::keyword("index"))];
static SQL_CI_UNIQUE_INDEX_NODES: &[Node] = &[
Node::Word(Word::keyword("unique")),
Node::Word(Word::keyword("index")),
];
const SQL_CI_UNIQUE_INDEX: Node = Node::Seq(SQL_CI_UNIQUE_INDEX_NODES);
static SQL_CI_LEAD_CHOICES: &[Node] =
&[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
static SQL_CI_LEAD_CHOICES: &[Node] = &[SQL_CI_UNIQUE_INDEX, Node::Word(Word::keyword("index"))];
const SQL_CI_LEAD: Node = Node::Choice(SQL_CI_LEAD_CHOICES);
static SQL_CI_IF_NOT_EXISTS_NODES: &[Node] = &[
@@ -1977,6 +2034,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: &["sql_create_index"],
usage_ids: &["parse.usage.sql_create_index"],
};
@@ -2079,8 +2137,7 @@ static AT_RENAME_COLUMN_TAIL_NODES: &[Node] = &[
NEW_COLUMN_NAME,
];
const AT_RENAME_COLUMN_TAIL: Node = Node::Seq(AT_RENAME_COLUMN_TAIL_NODES);
static AT_RENAME_TABLE_TAIL_NODES: &[Node] =
&[Node::Word(Word::keyword("to")), NEW_TABLE_NAME];
static AT_RENAME_TABLE_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("to")), NEW_TABLE_NAME];
const AT_RENAME_TABLE_TAIL: Node = Node::Seq(AT_RENAME_TABLE_TAIL_NODES);
static AT_RENAME_TAIL_CHOICES: &[Node] = &[AT_RENAME_COLUMN_TAIL, AT_RENAME_TABLE_TAIL];
const AT_RENAME_TAIL: Node = Node::Choice(AT_RENAME_TAIL_CHOICES);
@@ -2107,8 +2164,10 @@ static AT_AC_TYPE_NODES: &[Node] = &[
super::sql_create_table::SQL_TYPE,
];
const AT_AC_TYPE: Node = Node::Seq(AT_AC_TYPE_NODES);
static AT_AC_NOT_NULL_NODES: &[Node] =
&[Node::Word(Word::keyword("not")), Node::Word(Word::keyword("null"))];
static AT_AC_NOT_NULL_NODES: &[Node] = &[
Node::Word(Word::keyword("not")),
Node::Word(Word::keyword("null")),
];
const AT_AC_NOT_NULL: Node = Node::Seq(AT_AC_NOT_NULL_NODES);
static AT_AC_SET_DATA_TYPE_NODES: &[Node] = &[
Node::Word(Word::keyword("data")),
@@ -2124,8 +2183,7 @@ static AT_AC_SET_TAIL_CHOICES: &[Node] = &[
const AT_AC_SET_TAIL: Node = Node::Choice(AT_AC_SET_TAIL_CHOICES);
static AT_AC_SET_NODES: &[Node] = &[Node::Word(Word::keyword("set")), AT_AC_SET_TAIL];
const AT_AC_SET: Node = Node::Seq(AT_AC_SET_NODES);
static AT_AC_DROP_TAIL_CHOICES: &[Node] =
&[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))];
static AT_AC_DROP_TAIL_CHOICES: &[Node] = &[AT_AC_NOT_NULL, Node::Word(Word::keyword("default"))];
const AT_AC_DROP_TAIL: Node = Node::Choice(AT_AC_DROP_TAIL_CHOICES);
static AT_AC_DROP_NODES: &[Node] = &[Node::Word(Word::keyword("drop")), AT_AC_DROP_TAIL];
const AT_AC_DROP: Node = Node::Seq(AT_AC_DROP_NODES);
@@ -2233,10 +2291,14 @@ fn build_alter_add_column_spec(
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
MatchedKind::Ident { role: "col_name", .. } => {
MatchedKind::Ident {
role: "col_name", ..
} => {
pending_name = Some(item.text.clone());
}
MatchedKind::Ident { role: "col_type", .. } => {
MatchedKind::Ident {
role: "col_type", ..
} => {
let ty = Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
@@ -2255,7 +2317,10 @@ fn build_alter_add_column_spec(
spec = Some(ColumnSpec::new(name, Type::Real));
}
MatchedKind::Word("not") => {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("null"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("null"))
) {
items.next();
if let Some(s) = spec.as_mut() {
s.not_null = true;
@@ -2301,11 +2366,15 @@ fn build_alter_column_type(path: &MatchedPath) -> Result<AlterTableAction, Valid
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
match &item.kind {
MatchedKind::Ident { role: "col_type", .. } => {
ty = Some(Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?);
MatchedKind::Ident {
role: "col_type", ..
} => {
ty = Some(
Type::from_sql_name(&item.text).ok_or_else(|| ValidationError {
message_key: "parse.error_wrapper",
args: vec![("detail", "unknown type".to_string())],
})?,
);
}
MatchedKind::Word("double") => {
if matches!(
@@ -2354,7 +2423,10 @@ fn build_alter_column_attr(
message_key: "parse.error_wrapper",
args: vec![("detail", "set default needs a value".to_string())],
})?;
AlterTableAction::SetColumnDefault { column, default_sql }
AlterTableAction::SetColumnDefault {
column,
default_sql,
}
}
(false, true) => AlterTableAction::DropColumnDefault { column },
(true, false) => AlterTableAction::SetColumnNotNull { column },
@@ -2470,10 +2542,7 @@ fn build_alter_add_table_constraint(
/// Capture the raw SQL text of an `ADD … CHECK (<expr>)` (ADR-0035 §4g).
/// `sql_expr` is validate-only, so the expression is captured by byte
/// span — the 4a.2 / 4e mechanism.
fn capture_table_check_sql(
path: &MatchedPath,
source: &str,
) -> Result<String, ValidationError> {
fn capture_table_check_sql(path: &MatchedPath, source: &str) -> Result<String, ValidationError> {
let mut items = path.items.iter().peekable();
while let Some(item) = items.next() {
if matches!(item.kind, MatchedKind::Word("check"))
@@ -2503,7 +2572,10 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
items.next();
}
items.next(); // `foreign`
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("key"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("key"))
) {
items.next();
}
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct('('))) {
@@ -2523,7 +2595,10 @@ fn build_alter_fk(path: &MatchedPath) -> SqlForeignKey {
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Punct(')'))) {
items.next();
}
if matches!(items.peek().map(|i| &i.kind), Some(MatchedKind::Word("references"))) {
if matches!(
items.peek().map(|i| &i.kind),
Some(MatchedKind::Word("references"))
) {
items.next();
}
// `ALTER TABLE … ADD FOREIGN KEY (…)` is the table-level form.
@@ -2535,6 +2610,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: &["sql_alter_table"],
usage_ids: &["parse.usage.sql_alter_table"],
};
@@ -2600,7 +2676,10 @@ mod constraint_tests {
fn an_unconstrained_create_table_still_parses() {
let cols = create_columns("create table T with pk id(serial), name(text)");
assert_eq!(cols.len(), 2);
assert!(cols.iter().all(|c| !c.not_null && !c.unique && c.default.is_none()));
assert!(
cols.iter()
.all(|c| !c.not_null && !c.unique && c.default.is_none())
);
}
#[test]
@@ -2625,7 +2704,9 @@ mod constraint_tests {
#[test]
fn add_column_parses_a_unique_constraint() {
match parse_command("add column to T: email (text) unique").expect("parse") {
Command::AddColumn { unique, not_null, .. } => {
Command::AddColumn {
unique, not_null, ..
} => {
assert!(unique);
assert!(!not_null);
}
@@ -2656,9 +2737,7 @@ mod constraint_tests {
fn check_with_a_parenthesised_sub_expression_parses() {
// The check's own parens plus a nested group — the
// builder's paren-depth scan must pair them correctly.
let cols = create_columns(
"create table T with pk n(int) check ((n > 0) or (n < -10))",
);
let cols = create_columns("create table T with pk n(int) check ((n > 0) or (n < -10))");
assert!(cols[0].check.is_some());
}
@@ -2705,8 +2784,7 @@ mod constraint_tests {
#[test]
fn add_constraint_check_parses() {
match parse_command("add constraint check (age >= 0) to Users.age").expect("parse")
{
match parse_command("add constraint check (age >= 0) to Users.age").expect("parse") {
Command::AddConstraint {
column, constraint, ..
} => {
@@ -2800,8 +2878,11 @@ mod sql_drop_table_tests {
Command::DropColumn { .. }
));
assert!(matches!(
parse_command_in_mode("drop relationship Customers_id_to_Orders_CustId", Mode::Advanced)
.expect("parses"),
parse_command_in_mode(
"drop relationship Customers_id_to_Orders_CustId",
Mode::Advanced
)
.expect("parses"),
Command::DropRelationship { .. }
));
}
@@ -2906,7 +2987,13 @@ mod sql_create_index_tests {
columns,
unique,
if_not_exists,
} => Ci { name, table, columns, unique, if_not_exists },
} => Ci {
name,
table,
columns,
unique,
if_not_exists,
},
other => panic!("expected SqlCreateIndex, got {other:?}"),
}
}
@@ -3108,7 +3195,9 @@ mod sql_alter_table_tests {
// The target slot carries the `reject_internal_table` validator
// (mirroring CREATE TABLE), so an `__rdbms_*` target is refused
// before submit — engine-neutral, not a raw engine error.
assert!(parse_command_in_mode("alter table T rename to __rdbms_evil", Mode::Advanced).is_err());
assert!(
parse_command_in_mode("alter table T rename to __rdbms_evil", Mode::Advanced).is_err()
);
}
#[test]
@@ -3187,7 +3276,10 @@ mod sql_alter_table_tests {
// alias map still applies through the synonym
assert!(matches!(
alter("alter table T alter column n set data type double precision").1,
AlterTableAction::AlterColumnType { ty: crate::dsl::types::Type::Real, .. }
AlterTableAction::AlterColumnType {
ty: crate::dsl::types::Type::Real,
..
}
));
}
@@ -3212,7 +3304,10 @@ mod sql_alter_table_tests {
#[test]
fn alter_column_set_default_captures_raw_expr() {
match alter("alter table T alter column qty set default 0").1 {
AlterTableAction::SetColumnDefault { column, default_sql } => {
AlterTableAction::SetColumnDefault {
column,
default_sql,
} => {
assert_eq!(column, "qty");
assert_eq!(default_sql, "0");
}
@@ -3291,7 +3386,9 @@ mod sql_alter_table_tests {
match alter("alter table T add check (a < b)").1 {
AlterTableAction::AddTableConstraint { name, constraint } => {
assert_eq!(name, None);
assert!(matches!(*constraint, TableConstraint::Check { ref expr_sql } if expr_sql == "a < b"));
assert!(
matches!(*constraint, TableConstraint::Check { ref expr_sql } if expr_sql == "a < b")
);
}
other => panic!("expected AddTableConstraint/Check, got {other:?}"),
}
@@ -3309,7 +3406,9 @@ mod sql_alter_table_tests {
match alter("alter table T add unique (a, b)").1 {
AlterTableAction::AddTableConstraint { name, constraint } => {
assert_eq!(name, None);
assert!(matches!(*constraint, TableConstraint::Unique { ref columns } if columns == &["a".to_string(), "b".to_string()]));
assert!(
matches!(*constraint, TableConstraint::Unique { ref columns } if columns == &["a".to_string(), "b".to_string()])
);
}
other => panic!("expected AddTableConstraint/Unique, got {other:?}"),
}
@@ -3326,7 +3425,9 @@ mod sql_alter_table_tests {
)
.expect_err("a named UNIQUE constraint is refused");
assert!(
err.to_string().to_lowercase().contains("unique constraint cannot be named"),
err.to_string()
.to_lowercase()
.contains("unique constraint cannot be named"),
"expected the builder's named-UNIQUE refusal, got: {err}"
);
}
@@ -3338,7 +3439,9 @@ mod sql_alter_table_tests {
let err = parse_command_in_mode("alter table T add primary key (id)", Mode::Advanced)
.expect_err("ADD PRIMARY KEY is refused");
assert!(
err.to_string().to_lowercase().contains("primary key is fixed at creation"),
err.to_string()
.to_lowercase()
.contains("primary key is fixed at creation"),
"expected the builder's ADD-PRIMARY-KEY refusal, got: {err}"
);
}
@@ -3366,7 +3469,10 @@ mod sql_alter_table_tests {
assert_eq!(name.as_deref(), Some("fk_p"));
match *constraint {
TableConstraint::ForeignKey(fk) => {
assert_eq!(fk.parent_columns, None, "bare reference resolves at execution");
assert_eq!(
fk.parent_columns, None,
"bare reference resolves at execution"
);
}
other => panic!("expected ForeignKey, got {other:?}"),
}
+24 -35
View File
@@ -79,9 +79,9 @@ const EXPR_COLUMN: Node = Node::Ident {
writes_table: false,
writes_column: true,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
/// Operand alternatives. The literal keywords (`null` / `true`
@@ -126,8 +126,7 @@ fn where_rhs_operand(ctx: &WalkContext) -> Node {
// the leak is per distinct column (the walker
// memoizes `DynamicSubgrammar` resolution on
// `current_column`), not per keystroke.
let leaked: &'static str =
Box::leak(col.name.clone().into_boxed_str());
let leaked: &'static str = Box::leak(col.name.clone().into_boxed_str());
Node::TypedValueSlot {
ty: col.user_type,
column_name: Some(leaked),
@@ -260,10 +259,8 @@ static PAREN_GROUP_NODES: &[Node] = &[
Node::Subgrammar(&OR_EXPR),
Node::Punct(')'),
];
static BOOL_PRIMARY_CHOICES: &[Node] = &[
Node::Seq(PAREN_GROUP_NODES),
Node::Subgrammar(&PREDICATE),
];
static BOOL_PRIMARY_CHOICES: &[Node] =
&[Node::Seq(PAREN_GROUP_NODES), Node::Subgrammar(&PREDICATE)];
static BOOL_PRIMARY: Node = Node::Choice(BOOL_PRIMARY_CHOICES);
/// `not_expr := NOT not_expr | bool_primary`.
@@ -271,10 +268,7 @@ static NOT_FORM_NODES: &[Node] = &[
Node::Word(Word::keyword("not")),
Node::Subgrammar(&NOT_EXPR),
];
static NOT_EXPR_CHOICES: &[Node] = &[
Node::Seq(NOT_FORM_NODES),
Node::Subgrammar(&BOOL_PRIMARY),
];
static NOT_EXPR_CHOICES: &[Node] = &[Node::Seq(NOT_FORM_NODES), Node::Subgrammar(&BOOL_PRIMARY)];
static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
/// `and_expr := not_expr ( AND not_expr )*`.
@@ -296,10 +290,7 @@ static AND_EXPR: Node = Node::Seq(AND_EXPR_NODES);
/// `or_expr := and_expr ( OR and_expr )*` — the fragment entry
/// point. `update` / `delete` / `show data` reference this
/// through `Node::Subgrammar(&OR_EXPR)`.
static OR_TAIL_NODES: &[Node] = &[
Node::Word(Word::keyword("or")),
Node::Subgrammar(&AND_EXPR),
];
static OR_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("or")), Node::Subgrammar(&AND_EXPR)];
static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES);
static OR_EXPR_NODES: &[Node] = &[
Node::Subgrammar(&AND_EXPR),
@@ -534,18 +525,18 @@ impl<'a> ExprParser<'a> {
let span = item.span;
let literal = |value: Value| Operand::Literal { value, span };
match &item.kind {
MatchedKind::Ident { role: "expr_column", .. } => {
Ok(Operand::Column { name: item.text.clone(), span })
}
MatchedKind::Ident {
role: "expr_column",
..
} => Ok(Operand::Column {
name: item.text.clone(),
span,
}),
MatchedKind::Word("null") => Ok(literal(Value::Null)),
MatchedKind::Word("true") => Ok(literal(Value::Bool(true))),
MatchedKind::Word("false") => Ok(literal(Value::Bool(false))),
MatchedKind::NumberLit => {
Ok(literal(Value::Number(item.text.clone())))
}
MatchedKind::StringLit => {
Ok(literal(Value::Text(item.text.clone())))
}
MatchedKind::NumberLit => Ok(literal(Value::Number(item.text.clone()))),
MatchedKind::StringLit => Ok(literal(Value::Text(item.text.clone()))),
_ => Err(drift_error("expected a column or literal operand")),
}
}
@@ -591,8 +582,7 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
let result =
walk_node(input, 0, &OR_EXPR, &mut ctx, &mut path, &mut per_byte);
let result = walk_node(input, 0, &OR_EXPR, &mut ctx, &mut path, &mut per_byte);
match result {
NodeWalkResult::Matched { end, .. } => {
assert!(
@@ -730,8 +720,7 @@ mod tests {
negated: false,
}),
);
let Expr::Predicate(Predicate::Like { negated, .. }) =
parse_expr("Name not like 'A%'")
let Expr::Predicate(Predicate::Like { negated, .. }) = parse_expr("Name not like 'A%'")
else {
panic!("expected a negated Like");
};
@@ -794,16 +783,16 @@ mod tests {
fn nested_parentheses_round_trip() {
// Exercises the Subgrammar recursion a few levels deep.
let expr = parse_expr("((a = 1 and b = 2) or (c = 3))");
assert!(matches!(expr, Expr::Or(_) | Expr::And(_) | Expr::Predicate(_)));
assert!(matches!(
expr,
Expr::Or(_) | Expr::And(_) | Expr::Predicate(_)
));
}
#[test]
fn case_insensitive_keywords() {
// Keywords fold case; the built tree is identical.
assert_eq!(
parse_expr("a = 1 AND b = 2"),
parse_expr("a = 1 and b = 2"),
);
assert_eq!(parse_expr("a = 1 AND b = 2"), parse_expr("a = 1 and b = 2"),);
assert_eq!(
parse_expr("Email IS NOT NULL"),
parse_expr("Email is not null"),
+304 -60
View File
@@ -27,9 +27,9 @@ pub mod data;
pub mod ddl;
pub mod expr;
pub mod shared;
pub mod sql_expr;
pub mod sql_create_table;
pub mod sql_delete;
pub mod sql_expr;
pub mod sql_insert;
pub mod sql_select;
pub mod sql_update;
@@ -328,9 +328,7 @@ pub enum Node {
/// A number literal. The optional `validator` runs against
/// the matched text (used by Phase D value slots to enforce
/// per-type integer/decimal rules).
NumberLit {
validator: Option<NumberValidator>,
},
NumberLit { validator: Option<NumberValidator> },
/// A literal byte sequence at this position — matches
/// bytes verbatim (whitespace-skipped) with a lookahead so
/// `1` doesn't half-match `12` and `n` doesn't half-match
@@ -530,6 +528,18 @@ pub struct CommandNode {
/// so a newly-registered command appears in `help`
/// automatically (ADR-0024 §help_id).
pub help_id: Option<&'static str>,
/// Catalog key stems (`hint.cmd.<id>`) for this command's
/// **tier-3** contextual hints (ADR-0053 / H2), **one per form**,
/// mirroring `usage_ids`. A single-form command carries one; a
/// multi-form command (`add`, `drop`, `show`, `create`) carries
/// one per form so a live-input hint can be specific to the form
/// being typed (`hint.cmd.add_relationship`, not a shared `add`
/// block). `hint_key_for_input_in_mode` disambiguates by the form
/// word, reusing `usage_key_for_input_in_mode`'s logic. Empty
/// until a form's tier-3 block is authored (the surface falls back
/// to tier-2 ambient/error text). Distinct from `help_id` (which is
/// `None` on advanced-SQL forms purely to dedup the `help` list).
pub hint_ids: &'static [&'static str],
/// Catalog keys under `parse.usage.*` to render in the
/// "usage:" block when a parse error fires for this command
/// (ADR-0021 §1, ADR-0024 §architecture). Multi-form families
@@ -574,32 +584,100 @@ pub fn usage_keys_for_input_in_mode(
source: &str,
mode: crate::mode::Mode,
) -> Option<(&'static str, Vec<&'static str>)> {
let pick = selected_nodes_for_input_in_mode(source, mode);
if pick.is_empty() {
return None;
}
let mut keys: Vec<&'static str> = Vec::new();
for (_, node, _) in &pick {
for k in node.usage_ids {
if !keys.contains(k) {
keys.push(*k);
}
}
}
if keys.is_empty() {
return None;
}
let entry = pick[0].1.entry.primary;
Some((entry, keys))
}
/// The single tier-3 hint key (`hint.cmd.<id>` stem) for the command
/// **form** `source` is currently typing, in `mode` (H2 / ADR-0053).
///
/// Mirrors [`usage_key_for_input_in_mode`]: the union of the
/// mode-selected nodes' `hint_ids`, disambiguated to the typed form by
/// [`pick_form_key`] — so `add 1:n relationship` resolves to the
/// relationship hint, and an advanced-SQL form resolves to its own
/// (not its simple sibling's). `None` if no entry word matches or the
/// form has no tier-3 block yet (the caller falls back to tier-2).
#[must_use]
pub fn hint_key_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> {
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 {
if !keys.contains(k) {
keys.push(*k);
}
}
}
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
/// 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 +699,11 @@ 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;
if selected.is_empty() {
candidates
} else {
selected
}
let entry = pick[0].1.entry.primary;
Some((entry, keys))
}
/// The single usage template most relevant to `source`, when
@@ -654,18 +726,25 @@ pub fn usage_key_for_input(source: &str) -> Option<&'static str> {
/// disambiguates the single most-relevant usage key from the
/// mode-selected key set.
#[must_use]
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};
pub fn usage_key_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> {
let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?;
pick_form_key(source, &keys)
}
/// From the form word after the entry keyword, pick the single `keys`
/// entry for the form `source` names.
///
/// A single-entry list resolves to its one key; a multi-form list
/// disambiguates by the form word (`add 1:n relationship` → the
/// `…relationship` key, `create m:n …` → the `…m2n` key, else the
/// identifier form word matched against each key's suffix). Shared by
/// the usage-template and tier-3-hint single-key lookups so they agree.
fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> {
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
let first = *keys.first()?;
if keys.len() == 1 {
return Some(first);
}
// Multi-form: the form is named by the token right after
// the entry keyword.
let start = skip_whitespace(source, 0);
let (_, entry_end) = consume_ident(source, start)?;
let after = skip_whitespace(source, entry_end);
@@ -674,14 +753,15 @@ 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`).
if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) {
// — 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()))
@@ -692,8 +772,7 @@ pub fn usage_key_for_input_in_mode(
/// which read the same data through the legacy `usage::REGISTRY`.
#[must_use]
pub fn entry_words_alphabetised() -> Vec<&'static str> {
let mut words: Vec<&'static str> =
REGISTRY.iter().map(|(c, _)| c.entry.primary).collect();
let mut words: Vec<&'static str> = REGISTRY.iter().map(|(c, _)| c.entry.primary).collect();
words.sort_unstable();
words.dedup();
words
@@ -712,6 +791,8 @@ 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::VERSION, CommandCategory::Simple),
(&app::REBUILD, CommandCategory::Simple),
(&app::SAVE, CommandCategory::Simple),
(&app::NEW, CommandCategory::Simple),
@@ -825,9 +906,7 @@ pub fn command_for_entry_word(word: &str) -> Option<(usize, &'static CommandNode
/// returns its `Simple` DSL node and `Advanced` SQL node. The
/// dispatcher picks among them by the active input mode.
#[must_use]
pub fn commands_for_entry_word(
word: &str,
) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
pub fn commands_for_entry_word(word: &str) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
REGISTRY
.iter()
.enumerate()
@@ -836,6 +915,177 @@ 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")
);
// 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")
);
// 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);
}
/// 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.<id>` 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.<class>` 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}`"
);
}
}
/// Semantic-verification guard (handoff-71): every `hint.cmd.<form>`
/// **example** must parse in the mode the form is taught for. This
/// backstops the bug class found in the H2 corpus pass — an example
/// that drifts out of the real grammar (a typo, a removed clause, or
/// an argument the command never accepted, e.g. an inline name on
/// `save as` which opens a modal instead). It cannot police the
/// *semantics* of an example that happens to parse (that is the
/// manual pass), but it locks the syntactic floor so future edits
/// can't ship an unparseable teaching line.
///
/// The mode per form mirrors `hint_key_for_input_in_mode`: the
/// advanced-SQL forms are taught in advanced mode; everything else
/// (DSL + app commands) in simple mode.
#[test]
fn every_cmd_hint_example_parses_in_its_mode() {
use crate::dsl::parser::parse_command_in_mode;
use crate::mode::Mode;
// Advanced-mode forms — the SQL surface (ADR-00300039). Every
// other form (DSL + app commands) is taught in simple mode. This
// mirrors the mode split `hint_key_for_input_in_mode` resolves.
const ADVANCED: &[&str] = &[
"sql_create_table",
"sql_alter_table",
"sql_create_index",
"sql_drop_index",
"sql_drop_table",
"sql_insert",
"sql_update",
"sql_delete",
"select",
"with",
"explain_sql",
];
// Iterate the *catalog* (the corpus is the source of truth), not the
// REGISTRY: this reaches every `hint.cmd.<id>` block including any
// not owned by a command node, so an orphaned or mis-keyed example
// can't slip past the guard.
let cat = crate::friendly::catalog();
let mut checked = 0usize;
for key in cat.keys() {
let Some(id) = key
.strip_prefix("hint.cmd.")
.and_then(|rest| rest.strip_suffix(".example"))
else {
continue;
};
let example = cat.get(key).expect("key came from the catalog");
let mode = if ADVANCED.contains(&id) {
Mode::Advanced
} else {
Mode::Simple
};
assert!(
parse_command_in_mode(example, mode).is_ok(),
"hint.cmd.{id}.example does not parse in {mode:?} mode: {example:?}",
);
checked += 1;
}
// Floor guard: the corpus had 49 command forms at the time of
// writing (ADR-0053). If this drops, a block (and its example
// coverage) silently vanished.
assert!(
checked >= 49,
"expected at least 49 hint.cmd.* examples, checked {checked}",
);
}
}
#[cfg(test)]
mod usage_key_tests {
use super::usage_key_for_input;
@@ -850,10 +1100,7 @@ mod usage_key_tests {
let cases = [
("add column to T: c (int)", "parse.usage.add_column"),
("add index on T (c)", "parse.usage.add_index"),
(
"add constraint unique to T.c",
"parse.usage.add_constraint",
),
("add constraint unique to T.c", "parse.usage.add_constraint"),
(
"drop constraint check from T.c",
"parse.usage.drop_constraint",
@@ -870,10 +1117,7 @@ mod usage_key_tests {
("drop table T", "parse.usage.drop_table"),
("drop column from table T: c", "parse.usage.drop_column"),
("drop index i", "parse.usage.drop_index"),
(
"drop relationship r",
"parse.usage.drop_relationship",
),
("drop relationship r", "parse.usage.drop_relationship"),
("show data T", "parse.usage.show_data"),
("show table T", "parse.usage.show_table"),
// `create` is multi-form (table vs m:n, ADR-0045): each typed
+17 -24
View File
@@ -7,8 +7,8 @@
use crate::completion::TableColumn;
use crate::dsl::grammar::{
HighlightClass, HintMode, IdentSource, IdentValidator, Node,
NumberValidator, ValidationError, Word,
HighlightClass, HintMode, IdentSource, IdentValidator, Node, NumberValidator, ValidationError,
Word,
};
use crate::dsl::types::Type;
use crate::dsl::walker::context::WalkContext;
@@ -32,10 +32,7 @@ pub fn validate_type_name(value: &str) -> Result<(), ValidationError> {
.join(", ");
Err(ValidationError {
message_key: "parse.custom.unknown_type",
args: vec![
("found", value.to_string()),
("expected", expected),
],
args: vec![("found", value.to_string()), ("expected", expected)],
})
}
}
@@ -51,12 +48,12 @@ pub const TYPE_SLOT: Node = Node::Ident {
role: "type",
validator: Some(TYPE_VALIDATOR),
highlight_override: Some(HighlightClass::Type),
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
// --- Qualified column reference (`<Table>.<Column>`) --------------
@@ -70,9 +67,9 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct('.'),
Node::Ident {
@@ -83,9 +80,9 @@ const QUALIFIED_COLUMN_NODES: &[Node] = &[
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
pub const QUALIFIED_COLUMN: Node = Node::Seq(QUALIFIED_COLUMN_NODES);
@@ -313,9 +310,7 @@ const fn slot_inner_for_type(ty: Type) -> &'static Node {
Type::Real => &REAL_SLOT_INNER,
Type::Decimal => &DECIMAL_SLOT_INNER,
Type::Bool => &BOOL_SLOT_INNER,
Type::Text | Type::Date | Type::DateTime | Type::Blob | Type::ShortId => {
&TEXT_SLOT_INNER
}
Type::Text | Type::Date | Type::DateTime | Type::Blob | Type::ShortId => &TEXT_SLOT_INNER,
}
}
@@ -397,9 +392,7 @@ pub(crate) const FALLBACK_VALUE_LIST: Node = Node::Repeated {
/// This is the single source of truth shared by [`column_value_list`]
/// (which builds the typed slots) and the `data.rs` arity gate (which
/// counts them) so the two never disagree (issue #17).
pub fn insert_target_columns<'c>(
ctx: &'c WalkContext<'_>,
) -> Option<Vec<&'c TableColumn>> {
pub fn insert_target_columns<'c>(ctx: &'c WalkContext<'_>) -> Option<Vec<&'c TableColumn>> {
let table_cols = ctx.current_table_columns.as_ref()?;
if table_cols.is_empty() {
return None;
+72 -22
View File
@@ -405,8 +405,14 @@ const TABLE_FK_NAMED: Node = Node::Seq(TABLE_FK_NAMED_NODES);
// / `foreign`) that disambiguates it from a column name. (A column
// literally named with one of those keywords is therefore unavailable,
// the same trade real SQL makes with its reserved words.)
static ELEMENT_CHOICES: &[Node] =
&[TABLE_PK, TABLE_UNIQUE, TABLE_CHECK, TABLE_FK_NAMED, TABLE_FK, COLUMN_DEF];
static ELEMENT_CHOICES: &[Node] = &[
TABLE_PK,
TABLE_UNIQUE,
TABLE_CHECK,
TABLE_FK_NAMED,
TABLE_FK,
COLUMN_DEF,
];
const ELEMENT_INNER: Node = Node::Choice(ELEMENT_CHOICES);
// Issue #4: wrap the element slot in `IntroProse` so a fresh element
// position (`create table T (` and after every `,`) surfaces a prose
@@ -495,18 +501,31 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, &SQL_CREATE_TABLE_SHAPE, &mut ctx, &mut path, &mut per_byte) {
match walk_node(
input,
0,
&SQL_CREATE_TABLE_SHAPE,
&mut ctx,
&mut path,
&mut per_byte,
) {
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
}
fn good(input: &str) {
assert!(walks(input), "{input:?} should be a valid CREATE TABLE tail");
assert!(
walks(input),
"{input:?} should be a valid CREATE TABLE tail"
);
}
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete CREATE TABLE tail");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete CREATE TABLE tail"
);
}
#[test]
@@ -638,7 +657,9 @@ mod tests {
good("table t (id int, ref int references other(id))");
good("table t (id int, ref int references other)"); // bare ref
good("table t (id int, ref int references other(id) on delete cascade)");
good("table t (id int, ref int references other(id) on update set null on delete restrict)");
good(
"table t (id int, ref int references other(id) on update set null on delete restrict)",
);
good("table t (id int, ref int, foreign key (ref) references other(id))");
good("table t (id int, ref int, constraint fk_x foreign key (ref) references other(id))");
good(
@@ -691,7 +712,10 @@ mod builder_tests {
assert_eq!(name, "t");
assert_eq!(
cols,
vec![("id".to_string(), Type::Int), ("name".to_string(), Type::Text)]
vec![
("id".to_string(), Type::Int),
("name".to_string(), Type::Text)
]
);
assert!(pk.is_empty(), "no PK declared");
assert!(!ine);
@@ -740,7 +764,10 @@ mod builder_tests {
let (_, cols, _, _) = sct("create table t (a varchar(255), b numeric(10, 2))");
assert_eq!(
cols,
vec![("a".to_string(), Type::Text), ("b".to_string(), Type::Decimal)]
vec![
("a".to_string(), Type::Text),
("b".to_string(), Type::Decimal)
]
);
}
@@ -780,8 +807,7 @@ mod builder_tests {
fn redundant_constraints_deduped_off_sole_pk_column() {
// ADR-0035 §6.5: advanced mode accepts the redundant spelling
// and silently drops the flags off the sole PK column.
match parse_command("create table t (id int primary key not null unique)")
.expect("parses")
match parse_command("create table t (id int primary key not null unique)").expect("parses")
{
Command::SqlCreateTable {
columns,
@@ -944,8 +970,7 @@ mod builder_tests {
// depth 2, not an element boundary, so the following `check`
// is still column-level. A naive "reset on any comma" would
// misclassify it as table-level (the §4.2 probe).
let (cols, checks) =
parse_sct_checks("create table t (n numeric(10, 2) check (n > 0))");
let (cols, checks) = parse_sct_checks("create table t (n numeric(10, 2) check (n > 0))");
assert_eq!(col(&cols, "n").check_sql.as_deref(), Some("n > 0"));
assert!(checks.is_empty(), "no table-level CHECK was produced");
}
@@ -977,8 +1002,7 @@ mod builder_tests {
fn table_check_before_a_later_column_is_table_level() {
// A CHECK element that appears between columns (not after a
// column's type) is table-level even though more columns follow.
let (cols, checks) =
parse_sct_checks("create table t (a int, check (a > 0), b int)");
let (cols, checks) = parse_sct_checks("create table t (a int, check (a > 0), b int)");
assert_eq!(checks, vec!["a > 0".to_string()]);
assert!(col(&cols, "a").check_sql.is_none() && col(&cols, "b").check_sql.is_none());
}
@@ -1004,7 +1028,10 @@ mod builder_tests {
assert_eq!(fk.parent_columns, Some(vec!["id".to_string()]));
assert_eq!(fk.on_delete, ReferentialAction::NoAction);
assert_eq!(fk.on_update, ReferentialAction::NoAction);
assert!(fk.inline, "a column-level `references` is an inline FK (ADR-0043 D4)");
assert!(
fk.inline,
"a column-level `references` is an inline FK (ADR-0043 D4)"
);
}
#[test]
@@ -1012,14 +1039,19 @@ mod builder_tests {
// The table-level `FOREIGN KEY (...)` form is not inline, so it can
// carry a multi-column reference and never triggers the inline
// "use the table-level form" hint (ADR-0043 D4).
let fks = parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))");
let fks = parse_sct_fks(
"create table t (id int, pid int, foreign key (pid) references parent(id))",
);
assert!(!fks[0].inline, "table-level FOREIGN KEY is not inline");
}
#[test]
fn bare_inline_reference_has_no_parent_column() {
let fks = parse_sct_fks("create table t (id int, pid int references parent)");
assert_eq!(fks[0].parent_columns, None, "bare REFERENCES — resolved at execution");
assert_eq!(
fks[0].parent_columns, None,
"bare REFERENCES — resolved at execution"
);
assert_eq!(fks[0].parent_table, "parent");
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
}
@@ -1047,8 +1079,9 @@ mod builder_tests {
#[test]
fn table_level_foreign_key_captured() {
let fks =
parse_sct_fks("create table t (id int, pid int, foreign key (pid) references parent(id))");
let fks = parse_sct_fks(
"create table t (id int, pid int, foreign key (pid) references parent(id))",
);
assert_eq!(fks.len(), 1);
assert_eq!(fks[0].name, None);
assert_eq!(fks[0].child_columns, vec!["pid".to_string()]);
@@ -1073,8 +1106,20 @@ mod builder_tests {
foreign key (a) references p(id), foreign key (b) references q(id))",
);
assert_eq!(fks.len(), 2);
assert_eq!((fks[0].child_columns[0].as_str(), fks[0].parent_table.as_str()), ("a", "p"));
assert_eq!((fks[1].child_columns[0].as_str(), fks[1].parent_table.as_str()), ("b", "q"));
assert_eq!(
(
fks[0].child_columns[0].as_str(),
fks[0].parent_table.as_str()
),
("a", "p")
);
assert_eq!(
(
fks[1].child_columns[0].as_str(),
fks[1].parent_table.as_str()
),
("b", "q")
);
}
#[test]
@@ -1108,7 +1153,12 @@ mod builder_tests {
assert_eq!(foreign_keys[0].child_columns, vec!["pid".to_string()]);
// the column-level CHECK still attaches to `pid`
assert_eq!(
columns.iter().find(|c| c.name == "pid").unwrap().check_sql.as_deref(),
columns
.iter()
.find(|c| c.name == "pid")
.unwrap()
.check_sql
.as_deref(),
Some("pid > 0")
);
// the table-level CHECK is captured separately
+12 -2
View File
@@ -82,7 +82,14 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, &SQL_DELETE_SHAPE, &mut ctx, &mut path, &mut per_byte) {
match walk_node(
input,
0,
&SQL_DELETE_SHAPE,
&mut ctx,
&mut path,
&mut per_byte,
) {
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
@@ -93,7 +100,10 @@ mod tests {
}
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete DELETE tail");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete DELETE tail"
);
}
#[test]
+31 -63
View File
@@ -82,19 +82,16 @@ const EXPR_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
// =================================================================
// or_expr := and_expr ( OR and_expr )* — the fragment entry point
// =================================================================
static OR_TAIL_NODES: &[Node] = &[
Node::Word(Word::keyword("or")),
Node::Subgrammar(&AND_EXPR),
];
static OR_TAIL_NODES: &[Node] = &[Node::Word(Word::keyword("or")), Node::Subgrammar(&AND_EXPR)];
static OR_TAIL: Node = Node::Seq(OR_TAIL_NODES);
static SQL_OR_EXPR_NODES: &[Node] = &[
Node::Subgrammar(&AND_EXPR),
@@ -140,10 +137,7 @@ static NOT_FORM_NODES: &[Node] = &[
Node::Word(Word::keyword("not")),
Node::Subgrammar(&NOT_EXPR),
];
static NOT_EXPR_CHOICES: &[Node] = &[
Node::Seq(NOT_FORM_NODES),
Node::Subgrammar(&PREDICATE),
];
static NOT_EXPR_CHOICES: &[Node] = &[Node::Seq(NOT_FORM_NODES), Node::Subgrammar(&PREDICATE)];
static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
// =================================================================
@@ -156,10 +150,7 @@ static NOT_EXPR: Node = Node::Choice(NOT_EXPR_CHOICES);
// needs. ADR-0026's DSL grammar made the tail mandatory because it
// forbade a bare column as a boolean; SQL does not.
static PREDICATE_NODES: &[Node] = &[
Node::Subgrammar(&ADDITIVE),
Node::Optional(&PREDICATE_TAIL),
];
static PREDICATE_NODES: &[Node] = &[Node::Subgrammar(&ADDITIVE), Node::Optional(&PREDICATE_TAIL)];
static PREDICATE: Node = Node::Seq(PREDICATE_NODES);
// ---- cmp_op := <= | <> | >= | != | < | > | = --------------------
@@ -181,10 +172,7 @@ static CMP_OP_CHOICES: &[Node] = &[
// ---- predicate_tail branches ------------------------------------
/// `cmp_op additive`.
static COMPARE_FORM_NODES: &[Node] = &[
Node::Choice(CMP_OP_CHOICES),
Node::Subgrammar(&ADDITIVE),
];
static COMPARE_FORM_NODES: &[Node] = &[Node::Choice(CMP_OP_CHOICES), Node::Subgrammar(&ADDITIVE)];
/// `IS [NOT] NULL`.
static IS_NULL_NODES: &[Node] = &[
@@ -265,11 +253,7 @@ static PREDICATE_TAIL: Node = Node::Choice(PREDICATE_TAIL_CHOICES);
// additive := multiplicative ( ( + | - | || ) multiplicative )*
// =================================================================
static ADD_OP_CHOICES: &[Node] = &[
Node::Punct('+'),
Node::Punct('-'),
Node::Literal("||"),
];
static ADD_OP_CHOICES: &[Node] = &[Node::Punct('+'), Node::Punct('-'), Node::Literal("||")];
static ADD_TAIL_NODES: &[Node] = &[
Node::Choice(ADD_OP_CHOICES),
Node::Subgrammar(&MULTIPLICATIVE),
@@ -289,15 +273,8 @@ static ADDITIVE: Node = Node::Seq(ADDITIVE_NODES);
// multiplicative := unary ( ( * | / | % ) unary )*
// =================================================================
static MUL_OP_CHOICES: &[Node] = &[
Node::Punct('*'),
Node::Punct('/'),
Node::Punct('%'),
];
static MUL_TAIL_NODES: &[Node] = &[
Node::Choice(MUL_OP_CHOICES),
Node::Subgrammar(&UNARY),
];
static MUL_OP_CHOICES: &[Node] = &[Node::Punct('*'), Node::Punct('/'), Node::Punct('%')];
static MUL_TAIL_NODES: &[Node] = &[Node::Choice(MUL_OP_CHOICES), Node::Subgrammar(&UNARY)];
static MUL_TAIL: Node = Node::Seq(MUL_TAIL_NODES);
static MULTIPLICATIVE_NODES: &[Node] = &[
Node::Subgrammar(&UNARY),
@@ -314,14 +291,8 @@ static MULTIPLICATIVE: Node = Node::Seq(MULTIPLICATIVE_NODES);
// =================================================================
static SIGN_CHOICES: &[Node] = &[Node::Punct('-'), Node::Punct('+')];
static UNARY_SIGN_NODES: &[Node] = &[
Node::Choice(SIGN_CHOICES),
Node::Subgrammar(&UNARY),
];
static UNARY_CHOICES: &[Node] = &[
Node::Seq(UNARY_SIGN_NODES),
Node::Subgrammar(&PRIMARY),
];
static UNARY_SIGN_NODES: &[Node] = &[Node::Choice(SIGN_CHOICES), Node::Subgrammar(&UNARY)];
static UNARY_CHOICES: &[Node] = &[Node::Seq(UNARY_SIGN_NODES), Node::Subgrammar(&PRIMARY)];
static UNARY: Node = Node::Choice(UNARY_CHOICES);
// =================================================================
@@ -402,10 +373,7 @@ static SIMPLE_CASE_NODES: &[Node] = &[
Node::Optional(&ELSE_CLAUSE),
Node::Word(Word::keyword("end")),
];
static CASE_BODY_CHOICES: &[Node] = &[
Node::Seq(SEARCHED_CASE_NODES),
Node::Seq(SIMPLE_CASE_NODES),
];
static CASE_BODY_CHOICES: &[Node] = &[Node::Seq(SEARCHED_CASE_NODES), Node::Seq(SIMPLE_CASE_NODES)];
static CASE_NODES: &[Node] = &[
Node::Word(Word::keyword("case")),
Node::Choice(CASE_BODY_CHOICES),
@@ -467,14 +435,11 @@ const QUALIFIED_REF_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static QUALIFIED_REF_TAIL_NODES: &[Node] = &[
Node::Punct('.'),
QUALIFIED_REF_IDENT,
];
static QUALIFIED_REF_TAIL_NODES: &[Node] = &[Node::Punct('.'), QUALIFIED_REF_IDENT];
static NAME_OR_CALL_TAIL_CHOICES: &[Node] = &[
Node::Seq(QUALIFIED_REF_TAIL_NODES),
@@ -531,7 +496,10 @@ mod tests {
/// Assert `input` is *not* a complete SQL expression.
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete expression");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete expression"
);
}
#[test]
@@ -643,13 +611,13 @@ mod tests {
#[test]
fn malformed_expressions_do_not_walk() {
bad("a +"); // dangling operator
bad("a in b"); // IN requires a parenthesised list
bad("= 1"); // no left operand
bad("a = "); // no right operand
bad("case a end"); // CASE with no WHEN clause
bad("and b"); // leading connective
bad("upper("); // unclosed call
bad("a +"); // dangling operator
bad("a in b"); // IN requires a parenthesised list
bad("= 1"); // no left operand
bad("a = "); // no right operand
bad("case a end"); // CASE with no WHEN clause
bad("and b"); // leading connective
bad("upper("); // unclosed call
}
#[test]
@@ -680,9 +648,9 @@ mod tests {
// The optional tail dispatches `.identifier` (qualified
// ref) vs `(args)` (function call) by first token — a
// bare ident remains a column ref.
good("foo(x)"); // function call
good("foo.bar"); // qualified ref
good("foo"); // bare ref
good("foo(x)"); // function call
good("foo.bar"); // qualified ref
good("foo"); // bare ref
}
#[test]
+31 -8
View File
@@ -120,7 +120,10 @@ fn target_value_columns(ctx: &WalkContext) -> Vec<TableColumn> {
listed
.iter()
.filter_map(|name| {
table_cols.iter().find(|c| c.name.eq_ignore_ascii_case(name)).cloned()
table_cols
.iter()
.find(|c| c.name.eq_ignore_ascii_case(name))
.cloned()
})
.collect()
},
@@ -148,7 +151,11 @@ fn target_value_columns(ctx: &WalkContext) -> Vec<TableColumn> {
fn tuple_value_list(ctx: &WalkContext, source: &str, pos: usize) -> Node {
let cols = target_value_columns(ctx);
let (count, closed) = count_tuple_values(source, pos);
let arity_ok = if closed { count == cols.len() } else { count <= cols.len() };
let arity_ok = if closed {
count == cols.len()
} else {
count <= cols.len()
};
if !cols.is_empty() && arity_ok {
Node::DynamicSubgrammar(sql_value_list)
} else {
@@ -304,8 +311,10 @@ static DO_UPDATE_NODES: &[Node] = &[
/// the enclosing Seq, each branch's FIRST token (`nothing` vs
/// `update`) disambiguates, so a non-match of branch 0 is a clean
/// `NoMatch` that falls through to branch 1.
static DO_ACTION_CHOICES: &[Node] =
&[Node::Word(Word::keyword("nothing")), Node::Seq(DO_UPDATE_NODES)];
static DO_ACTION_CHOICES: &[Node] = &[
Node::Word(Word::keyword("nothing")),
Node::Seq(DO_UPDATE_NODES),
];
// `const` — used by value in `ON_CONFLICT_CLAUSE_NODES`.
const DO_ACTION: Node = Node::Choice(DO_ACTION_CHOICES);
@@ -361,7 +370,14 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, &SQL_INSERT_SHAPE, &mut ctx, &mut path, &mut per_byte) {
match walk_node(
input,
0,
&SQL_INSERT_SHAPE,
&mut ctx,
&mut path,
&mut per_byte,
) {
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
@@ -372,7 +388,10 @@ mod tests {
}
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete INSERT tail");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete INSERT tail"
);
}
#[test]
@@ -418,8 +437,12 @@ mod tests {
// 3h: ON CONFLICT … DO NOTHING / DO UPDATE (ADR-0033 §9).
good("into t (id, name) values (1, 'x') on conflict (id) do nothing");
good("into t (id, name) values (1, 'x') on conflict do nothing");
good("into t (id, name) values (1, 'x') on conflict (id) do update set name = excluded.name");
good("into t (id, name) values (1, 'x') on conflict (id) do update set name = 'y' where id > 0");
good(
"into t (id, name) values (1, 'x') on conflict (id) do update set name = excluded.name",
);
good(
"into t (id, name) values (1, 'x') on conflict (id) do update set name = 'y' where id > 0",
);
// Multi-column conflict target + multi-assignment DO UPDATE.
good("into t (a, b) values (1, 2) on conflict (a, b) do update set b = excluded.b, a = 9");
// ON CONFLICT composes with RETURNING (order: row source,
+62 -97
View File
@@ -141,8 +141,15 @@ static EMPTY_NOMATCH: Node = Node::Choice(&[]);
/// suffix keywords. `as` is not listed — the AS-form alias is a
/// separate `Choice` branch that fires before the lookahead.
const PROJECTION_FOLLOW_SET: &[&str] = &[
"from", "where", "group", "order", "having", "limit",
"union", "intersect", "except",
"from",
"where",
"group",
"order",
"having",
"limit",
"union",
"intersect",
"except",
// `returning` belongs to an enclosing DML statement
// (`INSERT … SELECT … RETURNING …`, ADR-0033 §5), never to a
// projection item's bare alias — so a no-FROM SELECT row source
@@ -158,9 +165,21 @@ const PROJECTION_FOLLOW_SET: &[&str] = &[
/// only when `b` has no alias — `on` is not a base-table name a
/// learner would type as an alias.
const TABLE_SOURCE_FOLLOW_SET: &[&str] = &[
"where", "group", "order", "having", "limit",
"union", "intersect", "except",
"inner", "left", "right", "full", "cross", "join", "on",
"where",
"group",
"order",
"having",
"limit",
"union",
"intersect",
"except",
"inner",
"left",
"right",
"full",
"cross",
"join",
"on",
// `returning` belongs to an enclosing DML statement
// (`INSERT … SELECT … FROM t RETURNING …`, ADR-0033 §5), so the
// SELECT row source must not read it as table `t`'s bare alias.
@@ -172,15 +191,9 @@ fn peek_next_ident_lower(source: &str, pos: usize) -> Option<String> {
consume_ident(source, p).map(|(s, e)| source[s..e].to_ascii_lowercase())
}
fn projection_bare_alias_factory(
_: &WalkContext,
source: &str,
pos: usize,
) -> Node {
fn projection_bare_alias_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
match peek_next_ident_lower(source, pos) {
Some(word)
if PROJECTION_FOLLOW_SET.iter().any(|k| *k == word) =>
{
Some(word) if PROJECTION_FOLLOW_SET.iter().any(|k| *k == word) => {
Node::Subgrammar(&EMPTY_NOMATCH)
}
Some(_) => PROJECTION_BARE_ALIAS_IDENT,
@@ -188,15 +201,9 @@ fn projection_bare_alias_factory(
}
}
fn table_source_bare_alias_factory(
_: &WalkContext,
source: &str,
pos: usize,
) -> Node {
fn table_source_bare_alias_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
match peek_next_ident_lower(source, pos) {
Some(word)
if TABLE_SOURCE_FOLLOW_SET.iter().any(|k| *k == word) =>
{
Some(word) if TABLE_SOURCE_FOLLOW_SET.iter().any(|k| *k == word) => {
Node::Subgrammar(&EMPTY_NOMATCH)
}
Some(_) => TABLE_SOURCE_BARE_ALIAS_IDENT,
@@ -237,14 +244,12 @@ const TABLE_SOURCE_BARE_ALIAS_IDENT: Node = Node::Ident {
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: true,
writes_cte_name: false,
writes_projection_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static PROJECTION_AS_ALIAS_NODES: &[Node] = &[
Node::Word(Word::keyword("as")),
PROJECTION_BARE_ALIAS_IDENT,
];
static PROJECTION_AS_ALIAS_NODES: &[Node] =
&[Node::Word(Word::keyword("as")), PROJECTION_BARE_ALIAS_IDENT];
static PROJECTION_AS_ALIAS: Node = Node::Seq(PROJECTION_AS_ALIAS_NODES);
static TABLE_SOURCE_AS_ALIAS_NODES: &[Node] = &[
@@ -258,17 +263,14 @@ static PROJECTION_ALIAS_CHOICES: &[Node] = &[
Node::Lookahead(projection_bare_alias_factory),
];
static PROJECTION_ALIAS_CHOICE: Node = Node::Choice(PROJECTION_ALIAS_CHOICES);
static PROJECTION_ALIAS_OPTIONAL: Node =
Node::Optional(&PROJECTION_ALIAS_CHOICE);
static PROJECTION_ALIAS_OPTIONAL: Node = Node::Optional(&PROJECTION_ALIAS_CHOICE);
static TABLE_SOURCE_ALIAS_CHOICES: &[Node] = &[
Node::Subgrammar(&TABLE_SOURCE_AS_ALIAS),
Node::Lookahead(table_source_bare_alias_factory),
];
static TABLE_SOURCE_ALIAS_CHOICE: Node =
Node::Choice(TABLE_SOURCE_ALIAS_CHOICES);
static TABLE_SOURCE_ALIAS_OPTIONAL: Node =
Node::Optional(&TABLE_SOURCE_ALIAS_CHOICE);
static TABLE_SOURCE_ALIAS_CHOICE: Node = Node::Choice(TABLE_SOURCE_ALIAS_CHOICES);
static TABLE_SOURCE_ALIAS_OPTIONAL: Node = Node::Optional(&TABLE_SOURCE_ALIAS_CHOICE);
// =================================================================
// Projection item
@@ -282,16 +284,13 @@ const QUALIFIED_STAR_QUALIFIER: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static QUALIFIED_STAR_NODES: &[Node] = &[
QUALIFIED_STAR_QUALIFIER,
Node::Punct('.'),
Node::Punct('*'),
];
static QUALIFIED_STAR_NODES: &[Node] =
&[QUALIFIED_STAR_QUALIFIER, Node::Punct('.'), Node::Punct('*')];
static QUALIFIED_STAR: Node = Node::Seq(QUALIFIED_STAR_NODES);
static PROJECTION_EXPR_ITEM_NODES: &[Node] = &[
@@ -310,11 +309,7 @@ static PROJECTION_EXPR_ITEM: Node = Node::Seq(PROJECTION_EXPR_ITEM_NODES);
/// ambiguity between `t.*` and `sql_expr` (which can match a
/// bare `t`), since the walker's `Choice` doesn't backtrack on
/// a committed match.
fn projection_item_factory(
_: &WalkContext,
source: &str,
pos: usize,
) -> Node {
fn projection_item_factory(_: &WalkContext, source: &str, pos: usize) -> Node {
let p = skip_whitespace(source, pos);
let bytes = source.as_bytes();
if bytes.get(p) == Some(&b'*') {
@@ -363,8 +358,7 @@ static DISTINCT_OR_ALL_CHOICES: &[Node] = &[
Node::Word(Word::keyword("all")),
];
static DISTINCT_OR_ALL_CHOICE: Node = Node::Choice(DISTINCT_OR_ALL_CHOICES);
static DISTINCT_OR_ALL_OPTIONAL: Node =
Node::Optional(&DISTINCT_OR_ALL_CHOICE);
static DISTINCT_OR_ALL_OPTIONAL: Node = Node::Optional(&DISTINCT_OR_ALL_CHOICE);
// =================================================================
// Table source (FROM / JOIN target)
@@ -379,8 +373,8 @@ const TABLE_NAME_IDENT: Node = Node::Ident {
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static TABLE_SOURCE_NODES: &[Node] = &[
@@ -395,8 +389,7 @@ static TABLE_SOURCE: Node = Node::Seq(TABLE_SOURCE_NODES);
const JOIN_WORD: Node = Node::Word(Word::keyword("join"));
const ON_WORD: Node = Node::Word(Word::keyword("on"));
static OUTER_OPTIONAL: Node =
Node::Optional(&Node::Word(Word::keyword("outer")));
static OUTER_OPTIONAL: Node = Node::Optional(&Node::Word(Word::keyword("outer")));
// `INNER JOIN` and bare `JOIN` are split into two Choice
// branches so each branch has a distinct leading keyword
@@ -585,8 +578,7 @@ static SET_OP_CHOICES: &[Node] = &[
];
static SET_OP: Node = Node::Choice(SET_OP_CHOICES);
static SET_OP_TAIL_NODES: &[Node] =
&[Node::Subgrammar(&SET_OP), Node::Subgrammar(&SELECT_CORE)];
static SET_OP_TAIL_NODES: &[Node] = &[Node::Subgrammar(&SET_OP), Node::Subgrammar(&SELECT_CORE)];
static SET_OP_TAIL: Node = Node::Seq(SET_OP_TAIL_NODES);
static PLAIN_COMPOUND_NODES: &[Node] = &[
@@ -619,8 +611,7 @@ static WITH_PREFIXED_COMPOUND_NODES: &[Node] = &[
Node::Subgrammar(&WITH_CLAUSE),
Node::Subgrammar(&PLAIN_COMPOUND),
];
static WITH_PREFIXED_COMPOUND: Node =
Node::Seq(WITH_PREFIXED_COMPOUND_NODES);
static WITH_PREFIXED_COMPOUND: Node = Node::Seq(WITH_PREFIXED_COMPOUND_NODES);
static COMPOUND_CHOICES: &[Node] = &[
Node::Subgrammar(&WITH_PREFIXED_COMPOUND),
@@ -659,9 +650,9 @@ const CTE_COLUMN_IDENT: Node = Node::Ident {
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
static CTE_COLUMN_LIST_NODES: &[Node] = &[
@@ -674,18 +665,13 @@ static CTE_COLUMN_LIST_NODES: &[Node] = &[
RPAREN,
];
static CTE_COLUMN_LIST_SEQ: Node = Node::Seq(CTE_COLUMN_LIST_NODES);
static CTE_COLUMN_LIST_OPTIONAL: Node =
Node::Optional(&CTE_COLUMN_LIST_SEQ);
static CTE_COLUMN_LIST_OPTIONAL: Node = Node::Optional(&CTE_COLUMN_LIST_SEQ);
// CTE body recursion pushes a fresh lexical scope frame (ADR-
// 0032 §4 / §10.2). Subqueries in `sql_expr.rs` do the same;
// the top-level statement's own COMPOUND embedding does not
// (it shares the implicit bottom frame).
static CTE_BODY_NODES: &[Node] = &[
LPAREN,
Node::ScopedSubgrammar(&SQL_SELECT_COMPOUND),
RPAREN,
];
static CTE_BODY_NODES: &[Node] = &[LPAREN, Node::ScopedSubgrammar(&SQL_SELECT_COMPOUND), RPAREN];
static CTE_BODY: Node = Node::Seq(CTE_BODY_NODES);
static CTE_DEF_NODES: &[Node] = &[
@@ -807,9 +793,7 @@ mod tests {
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, fragment, &mut ctx, &mut path, &mut per_byte) {
NodeWalkResult::Matched { end, .. } => {
input[end..].trim().is_empty()
}
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
}
@@ -819,10 +803,7 @@ mod tests {
}
fn good(input: &str) {
assert!(
walks(input),
"{input:?} should be a valid SELECT statement"
);
assert!(walks(input), "{input:?} should be a valid SELECT statement");
}
fn bad(input: &str) {
@@ -1051,16 +1032,12 @@ mod tests {
#[test]
fn set_op_chain() {
good(
"select a from t union select b from u intersect select c from v",
);
good("select a from t union select b from u intersect select c from v");
}
#[test]
fn set_op_with_outer_order_by_and_limit() {
good(
"select a from t union select b from u order by a limit 10",
);
good("select a from t union select b from u order by a limit 10");
}
// ----- ORDER BY / LIMIT / OFFSET -----
@@ -1126,16 +1103,12 @@ mod tests {
#[test]
fn recursive_cte() {
good(
"with recursive r as (select 1 union all select 2) select * from r",
);
good("with recursive r as (select 1 union all select 2) select * from r");
}
#[test]
fn multiple_ctes() {
good(
"with a as (select 1), b as (select 2) select * from a union select * from b",
);
good("with a as (select 1), b as (select 2) select * from a union select * from b");
}
// ----- subquery shapes (recursion through SQL_SELECT_COMPOUND) -----
@@ -1147,9 +1120,7 @@ mod tests {
#[test]
fn nested_cte_body_with_union() {
good(
"with x as (select 1 union select 2) select * from x",
);
good("with x as (select 1 union select 2) select * from x");
}
// ----- case insensitivity / spacing -----
@@ -1363,9 +1334,7 @@ mod tests {
#[test]
fn in_subquery_in_where_clause() {
good("select * from t where id in (select user_id from orders)");
good(
"select * from customers where id not in (select customer_id from blocklist)",
);
good("select * from customers where id not in (select customer_id from blocklist)");
}
#[test]
@@ -1378,9 +1347,7 @@ mod tests {
#[test]
fn nested_subqueries() {
good(
"select * from t where x in (select y from u where y in (select z from v))",
);
good("select * from t where x in (select y from u where y in (select z from v))");
}
#[test]
@@ -1393,8 +1360,6 @@ mod tests {
#[test]
fn cte_body_references_qualified_columns() {
good(
"with x as (select t.name, t.age from t) select x.name from x",
);
good("with x as (select t.name, t.age from t) select x.name from x");
}
}
+12 -2
View File
@@ -119,7 +119,14 @@ mod tests {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
match walk_node(input, 0, &SQL_UPDATE_SHAPE, &mut ctx, &mut path, &mut per_byte) {
match walk_node(
input,
0,
&SQL_UPDATE_SHAPE,
&mut ctx,
&mut path,
&mut per_byte,
) {
NodeWalkResult::Matched { end, .. } => input[end..].trim().is_empty(),
_ => false,
}
@@ -130,7 +137,10 @@ mod tests {
}
fn bad(input: &str) {
assert!(!walks(input), "{input:?} should NOT walk as a complete UPDATE tail");
assert!(
!walks(input),
"{input:?} should NOT walk as a complete UPDATE tail"
);
}
#[test]
+3 -3
View File
@@ -21,9 +21,9 @@ pub mod walker;
pub use action::ReferentialAction;
pub use command::{
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope, Expr,
IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector, RowFilter,
ShowListKind, SqlForeignKey,
AlterTableAction, AppCommand, ChangeColumnMode, ColumnSpec, Command, CompareOp, CopyScope,
Expr, IndexSelector, MessagesValue, ModeValue, Operand, Predicate, RelationshipSelector,
RowFilter, ShowListKind, SqlForeignKey,
};
pub use parser::{ParseError, parse_command};
pub use types::Type;
+18 -25
View File
@@ -55,10 +55,9 @@ pub enum ParseError {
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Invalid { message, .. } => f.write_str(&crate::t!(
"parse.error_wrapper",
detail = message,
)),
Self::Invalid { message, .. } => {
f.write_str(&crate::t!("parse.error_wrapper", detail = message,))
}
Self::Empty => f.write_str(&crate::t!("parse.empty")),
}
}
@@ -125,10 +124,7 @@ pub fn parse_command_with_schema(
/// Schemaless, mode-aware parse (ADR-0030 §2). In `Mode::Simple`
/// the walker gates SQL-only commands and produces the
/// "this is SQL" hint instead of executing them.
pub fn parse_command_in_mode(
input: &str,
mode: Mode,
) -> Result<Command, ParseError> {
pub fn parse_command_in_mode(input: &str, mode: Mode) -> Result<Command, ParseError> {
parse_command_inner(input, None, mode)
}
@@ -185,10 +181,8 @@ fn unknown_command_error(source: &str) -> ParseError {
.collect();
let joined = oxford_join(&entries);
let start = skip_whitespace(source, 0);
let (position, found_word) = consume_ident(source, start).map_or_else(
|| (start, None),
|(s, e)| (s, Some(&source[s..e])),
);
let (position, found_word) = consume_ident(source, start)
.map_or_else(|| (start, None), |(s, e)| (s, Some(&source[s..e])));
let message = found_word.map_or_else(
|| format!("expected one of {joined}"),
|w| format!("expected one of {joined}, found `{w}`"),
@@ -1034,19 +1028,22 @@ mod tests {
false,
);
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on delete cascade on update set null"),
ok(
"add 1:n relationship from Customers.Id to Orders.CustId on delete cascade on update set null"
),
expected
);
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on update set null on delete cascade"),
ok(
"add 1:n relationship from Customers.Id to Orders.CustId on update set null on delete cascade"
),
expected
);
}
#[test]
fn add_relationship_repeated_clause_errors() {
let e =
err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict");
let e = err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict");
match e {
ParseError::Invalid { message, .. } => {
assert!(message.contains("specified twice"), "{message}");
@@ -1073,7 +1070,9 @@ mod tests {
#[test]
fn add_relationship_with_name_actions_and_flag() {
assert_eq!(
ok("add 1:n relationship as cust_orders from Customers.Id to Orders.CustId on delete cascade on update no action --create-fk"),
ok(
"add 1:n relationship as cust_orders from Customers.Id to Orders.CustId on delete cascade on update no action --create-fk"
),
rel(
Some("cust_orders"),
("Customers", "Id"),
@@ -1300,10 +1299,7 @@ mod tests {
#[test]
fn advanced_ambiguous_update_routes_to_sql() {
assert!(matches!(
parse_command_in_mode(
"update Orders set total = 0 where id = 1",
Mode::Advanced,
),
parse_command_in_mode("update Orders set total = 0 where id = 1", Mode::Advanced,),
Ok(Command::SqlUpdate { .. })
));
}
@@ -1399,10 +1395,7 @@ mod tests {
// in advanced mode)" pointer is added at the hint layer
// (input_render), not in the parsed command/error here.
assert!(matches!(
parse_command_in_mode(
"delete from Orders where id = 1 returning *",
Mode::Simple,
),
parse_command_in_mode("delete from Orders where id = 1 returning *", Mode::Simple,),
Err(ParseError::Invalid { .. })
));
}
+1 -2
View File
@@ -9,8 +9,7 @@ use rand::RngExt;
/// Base58 alphabet — Bitcoin-style. 0 / O / I / l are excluded
/// because they are easily confused in print.
const ALPHABET: &[u8; 58] =
b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
const ALPHABET: &[u8; 58] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
const DEFAULT_LEN: usize = 10;
+4 -26
View File
@@ -43,29 +43,9 @@
/// - **Broader scalars:** `date`, `datetime`, `hex`, `ifnull`,
/// `instr`, `nullif`, `random`, `replace`, `strftime`, `typeof`.
pub const KNOWN_SQL_FUNCTIONS: &[&str] = &[
"abs",
"avg",
"coalesce",
"count",
"date",
"datetime",
"hex",
"ifnull",
"instr",
"length",
"lower",
"max",
"min",
"nullif",
"random",
"replace",
"round",
"strftime",
"substr",
"sum",
"trim",
"typeof",
"upper",
"abs", "avg", "coalesce", "count", "date", "datetime", "hex", "ifnull", "instr", "length",
"lower", "max", "min", "nullif", "random", "replace", "round", "strftime", "substr", "sum",
"trim", "typeof", "upper",
];
/// Whether `partial` is a case-insensitive prefix of at least one
@@ -80,9 +60,7 @@ pub const KNOWN_SQL_FUNCTIONS: &[&str] = &[
#[must_use]
pub fn is_known_function_prefix(partial: &str) -> bool {
let lowered = partial.to_lowercase();
KNOWN_SQL_FUNCTIONS
.iter()
.any(|f| f.starts_with(&lowered))
KNOWN_SQL_FUNCTIONS.iter().any(|f| f.starts_with(&lowered))
}
#[cfg(test)]
+2 -9
View File
@@ -59,11 +59,7 @@ impl Type {
#[must_use]
pub const fn sqlite_strict_type(self) -> &'static str {
match self {
Self::Text
| Self::ShortId
| Self::Decimal
| Self::Date
| Self::DateTime => "TEXT",
Self::Text | Self::ShortId | Self::Decimal | Self::Date | Self::DateTime => "TEXT",
Self::Int | Self::Serial | Self::Bool => "INTEGER",
Self::Real => "REAL",
Self::Blob => "BLOB",
@@ -107,10 +103,7 @@ impl Type {
/// match against a numeric column (ADR-0027, Amendment 1).
#[must_use]
pub const fn is_numeric(self) -> bool {
matches!(
self,
Self::Int | Self::Real | Self::Decimal | Self::Serial
)
matches!(self, Self::Int | Self::Real | Self::Decimal | Self::Serial)
}
/// The user-facing type that an FK column should use to
+37 -18
View File
@@ -129,13 +129,14 @@ impl Value {
fn bind_int(&self, column: &str, ty: Type) -> Result<Bound, ValueError> {
match self {
Self::Number(n) => n
.parse::<i64>()
.map(Bound::Integer)
.map_err(|_| ValueError::Format {
column: column.to_string(),
message: format!("`{n}` is not a valid {ty} (whole number expected)"),
}),
Self::Number(n) => {
n.parse::<i64>()
.map(Bound::Integer)
.map_err(|_| ValueError::Format {
column: column.to_string(),
message: format!("`{n}` is not a valid {ty} (whole number expected)"),
})
}
other => Err(ValueError::TypeMismatch {
column: column.to_string(),
expected_human: format!("a whole number for `{ty}`"),
@@ -241,9 +242,7 @@ pub(crate) fn validate_date(s: &str) -> Result<(), String> {
// Expect YYYY-MM-DD: 10 chars, two dashes at fixed positions.
let bytes = s.as_bytes();
if bytes.len() != 10 || bytes[4] != b'-' || bytes[7] != b'-' {
return Err(format!(
"`{s}` is not a date in `YYYY-MM-DD` form"
));
return Err(format!("`{s}` is not a date in `YYYY-MM-DD` form"));
}
let year = parse_digits(&s[0..4]).ok_or_else(|| format!("`{s}`: invalid year"))?;
let month = parse_digits(&s[5..7]).ok_or_else(|| format!("`{s}`: invalid month"))?;
@@ -272,7 +271,9 @@ pub(crate) fn validate_datetime(s: &str) -> Result<(), String> {
validate_date(date_part)?;
let bytes = s.as_bytes();
if bytes[10] != b'T' {
return Err(format!("`{s}`: missing `T` separator between date and time"));
return Err(format!(
"`{s}`: missing `T` separator between date and time"
));
}
if bytes[13] != b':' || bytes[16] != b':' {
return Err(format!("`{s}`: time portion must be `HH:MM:SS`"));
@@ -326,8 +327,14 @@ mod tests {
#[test]
fn integer_for_int_column() {
assert_eq!(n("42").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(42));
assert_eq!(n("-7").bind_for_column("c", Type::Int).unwrap(), Bound::Integer(-7));
assert_eq!(
n("42").bind_for_column("c", Type::Int).unwrap(),
Bound::Integer(42)
);
assert_eq!(
n("-7").bind_for_column("c", Type::Int).unwrap(),
Bound::Integer(-7)
);
}
#[test]
@@ -355,7 +362,9 @@ mod tests {
#[test]
fn shortid_validation_runs_on_text_for_shortid_column() {
let err = t("toolong_xyz_more").bind_for_column("c", Type::ShortId).unwrap_err();
let err = t("toolong_xyz_more")
.bind_for_column("c", Type::ShortId)
.unwrap_err();
assert!(matches!(err, ValueError::Format { .. }));
// Well-formed shortid binds fine.
@@ -367,8 +376,14 @@ mod tests {
#[test]
fn bool_for_bool_column_maps_to_zero_or_one() {
assert_eq!(Value::Bool(true).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(1));
assert_eq!(Value::Bool(false).bind_for_column("c", Type::Bool).unwrap(), Bound::Integer(0));
assert_eq!(
Value::Bool(true).bind_for_column("c", Type::Bool).unwrap(),
Bound::Integer(1)
);
assert_eq!(
Value::Bool(false).bind_for_column("c", Type::Bool).unwrap(),
Bound::Integer(0)
);
}
#[test]
@@ -377,13 +392,17 @@ mod tests {
t("2025-01-15").bind_for_column("c", Type::Date).unwrap(),
Bound::Text("2025-01-15".to_string())
);
let err = t("2025/01/15").bind_for_column("c", Type::Date).unwrap_err();
let err = t("2025/01/15")
.bind_for_column("c", Type::Date)
.unwrap_err();
assert!(matches!(err, ValueError::Format { .. }));
}
#[test]
fn date_range_check() {
let err = t("2025-13-01").bind_for_column("c", Type::Date).unwrap_err();
let err = t("2025-13-01")
.bind_for_column("c", Type::Date)
.unwrap_err();
assert!(matches!(err, ValueError::Format { message, .. } if message.contains("month")));
}
+119 -179
View File
@@ -28,12 +28,10 @@ use crate::completion::TableColumn;
use crate::dsl::grammar::{HighlightClass, Node, ValidationError};
use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::lex_helpers::{
consume_bare_path, consume_flag, consume_ident, consume_number_literal,
consume_string_literal, skip_whitespace,
};
use crate::dsl::walker::outcome::{
ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath,
consume_bare_path, consume_flag, consume_ident, consume_number_literal, consume_string_literal,
skip_whitespace,
};
use crate::dsl::walker::outcome::{ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath};
/// Maximum nesting of `Node::Subgrammar` frames (ADR-0026 §1).
///
@@ -77,10 +75,7 @@ static DYNAMIC_CACHE: LazyLock<Mutex<HashMap<DynamicKey, &'static Node>>> =
/// Resolve a `DynamicSubgrammar` factory to a `&'static Node`,
/// reusing a previously-leaked Node when the factory's inputs
/// match a cached entry.
fn resolve_dynamic(
factory: fn(&WalkContext) -> Node,
ctx: &WalkContext,
) -> &'static Node {
fn resolve_dynamic(factory: fn(&WalkContext) -> Node, ctx: &WalkContext) -> &'static Node {
let key = DynamicKey {
factory: factory as usize,
current_table_columns: ctx.current_table_columns.clone(),
@@ -123,10 +118,7 @@ pub enum NodeWalkResult {
expected: Vec<Expectation>,
},
/// Committed and hit a hard mismatch or validator failure.
Failed {
position: usize,
kind: FailureKind,
},
Failed { position: usize, kind: FailureKind },
}
const fn matched(end: usize) -> NodeWalkResult {
@@ -218,9 +210,7 @@ fn walk_node_inner(
kind: FailureKind::Mismatch { expected: vec![] },
}
}
Node::Subgrammar(inner) => {
walk_subgrammar(source, pos, inner, ctx, path, per_byte)
}
Node::Subgrammar(inner) => walk_subgrammar(source, pos, inner, ctx, path, per_byte),
Node::ScopedSubgrammar(inner) => {
walk_scoped_subgrammar(source, pos, inner, ctx, path, per_byte)
}
@@ -247,8 +237,7 @@ fn walk_node_inner(
// DynamicSubgrammar wrapper that delegates to the
// memoized `column_value_list`), so the per-walk
// leak is a few bytes, not a whole typed tree.
let resolved: &'static Node =
Box::leak(Box::new(factory(ctx, source, pos)));
let resolved: &'static Node = Box::leak(Box::new(factory(ctx, source, pos)));
walk_node(source, pos, resolved, ctx, path, per_byte)
}
Node::SetColumn(col) => {
@@ -262,7 +251,10 @@ fn walk_node_inner(
let col: &crate::completion::TableColumn = col;
ctx.current_column = Some(col.clone());
ctx.pending_value_column = Some(col.name.clone());
NodeWalkResult::Matched { end: pos, skipped: Vec::new() }
NodeWalkResult::Matched {
end: pos,
skipped: Vec::new(),
}
}
Node::TypedValueSlot {
ty,
@@ -342,7 +334,10 @@ fn walk_word(
// Amendment 4). Plain keywords leave it `None`.
class: word.highlight_override.unwrap_or(HighlightClass::Keyword),
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
} else {
NodeWalkResult::NoMatch {
position,
@@ -477,9 +472,7 @@ fn walk_ident(
// ScopedSubgrammar (which is structurally guaranteed to be
// the CTE body — no intervening scoped subgrammar in CTE
// syntax) runs the harvest at body-frame exit.
if writes_cte_name
&& let Some(frame) = ctx.from_scope_stack.last_mut()
{
if writes_cte_name && let Some(frame) = ctx.from_scope_stack.last_mut() {
frame
.cte_bindings
.push(crate::dsl::walker::context::CteBinding {
@@ -487,13 +480,12 @@ fn walk_ident(
columns: Vec::new(),
});
let placeholder_index = frame.cte_bindings.len() - 1;
ctx.pending_cte_harvest =
Some(crate::dsl::walker::context::PendingCteHarvest {
placeholder_index,
col_list: Vec::new(),
cte_name: text.clone(),
cte_name_span: (start, end),
});
ctx.pending_cte_harvest = Some(crate::dsl::walker::context::PendingCteHarvest {
placeholder_index,
col_list: Vec::new(),
cte_name: text.clone(),
cte_name_span: (start, end),
});
}
// ADR-0032 §10.3: the optional `(c1, c2, …)` rename list
// between the cte name and `AS`. Each `cte_column` ident
@@ -507,9 +499,7 @@ fn walk_ident(
}
// ADR-0032 §10.4: projection-list alias accumulator for
// ORDER BY completion candidates.
if writes_projection_alias
&& let Some(frame) = ctx.from_scope_stack.last_mut()
{
if writes_projection_alias && let Some(frame) = ctx.from_scope_stack.last_mut() {
frame.projection_aliases.push(text.clone());
}
if writes_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
@@ -529,9 +519,7 @@ fn walk_ident(
.map(|c| c.name.clone())
.or_else(|| Some(text.clone()));
}
if writes_user_listed_column
&& matches!(src, crate::dsl::grammar::IdentSource::Columns)
{
if writes_user_listed_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
// Form A: `insert into <T> (col1, col2, …)`. Append the
// matched column name to user_listed_columns so the
// inner `values (…)` slot list mirrors the user's
@@ -564,7 +552,10 @@ fn walk_ident(
// (issue #8 / ADR-0022 Amendment 4).
class: highlight_override.unwrap_or(HighlightClass::Identifier),
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
fn walk_string_lit(
@@ -648,7 +639,10 @@ fn walk_literal(
end,
class,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
fn walk_number_lit(
@@ -683,7 +677,10 @@ fn walk_number_lit(
end,
class: HighlightClass::Number,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
fn walk_flag(
@@ -717,7 +714,10 @@ fn walk_flag(
end,
class: HighlightClass::Flag,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
#[allow(clippy::too_many_arguments)]
@@ -784,7 +784,10 @@ fn walk_repeated(
count += 1;
last_item_skipped = skipped;
}
NodeWalkResult::NoMatch { expected, position: inner_pos } => {
NodeWalkResult::NoMatch {
expected,
position: inner_pos,
} => {
// Mid-typing-the-next-item recovery: if the
// separator just consumed and the inner failed
// at EOF, the user is partway through typing the
@@ -860,7 +863,10 @@ fn walk_bare_path(
end,
class: HighlightClass::String,
});
NodeWalkResult::Matched { end, skipped: Vec::new() }
NodeWalkResult::Matched {
end,
skipped: Vec::new(),
}
}
fn walk_choice(
@@ -1031,7 +1037,10 @@ fn walk_optional(
skipped: expected,
}
}
NodeWalkResult::Incomplete { position: p, expected } if !inner_committed => {
NodeWalkResult::Incomplete {
position: p,
expected,
} if !inner_committed => {
// Inner reported Incomplete without consuming
// anything — same as NoMatch from the user's
// perspective. Roll back and skip.
@@ -1156,9 +1165,7 @@ fn walk_scoped_subgrammar(
// walks that NoMatch / Incomplete / Fail leave the placeholder
// empty (the outer-frame state is also discarded in the
// speculative path, so this is correct).
if let (Some(req), NodeWalkResult::Matched { end, .. }) =
(pending_cte, &result)
{
if let (Some(req), NodeWalkResult::Matched { end, .. }) = (pending_cte, &result) {
run_cte_harvest(ctx, path, source, pos, *end, &req);
}
@@ -1240,9 +1247,8 @@ fn run_cte_harvest(
select_idx = Some(i + 1); // start of projection list
}
MatchedKind::Word(
"from" | "where" | "group" | "having" | "order"
| "limit" | "offset" | "union" | "intersect"
| "except",
"from" | "where" | "group" | "having" | "order" | "limit" | "offset" | "union"
| "intersect" | "except",
) if select_idx.is_some() => {
end_idx = i;
break;
@@ -1281,12 +1287,7 @@ fn run_cte_harvest(
// Classify each projection item per ADR-0032 §10.3.
let mut derived: Vec<CteColumn> = Vec::new();
for slice in item_slices {
classify_projection_item(
slice,
body_frame,
&ctx.from_scope_stack,
&mut derived,
);
classify_projection_item(slice, body_frame, &ctx.from_scope_stack, &mut derived);
}
// Apply (c1, c2, …) positional rename if provided. Types
@@ -1339,8 +1340,7 @@ fn run_cte_harvest(
let stack_len = ctx.from_scope_stack.len();
if stack_len >= 2
&& let Some(outer) = ctx.from_scope_stack.get_mut(stack_len - 2)
&& let Some(placeholder) =
outer.cte_bindings.get_mut(req.placeholder_index)
&& let Some(placeholder) = outer.cte_bindings.get_mut(req.placeholder_index)
{
placeholder.columns = derived;
}
@@ -1368,9 +1368,7 @@ fn classify_projection_item(
// empty because it wasn't a base-table lookup), resolve
// through to the in-scope CteBinding so nested CTEs project
// correctly.
if expr_slice.len() == 1
&& matches!(expr_slice[0].kind, MatchedKind::Punct('*'))
{
if expr_slice.len() == 1 && matches!(expr_slice[0].kind, MatchedKind::Punct('*')) {
for binding in &body_frame.from_scope {
for col in expand_binding(binding, scope_stack) {
out.push(col);
@@ -1383,7 +1381,10 @@ fn classify_projection_item(
if expr_slice.len() == 3
&& matches!(
expr_slice[0].kind,
MatchedKind::Ident { role: "qualified_star_qualifier", .. }
MatchedKind::Ident {
role: "qualified_star_qualifier",
..
}
)
&& matches!(expr_slice[1].kind, MatchedKind::Punct('.'))
&& matches!(expr_slice[2].kind, MatchedKind::Punct('*'))
@@ -1413,11 +1414,7 @@ fn classify_projection_item(
)
{
let col_text = &expr_slice[0].text;
let resolved_type = resolve_bare_column_type_in_frame(
body_frame,
scope_stack,
col_text,
);
let resolved_type = resolve_bare_column_type_in_frame(body_frame, scope_stack, col_text);
let name = alias.unwrap_or_else(|| col_text.clone());
out.push(CteColumn {
name: Some(name),
@@ -1447,12 +1444,7 @@ fn classify_projection_item(
{
let qual = &expr_slice[0].text;
let col_text = &expr_slice[2].text;
let resolved_type = resolve_qualified_column_type(
body_frame,
scope_stack,
qual,
col_text,
);
let resolved_type = resolve_qualified_column_type(body_frame, scope_stack, qual, col_text);
let name = alias.unwrap_or_else(|| col_text.clone());
out.push(CteColumn {
name: Some(name),
@@ -1493,16 +1485,8 @@ fn strip_trailing_alias<'a>(
}
) {
// Optional preceding `AS` keyword.
if slice.len() >= 2
&& matches!(
slice[slice.len() - 2].kind,
MatchedKind::Word("as")
)
{
return (
&slice[..slice.len() - 2],
Some(last.text.clone()),
);
if slice.len() >= 2 && matches!(slice[slice.len() - 2].kind, MatchedKind::Word("as")) {
return (&slice[..slice.len() - 2], Some(last.text.clone()));
}
return (&slice[..slice.len() - 1], Some(last.text.clone()));
}
@@ -1613,8 +1597,8 @@ fn merge_expected(dst: &mut Vec<Expectation>, src: Vec<Expectation>) {
#[cfg(test)]
mod tests {
use super::{
DYNAMIC_CACHE, FailureKind, MAX_SUBGRAMMAR_DEPTH, NodeWalkResult,
resolve_dynamic, walk_node,
DYNAMIC_CACHE, FailureKind, MAX_SUBGRAMMAR_DEPTH, NodeWalkResult, resolve_dynamic,
walk_node,
};
use crate::dsl::grammar::{Node, Word};
use crate::dsl::walker::context::WalkContext;
@@ -1629,18 +1613,14 @@ mod tests {
Node::Subgrammar(&NESTED),
Node::Punct(')'),
];
static NESTED_CHOICES: &[Node] = &[
Node::Seq(NESTED_GROUP),
Node::Word(Word::keyword("x")),
];
static NESTED_CHOICES: &[Node] = &[Node::Seq(NESTED_GROUP), Node::Word(Word::keyword("x"))];
static NESTED: Node = Node::Choice(NESTED_CHOICES);
fn walk_nested(input: &str) -> NodeWalkResult {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
let result =
walk_node(input, 0, &NESTED, &mut ctx, &mut path, &mut per_byte);
let result = walk_node(input, 0, &NESTED, &mut ctx, &mut path, &mut per_byte);
assert_eq!(
ctx.subgrammar_depth, 0,
"subgrammar_depth must be restored to 0 after the walk",
@@ -1726,14 +1706,8 @@ mod tests {
fn resolve_dynamic_cache_is_populated() {
let ctx = WalkContext::new();
let _ = resolve_dynamic(const_factory, &ctx);
let populated = !DYNAMIC_CACHE
.lock()
.expect("cache lock")
.is_empty();
assert!(
populated,
"resolve_dynamic should populate the memo cache",
);
let populated = !DYNAMIC_CACHE.lock().expect("cache lock").is_empty();
assert!(populated, "resolve_dynamic should populate the memo cache",);
}
// ---- ScopedSubgrammar (ADR-0032 §10.2) -----------------------
@@ -1758,14 +1732,7 @@ mod tests {
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
let baseline_frames = ctx.from_scope_stack.len();
let result = walk_node(
input,
0,
&SCOPED_NESTED,
&mut ctx,
&mut path,
&mut per_byte,
);
let result = walk_node(input, 0, &SCOPED_NESTED, &mut ctx, &mut path, &mut per_byte);
assert_eq!(
ctx.subgrammar_depth, 0,
"subgrammar_depth must be restored to 0 after the walk",
@@ -1801,9 +1768,9 @@ mod tests {
kind: FailureKind::Validation(err),
..
} => assert_eq!(err.message_key, "parse.custom.expression_too_deep"),
other => panic!(
"expected expression_too_deep on pathological scoped nesting, got {other:?}",
),
other => {
panic!("expected expression_too_deep on pathological scoped nesting, got {other:?}",)
}
}
}
@@ -1822,9 +1789,7 @@ mod tests {
/// Walk a top-level SQL SELECT and return the bottom frame's
/// `from_scope` after the walk completes. Used to verify that
/// `writes_table` / `writes_table_alias` populate bindings.
fn from_scope_after_walk(
input: &str,
) -> Vec<crate::dsl::walker::context::TableBinding> {
fn from_scope_after_walk(input: &str) -> Vec<crate::dsl::walker::context::TableBinding> {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
@@ -1871,9 +1836,7 @@ mod tests {
#[test]
fn join_pushes_a_second_binding() {
let bindings = from_scope_after_walk(
"select * from a join b on x = y",
);
let bindings = from_scope_after_walk("select * from a join b on x = y");
assert_eq!(bindings.len(), 2);
assert_eq!(bindings[0].table, "a");
assert_eq!(bindings[1].table, "b");
@@ -1881,9 +1844,7 @@ mod tests {
#[test]
fn join_with_aliases() {
let bindings = from_scope_after_walk(
"select * from a as x join b as y on x.id = y.id",
);
let bindings = from_scope_after_walk("select * from a as x join b as y on x.id = y.id");
assert_eq!(bindings.len(), 2);
assert_eq!(bindings[0].table, "a");
assert_eq!(bindings[0].alias, Some("x".to_string()));
@@ -1893,9 +1854,8 @@ mod tests {
#[test]
fn three_way_join_pushes_three_bindings() {
let bindings = from_scope_after_walk(
"select * from a join b on x = y left join c on y = z",
);
let bindings =
from_scope_after_walk("select * from a join b on x = y left join c on y = z");
assert_eq!(bindings.len(), 3);
assert_eq!(bindings[0].table, "a");
assert_eq!(bindings[1].table, "b");
@@ -1908,9 +1868,8 @@ mod tests {
// binding into the inner scope frame; on exit, the frame
// pops and the inner binding is gone. The outer scope's
// from_scope still contains only `outer_t`.
let bindings = from_scope_after_walk(
"select * from outer_t where id in (select id from inner_t)",
);
let bindings =
from_scope_after_walk("select * from outer_t where id in (select id from inner_t)");
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].table, "outer_t");
}
@@ -1921,9 +1880,8 @@ mod tests {
// body's scope frame; on body-frame exit, the inner
// binding goes away. The outer scope contains only
// the CTE-name reference `cte_x`.
let bindings = from_scope_after_walk(
"with cte_x as (select * from base_table) select * from cte_x",
);
let bindings =
from_scope_after_walk("with cte_x as (select * from base_table) select * from cte_x");
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].table, "cte_x");
}
@@ -1940,10 +1898,7 @@ mod tests {
/// `cte_bindings` and `projection_aliases` after the walk.
fn frame_state_after_walk(
input: &str,
) -> (
Vec<crate::dsl::walker::context::CteBinding>,
Vec<String>,
) {
) -> (Vec<crate::dsl::walker::context::CteBinding>, Vec<String>) {
let mut ctx = WalkContext::new();
let mut path = MatchedPath::new();
let mut per_byte = Vec::new();
@@ -1968,9 +1923,7 @@ mod tests {
#[test]
fn cte_name_pushes_placeholder_binding() {
let (ctes, _) = frame_state_after_walk(
"with cte_x as (select 1) select * from cte_x",
);
let (ctes, _) = frame_state_after_walk("with cte_x as (select 1) select * from cte_x");
assert_eq!(ctes.len(), 1);
assert_eq!(ctes[0].name, "cte_x");
// §10.3 stage-2 harvest produces one CteColumn per
@@ -1984,9 +1937,8 @@ mod tests {
#[test]
fn multiple_ctes_push_in_order() {
let (ctes, _) = frame_state_after_walk(
"with a as (select 1), b as (select 2) select * from b",
);
let (ctes, _) =
frame_state_after_walk("with a as (select 1), b as (select 2) select * from b");
assert_eq!(ctes.len(), 2);
assert_eq!(ctes[0].name, "a");
assert_eq!(ctes[1].name, "b");
@@ -2006,25 +1958,20 @@ mod tests {
#[test]
fn projection_aliases_captured_via_as_form() {
let (_, aliases) = frame_state_after_walk(
"select a as alpha, b as beta from t",
);
let (_, aliases) = frame_state_after_walk("select a as alpha, b as beta from t");
assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]);
}
#[test]
fn projection_aliases_captured_via_bare_form() {
let (_, aliases) = frame_state_after_walk(
"select a alpha, b beta from t",
);
let (_, aliases) = frame_state_after_walk("select a alpha, b beta from t");
assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]);
}
#[test]
fn projection_aliases_mixed_forms() {
let (_, aliases) = frame_state_after_walk(
"select a as alpha, b beta, c, d as delta from t",
);
let (_, aliases) =
frame_state_after_walk("select a as alpha, b beta, c, d as delta from t");
assert_eq!(
aliases,
vec!["alpha".to_string(), "beta".to_string(), "delta".to_string()]
@@ -2033,8 +1980,7 @@ mod tests {
#[test]
fn projection_aliases_empty_when_no_aliases() {
let (_, aliases) =
frame_state_after_walk("select a, b from t");
let (_, aliases) = frame_state_after_walk("select a, b from t");
assert!(aliases.is_empty());
}
@@ -2088,9 +2034,24 @@ mod tests {
s.table_columns.insert(
"users".to_string(),
vec![
TableColumn { name: "id".to_string(), user_type: Type::Int, not_null: false, has_default: false },
TableColumn { name: "name".to_string(), user_type: Type::Text, not_null: false, has_default: false },
TableColumn { name: "age".to_string(), user_type: Type::Int, not_null: false, has_default: false },
TableColumn {
name: "id".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
TableColumn {
name: "name".to_string(),
user_type: Type::Text,
not_null: false,
has_default: false,
},
TableColumn {
name: "age".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
],
);
s
@@ -2108,10 +2069,7 @@ mod tests {
assert_eq!(ctes.len(), 1);
assert_eq!(ctes[0].columns.len(), 3);
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("id"));
assert_eq!(
ctes[0].columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
assert_eq!(ctes[0].columns[1].name.as_deref(), Some("name"));
assert_eq!(
ctes[0].columns[1].type_,
@@ -2161,10 +2119,7 @@ mod tests {
);
assert_eq!(ctes[0].columns.len(), 1);
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("age"));
assert_eq!(
ctes[0].columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
}
#[test]
@@ -2259,15 +2214,9 @@ mod tests {
.expect("outer_cte binding");
assert_eq!(outer.columns.len(), 2);
assert_eq!(outer.columns[0].name.as_deref(), Some("id"));
assert_eq!(
outer.columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(outer.columns[0].type_, Some(crate::dsl::types::Type::Int),);
assert_eq!(outer.columns[1].name.as_deref(), Some("name"));
assert_eq!(
outer.columns[1].type_,
Some(crate::dsl::types::Type::Text),
);
assert_eq!(outer.columns[1].type_, Some(crate::dsl::types::Type::Text),);
}
#[test]
@@ -2287,15 +2236,9 @@ mod tests {
let b = ctes.iter().find(|c| c.name == "b").expect("b binding");
assert_eq!(b.columns.len(), 2);
assert_eq!(b.columns[0].name.as_deref(), Some("id"));
assert_eq!(
b.columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(b.columns[0].type_, Some(crate::dsl::types::Type::Int),);
assert_eq!(b.columns[1].name.as_deref(), Some("name"));
assert_eq!(
b.columns[1].type_,
Some(crate::dsl::types::Type::Text),
);
assert_eq!(b.columns[1].type_, Some(crate::dsl::types::Type::Text),);
}
#[test]
@@ -2310,10 +2253,7 @@ mod tests {
);
assert_eq!(ctes[0].columns.len(), 3);
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("a"));
assert_eq!(
ctes[0].columns[0].type_,
Some(crate::dsl::types::Type::Int),
);
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
assert_eq!(ctes[0].columns[1].name.as_deref(), Some("b"));
assert_eq!(
ctes[0].columns[1].type_,
+41 -28
View File
@@ -24,8 +24,8 @@
use crate::dsl::grammar::HighlightClass;
use crate::dsl::walker::context::WalkContext;
use crate::dsl::walker::lex_helpers::{
consume_bare_path, consume_flag, consume_ident, consume_number_literal,
consume_string_literal, skip_whitespace,
consume_bare_path, consume_flag, consume_ident, consume_number_literal, consume_string_literal,
skip_whitespace,
};
use crate::dsl::walker::outcome::{ByteClass, WalkBound};
@@ -47,16 +47,11 @@ pub fn highlight_runs(source: &str) -> Vec<ByteClass> {
/// token, producing the keyword classes the renderer needs to
/// colour `select` / `from` / `where` / `union` / `case` / etc.
#[must_use]
pub fn highlight_runs_in_mode(
source: &str,
mode: crate::mode::Mode,
) -> Vec<ByteClass> {
pub fn highlight_runs_in_mode(source: &str, mode: crate::mode::Mode) -> Vec<ByteClass> {
let mut ctx = WalkContext::new();
ctx.mode = mode;
let (result, _cmd) = super::walk(source, WalkBound::EndOfInput, &mut ctx);
let mut classes: Vec<ByteClass> = result
.map(|r| r.per_byte_class)
.unwrap_or_default();
let mut classes: Vec<ByteClass> = result.map(|r| r.per_byte_class).unwrap_or_default();
let scan_start = classes.last().map_or(0, |c| c.end);
scan_remainder(source, scan_start, &mut classes);
@@ -133,9 +128,7 @@ fn scan_remainder(source: &str, start: usize, classes: &mut Vec<ByteClass>) {
.get(pos + 1)
.copied()
.is_some_and(|c| c.is_ascii_digit()));
if looks_like_number
&& let Some((s, e)) = consume_number_literal(source, pos)
{
if looks_like_number && let Some((s, e)) = consume_number_literal(source, pos) {
classes.push(ByteClass {
start: s,
end: e,
@@ -222,8 +215,14 @@ mod tests {
"no Error highlight on a valid m:n line: {runs:?}"
);
let kinds: Vec<HighlightClass> = runs.iter().map(|(_, _, c)| *c).collect();
assert!(kinds.contains(&HighlightClass::Keyword), "keywords highlighted: {runs:?}");
assert!(kinds.contains(&HighlightClass::Identifier), "table names highlighted: {runs:?}");
assert!(
kinds.contains(&HighlightClass::Keyword),
"keywords highlighted: {runs:?}"
);
assert!(
kinds.contains(&HighlightClass::Identifier),
"table names highlighted: {runs:?}"
);
}
#[test]
@@ -276,10 +275,7 @@ mod tests {
#[test]
fn flag_classified_via_fallback() {
// Walker doesn't engage for a bare `--all-rows`.
assert_eq!(
run("--all-rows"),
vec![(0, 10, HighlightClass::Flag)],
);
assert_eq!(run("--all-rows"), vec![(0, 10, HighlightClass::Flag)],);
}
#[test]
@@ -445,15 +441,13 @@ mod tests {
// dispatcher, so only the entry word would highlight).
let runs = run_advanced("select * from t");
assert!(
runs.iter().any(|(s, e, c)| {
*c == HighlightClass::Keyword && (*s, *e) == (0, 6)
}),
runs.iter()
.any(|(s, e, c)| { *c == HighlightClass::Keyword && (*s, *e) == (0, 6) }),
"expected `select` keyword span 0..6; got {runs:?}",
);
assert!(
runs.iter().any(|(s, e, c)| {
*c == HighlightClass::Keyword && (*s, *e) == (9, 13)
}),
runs.iter()
.any(|(s, e, c)| { *c == HighlightClass::Keyword && (*s, *e) == (9, 13) }),
"expected `from` keyword span 9..13; got {runs:?}",
);
}
@@ -514,18 +508,37 @@ mod tests {
let insert = keywords_of(
"insert into t (a) values (1) on conflict (a) do update set a = excluded.a returning a",
);
for kw in ["insert", "into", "values", "on", "conflict", "do", "update", "set", "returning"] {
assert!(insert.contains(&kw), "INSERT/UPSERT: missing `{kw}`; got {insert:?}");
for kw in [
"insert",
"into",
"values",
"on",
"conflict",
"do",
"update",
"set",
"returning",
] {
assert!(
insert.contains(&kw),
"INSERT/UPSERT: missing `{kw}`; got {insert:?}"
);
}
let update = keywords_of("update t set a = 1 where id = 2 returning a");
for kw in ["update", "set", "where", "returning"] {
assert!(update.contains(&kw), "UPDATE: missing `{kw}`; got {update:?}");
assert!(
update.contains(&kw),
"UPDATE: missing `{kw}`; got {update:?}"
);
}
let delete = keywords_of("delete from t where id = 1 returning *");
for kw in ["delete", "from", "where", "returning"] {
assert!(delete.contains(&kw), "DELETE: missing `{kw}`; got {delete:?}");
assert!(
delete.contains(&kw),
"DELETE: missing `{kw}`; got {delete:?}"
);
}
}
}
+1 -3
View File
@@ -110,9 +110,7 @@ pub fn consume_number_literal(source: &str, start: usize) -> Option<(usize, usiz
return None;
}
let mut i = start;
let leading_minus = bytes[i] == b'-'
&& i + 1 < bytes.len()
&& bytes[i + 1].is_ascii_digit();
let leading_minus = bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit();
if leading_minus {
i += 1;
}
+432 -559
View File
File diff suppressed because it is too large Load Diff
+110 -38
View File
@@ -14,12 +14,12 @@
//! advanced effective mode (ADR-0037).
use crate::app::EffectiveMode;
use crate::dsl::ReferentialAction;
use crate::dsl::types::Type;
use crate::dsl::Command;
use crate::dsl::ReferentialAction;
use crate::dsl::command::{
ColumnSpec, CompareOp, Constraint, ConstraintKind, Expr, Operand, Predicate, RowFilter,
};
use crate::dsl::types::Type;
use crate::dsl::value::Value;
/// The dimmed `Executing SQL:` prefix on a teaching-echo line
@@ -79,7 +79,12 @@ pub fn echo_for_query(
name,
filter,
limit,
} => Some(vec![render_show_data(name, filter.as_ref(), *limit, primary_key)]),
} => Some(vec![render_show_data(
name,
filter.as_ref(),
*limit,
primary_key,
)]),
_ => None,
}
}
@@ -150,12 +155,12 @@ pub fn command_to_sql(command: &Command) -> Option<String> {
column,
kind,
} => match kind {
ConstraintKind::NotNull => {
Some(format!("ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL"))
}
ConstraintKind::Default => {
Some(format!("ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT"))
}
ConstraintKind::NotNull => Some(format!(
"ALTER TABLE {table} ALTER COLUMN {column} DROP NOT NULL"
)),
ConstraintKind::Default => Some(format!(
"ALTER TABLE {table} ALTER COLUMN {column} DROP DEFAULT"
)),
// A column-level UNIQUE / CHECK is anonymous in our model —
// no portable name to DROP CONSTRAINT by, so no echo (Bucket C,
// ADR-0035 Amendment 2 residual gap / ADR-0038 §7).
@@ -169,7 +174,10 @@ pub fn command_to_sql(command: &Command) -> Option<String> {
table,
assignments,
filter: RowFilter::AllRows,
} => Some(format!("UPDATE {table} SET {}", render_assignments(assignments))),
} => Some(format!(
"UPDATE {table} SET {}",
render_assignments(assignments)
)),
Command::Delete {
table,
filter: RowFilter::AllRows,
@@ -199,7 +207,13 @@ fn render_create_table(name: &str, columns: &[ColumnSpec], primary_key: &[String
// The same column-constraint suffix `add column` emits (ADR-0029):
// simple-mode `create table` can carry `default` / `check` too, so
// the echo must render them or it is not equivalent (§1 contract).
append_constraints(&mut s, c.not_null, c.unique, c.default.as_ref(), c.check.as_ref());
append_constraints(
&mut s,
c.not_null,
c.unique,
c.default.as_ref(),
c.check.as_ref(),
);
s
})
.collect();
@@ -299,8 +313,10 @@ pub(crate) fn render_create_m2n(
primary_key: &[String],
foreign_keys: &[(Vec<String>, String, Vec<String>)],
) -> String {
let mut parts: Vec<String> =
columns.iter().map(|(n, ty)| format!("{n} {}", ty.keyword())).collect();
let mut parts: Vec<String> = columns
.iter()
.map(|(n, ty)| format!("{n} {}", ty.keyword()))
.collect();
parts.push(format!("PRIMARY KEY ({})", primary_key.join(", ")));
for (child_columns, parent_table, parent_columns) in foreign_keys {
parts.push(format!(
@@ -368,7 +384,12 @@ pub(crate) fn render_add_relationship_create_fk(
) -> Vec<String> {
let mut lines: Vec<String> = new_columns
.iter()
.map(|(col, ty)| format!("ALTER TABLE {child_table} ADD COLUMN {col} {}", ty.keyword()))
.map(|(col, ty)| {
format!(
"ALTER TABLE {child_table} ADD COLUMN {col} {}",
ty.keyword()
)
})
.collect();
lines.push(render_add_relationship(
name,
@@ -461,7 +482,11 @@ fn predicate_to_sql(predicate: &Predicate) -> String {
negated,
} => {
let not = if *negated { "NOT " } else { "" };
format!("{} {not}LIKE {}", operand_to_sql(target), operand_to_sql(pattern))
format!(
"{} {not}LIKE {}",
operand_to_sql(target),
operand_to_sql(pattern)
)
}
Predicate::Between {
target,
@@ -484,7 +509,11 @@ fn predicate_to_sql(predicate: &Predicate) -> String {
} => {
let not = if *negated { "NOT " } else { "" };
let rendered: Vec<String> = items.iter().map(operand_to_sql).collect();
format!("{} {not}IN ({})", operand_to_sql(target), rendered.join(", "))
format!(
"{} {not}IN ({})",
operand_to_sql(target),
rendered.join(", ")
)
}
Predicate::IsNull { target, negated } => {
let not = if *negated { "NOT " } else { "" };
@@ -562,7 +591,10 @@ mod tests {
fn create_table_compound_pk_renders_table_level() {
let cmd = create_table(
"T",
vec![ColumnSpec::new("a", Type::Int), ColumnSpec::new("b", Type::Int)],
vec![
ColumnSpec::new("a", Type::Int),
ColumnSpec::new("b", Type::Int),
],
&["a", "b"],
);
assert_eq!(
@@ -594,7 +626,11 @@ mod tests {
default: Some(Value::Text("A".to_string())),
..ColumnSpec::new("grade", Type::Text)
};
let cmd = create_table("T", vec![ColumnSpec::new("id", Type::Serial), age, grade], &["id"]);
let cmd = create_table(
"T",
vec![ColumnSpec::new("id", Type::Serial), age, grade],
&["id"],
);
let sql = command_to_sql(&cmd).expect("echo");
assert_eq!(
sql,
@@ -625,11 +661,11 @@ mod tests {
check: None,
};
let sql = command_to_sql(&cmd).expect("echo");
assert_eq!(sql, "ALTER TABLE T ADD COLUMN note text NOT NULL DEFAULT 'n/a'");
assert!(matches!(
reparse(&sql),
Ok(Command::SqlAlterTable { .. })
));
assert_eq!(
sql,
"ALTER TABLE T ADD COLUMN note text NOT NULL DEFAULT 'n/a'"
);
assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
}
#[test]
@@ -657,7 +693,10 @@ mod tests {
})),
};
let sql = command_to_sql(&cmd).expect("echo");
assert_eq!(sql, "ALTER TABLE T ADD COLUMN score int UNIQUE CHECK (score >= 0)");
assert_eq!(
sql,
"ALTER TABLE T ADD COLUMN score int UNIQUE CHECK (score >= 0)"
);
assert!(matches!(reparse(&sql), Ok(Command::SqlAlterTable { .. })));
}
@@ -1031,7 +1070,10 @@ mod tests {
let lines = render_drop_column_cascade(
"Orders",
"CustId",
&["Orders_CustId_idx".to_string(), "Orders_CustId_Day_idx".to_string()],
&[
"Orders_CustId_idx".to_string(),
"Orders_CustId_Day_idx".to_string(),
],
);
assert_eq!(
lines.as_slice(),
@@ -1043,9 +1085,18 @@ mod tests {
);
// Each line is itself runnable advanced-mode SQL (the §1 contract
// holds per line for category 2).
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlDropIndex { .. })));
assert!(matches!(reparse(&lines[1]), Ok(Command::SqlDropIndex { .. })));
assert!(matches!(reparse(&lines[2]), Ok(Command::SqlAlterTable { .. })));
assert!(matches!(
reparse(&lines[0]),
Ok(Command::SqlDropIndex { .. })
));
assert!(matches!(
reparse(&lines[1]),
Ok(Command::SqlDropIndex { .. })
));
assert!(matches!(
reparse(&lines[2]),
Ok(Command::SqlAlterTable { .. })
));
}
#[test]
@@ -1054,7 +1105,10 @@ mod tests {
// plain `DROP COLUMN` — still semantically equivalent.
let lines = render_drop_column_cascade("T", "c", &[]);
assert_eq!(lines.as_slice(), &["ALTER TABLE T DROP COLUMN c"]);
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlAlterTable { .. })));
assert!(matches!(
reparse(&lines[0]),
Ok(Command::SqlAlterTable { .. })
));
}
#[test]
@@ -1078,8 +1132,14 @@ mod tests {
"ALTER TABLE Orders ADD CONSTRAINT Customers_id_to_Orders_CustId FOREIGN KEY (CustId) REFERENCES Customers (id) ON DELETE CASCADE",
]
);
assert!(matches!(reparse(&lines[0]), Ok(Command::SqlAlterTable { .. })));
assert!(matches!(reparse(&lines[1]), Ok(Command::SqlAlterTable { .. })));
assert!(matches!(
reparse(&lines[0]),
Ok(Command::SqlAlterTable { .. })
));
assert!(matches!(
reparse(&lines[1]),
Ok(Command::SqlAlterTable { .. })
));
}
#[test]
@@ -1116,8 +1176,16 @@ mod tests {
],
&["Students_id".to_string(), "Courses_id".to_string()],
&[
(vec!["Students_id".to_string()], "Students".to_string(), vec!["id".to_string()]),
(vec!["Courses_id".to_string()], "Courses".to_string(), vec!["id".to_string()]),
(
vec!["Students_id".to_string()],
"Students".to_string(),
vec!["id".to_string()],
),
(
vec!["Courses_id".to_string()],
"Courses".to_string(),
vec!["id".to_string()],
),
],
);
assert_eq!(
@@ -1172,8 +1240,14 @@ mod tests {
#[test]
fn value_literal_renders_null_uppercase_and_quotes_text() {
assert_eq!(value_to_sql_literal(&Value::Null), "NULL");
assert_eq!(value_to_sql_literal(&Value::Text("O'Hara".to_string())), "'O''Hara'");
assert_eq!(value_to_sql_literal(&Value::Number("3.14".to_string())), "3.14");
assert_eq!(
value_to_sql_literal(&Value::Text("O'Hara".to_string())),
"'O''Hara'"
);
assert_eq!(
value_to_sql_literal(&Value::Number("3.14".to_string())),
"3.14"
);
assert_eq!(value_to_sql_literal(&Value::Bool(false)), "false");
}
@@ -1258,9 +1332,7 @@ mod tests {
"Command::App({app:?}) is Bucket C — no echo"
);
// Also confirm echo_for gates the same in advanced mode.
assert!(
echo_for(&Command::App(app), EffectiveMode::AdvancedPersistent).is_none(),
);
assert!(echo_for(&Command::App(app), EffectiveMode::AdvancedPersistent).is_none(),);
}
}
+15 -5
View File
@@ -8,9 +8,8 @@
use crossterm::event::KeyEvent;
use crate::db::{
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult,
DropColumnResult, InsertResult, QueryPlan, RelationshipDiagramData, TableDescription,
UpdateResult,
AddColumnResult, ChangeColumnTypeResult, DataResult, DbError, DeleteResult, DropColumnResult,
InsertResult, QueryPlan, RelationshipDiagramData, TableDescription, UpdateResult,
};
use crate::dsl::Command;
@@ -73,10 +72,16 @@ pub enum AppEvent {
},
/// An `explain …` command succeeded (ADR-0028). `plan`
/// carries the captured query plan; nothing was executed.
DslExplainSucceeded { command: Command, plan: QueryPlan },
DslExplainSucceeded {
command: Command,
plan: QueryPlan,
},
/// A `show <kind>` list command (V5) — carries pre-formatted
/// display lines (tables / relationships / indexes).
DslShowListSucceeded { command: Command, lines: Vec<String> },
DslShowListSucceeded {
command: Command,
lines: Vec<String>,
},
/// `show relationship <name>` (ADR-0044) — structured data for the
/// diagram, rendered App-side; `None` when no such relationship.
DslShowRelationshipSucceeded {
@@ -161,6 +166,11 @@ pub enum AppEvent {
/// commands, so an execution failure would otherwise be
/// lost across sessions.
source: String,
/// Whether the rejected command was submitted in an advanced
/// effective mode (ADR-0052): threaded so the App can tag the
/// `err` record `err:adv` and the failed advanced command
/// hydrates in its `:`-prefixed, simple-mode-recallable form.
advanced: bool,
},
/// Refreshed list of tables in the database.
TablesRefreshed(Vec<String>),
+3 -11
View File
@@ -43,17 +43,11 @@ impl Catalog {
}
}
fn flatten(
value: &serde_norway::Value,
prefix: String,
out: &mut HashMap<String, String>,
) {
fn flatten(value: &serde_norway::Value, prefix: String, out: &mut HashMap<String, String>) {
match value {
serde_norway::Value::Mapping(map) => {
for (k, v) in map {
let k_str = k
.as_str()
.expect("catalog keys must be strings");
let k_str = k.as_str().expect("catalog keys must be strings");
let next = if prefix.is_empty() {
k_str.to_string()
} else {
@@ -85,9 +79,7 @@ pub fn catalog() -> &'static Catalog {
/// See module docs for failure modes.
pub fn translate(key: &str, args: &[(&str, &dyn Display)]) -> String {
let template = catalog().get(key).unwrap_or_else(|| {
panic!(
"missing catalog key: `{key}` (the validator should have caught this)"
);
panic!("missing catalog key: `{key}` (the validator should have caught this)");
});
substitute(template, args, key)
}
+223 -34
View File
@@ -41,8 +41,14 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("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"]),
("diagnostic.cte_arity_mismatch", &["cte", "declared", "actual"]),
(
"diagnostic.compound_arity_mismatch",
&["op", "left_n", "right_n"],
),
(
"diagnostic.cte_arity_mismatch",
&["cte", "declared", "actual"],
),
("diagnostic.duplicate_cte", &["name"]),
("diagnostic.eq_null", &[]),
("diagnostic.insert_arity_mismatch", &["expected", "actual"]),
@@ -63,7 +69,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
),
("diagnostic.not_null_missing", &["column"]),
("diagnostic.like_numeric", &["column", "type"]),
("diagnostic.projection_alias_misplaced", &["alias", "clause"]),
(
"diagnostic.projection_alias_misplaced",
&["alias", "clause"],
),
("diagnostic.table_used_as_column", &["name"]),
("diagnostic.type_mismatch", &["column", "type"]),
("diagnostic.unknown_column", &["name", "table"]),
@@ -149,10 +158,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
"error.type_mismatch.change_column.headline",
&["table", "column", "src_type", "target_type"],
),
(
"error.type_mismatch.change_column.hint",
&["target_type"],
),
("error.type_mismatch.change_column.hint", &["target_type"]),
(
"error.type_mismatch.insert.headline",
&["value", "expected_type"],
@@ -180,6 +186,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.unknown_topic", &["topic"]),
("help.app.quit", &[]),
("help.app.help", &[]),
("help.app.hint", &[]),
("help.app.version", &[]),
("help.app.rebuild", &[]),
("help.app.save", &[]),
("help.app.new", &[]),
@@ -217,15 +225,189 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.data.explain", &[]),
// ---- Hint panel ambient typing assistance (ADR-0022 §6) ----
("hint.ambient_complete", &[]),
(
"hint.ambient_error_with_usage",
&["message", "usage"],
),
("hint.ambient_error_with_usage", &["message", "usage"]),
("hint.ambient_expected", &["expected"]),
(
"hint.ambient_invalid_ident",
&["kind", "found"],
),
("hint.getting_started", &[]),
("hint.block.heading", &[]),
("hint.block.what", &[]),
("hint.block.example", &[]),
("hint.block.concept", &[]),
// 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", &[]),
// 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", &[]),
("hint.cmd.help.what", &[]),
("hint.cmd.help.example", &[]),
("hint.cmd.help.concept", &[]),
("hint.cmd.hint.what", &[]),
("hint.cmd.hint.example", &[]),
("hint.cmd.version.what", &[]),
("hint.cmd.version.example", &[]),
("hint.cmd.rebuild.what", &[]),
("hint.cmd.rebuild.example", &[]),
("hint.cmd.rebuild.concept", &[]),
("hint.cmd.save.what", &[]),
("hint.cmd.save.example", &[]),
("hint.cmd.save.concept", &[]),
("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", &[]),
// 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", &[]),
// 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", &[]),
// 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"]),
("hint.ambient_typing_name", &[]),
// Issue #4: introduce the advanced-mode CREATE TABLE element
// slot (`create table T (`) so the otherwise-invisible
@@ -233,10 +415,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("hint.create_table_element", &[]),
("hint.seed_count", &[]),
("hint.value_literal_slot", &[]),
(
"hint.ambient_typing_name_then",
&["next"],
),
("hint.ambient_typing_name_then", &["next"]),
// Per-column-type value-slot hints (ADR-0024 §Phase D).
("hint.value_slot_blob", &[]),
("hint.value_slot_bool", &[]),
@@ -259,7 +438,10 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.custom.alter_named_unique", &[]),
("parse.custom.bind_type_mismatch", &["found", "expected"]),
("parse.custom.change_column_flags_exclusive", &[]),
("parse.custom.constraint_redundant_on_pk", &["column", "constraint"]),
(
"parse.custom.constraint_redundant_on_pk",
&["column", "constraint"],
),
("parse.custom.create_table_needs_pk", &[]),
("parse.custom.expression_too_deep", &[]),
("parse.custom.insert_form_a_missing_values", &["columns"]),
@@ -299,6 +481,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", &[]),
@@ -306,6 +489,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.usage.mode", &[]),
("parse.usage.new", &[]),
("parse.usage.quit", &[]),
("parse.usage.version", &[]),
("parse.usage.rebuild", &[]),
("parse.usage.redo", &[]),
("parse.usage.replay", &[]),
@@ -392,14 +576,12 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
&["table", "col_count", "col_list", "supplied", "non_auto_csv"],
),
("select.internal_table", &["table"]),
(
"cli.invalid_value",
&["flag", "value", "expected"],
),
("cli.invalid_value", &["flag", "value", "expected"]),
("cli.missing_value", &["flag"]),
("cli.multiple_paths", &["first", "second"]),
("cli.resume_with_path", &[]),
("cli.unknown_argument", &["arg"]),
("cli.version_line", &["version"]),
(
"archive.export_sequence_exhausted",
&["project", "target_dir", "limit"],
@@ -446,6 +628,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 +645,26 @@ 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.hint", &[]),
("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", &[]),
@@ -673,8 +864,7 @@ mod tests {
}
}
let declared: HashSet<&str> =
KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect();
let declared: HashSet<&str> = KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect();
for key in cat.keys() {
if key.starts_with("_test.") {
continue;
@@ -696,9 +886,8 @@ mod tests {
/// Mirror of `tests/engine_vocabulary_audit.rs::FORBIDDEN`,
/// duplicated here so the catalog validator is self-contained
/// (no dependency on the integration-test binary).
const FORBIDDEN_ENGINE_VOCABULARY: &[&str] = &[
"SQLite", "sqlite", "rusqlite", "STRICT", "PRAGMA",
];
const FORBIDDEN_ENGINE_VOCABULARY: &[&str] =
&["SQLite", "sqlite", "rusqlite", "STRICT", "PRAGMA"];
/// Detect a `{name:...}` format-specifier placeholder.
/// Doubled braces `{{` / `}}` are escapes — must skip them.
+2 -2
View File
@@ -34,8 +34,8 @@ pub mod keys;
pub mod translate;
pub use error::{DiagnosticTable, FriendlyError};
pub use format::{catalog, Catalog};
pub use translate::{FailureContext, Operation, TranslateContext, Verbosity};
pub use format::{Catalog, catalog};
pub use translate::{FailureContext, Operation, TranslateContext, Verbosity, error_hint_class};
// `translate::translate` and `format::translate` are different
// callables — the former is the structured DbError → FriendlyError
+291 -5
View File
@@ -162,6 +162,10 @@ error:
# ---- Help text (CLI banner + in-app `help` command) ------------------
# ---- CLI argument-parsing errors (stderr before TUI starts) ---------
cli:
# Version line for `--version` / `-V` and the in-app `version` command
# (ADR-0054). `{version}` is `CARGO_PKG_VERSION` — the single source of
# truth, equal to the `v*` release tag (release CI guards the match).
version_line: "rdbms-playground {version}"
missing_value: "missing value for --{flag}"
invalid_value: "invalid value for --{flag}: {value} (expected one of: {expected})"
unknown_argument: "unknown argument: {arg}"
@@ -186,6 +190,7 @@ help:
Options:
-h, --help Print this help and exit.
-V, --version Print the version and exit.
--theme <light|dark> Override theme (default: auto-detect).
--data-dir <PATH> Use PATH as the data root instead of
the OS-standard location for this run.
@@ -210,6 +215,7 @@ help:
App-level commands (typed inside the app, available in both modes):
quit Exit cleanly.
version Print the application version.
mode simple|advanced Switch input mode.
help Show this list of commands in-app.
save Save the current temp project under a
@@ -256,6 +262,10 @@ help:
help: |-
help — show this command list
help <command> — detailed help for one command (e.g. `help insert`)
hint: |-
hint — explain the most recent error (press F1 for a hint on what you're typing)
version: |-
version — print the application version (same as the `--version` command-line flag)
rebuild: |-
rebuild — rebuild the project database from project.yaml + data/ (with confirmation)
save: |-
@@ -386,6 +396,263 @@ hint:
ambient_complete: "Submit with Enter"
ambient_expected: "Next: {expected}"
ambient_error_with_usage: "{message} — usage: {usage}"
# H2 / ADR-0053: shown by `hint` / F1 when there is nothing specific
# to expand on (no recent error, empty input).
getting_started: "Start typing a command and press F1 for a hint, or type `help` for the full command list."
# Tier-3 block scaffolding (ADR-0053 D4): the heading + the labels the
# `what` / `example` / `concept` parts render under.
block:
heading: "Hint"
what: "What"
example: "Example"
concept: "Concept"
# ── Tier-3 teaching blocks (ADR-0053 D3) ──────────────────────────
# Per-form command hints (`hint.cmd.<form>`) and per-class error
# hints (`hint.err.<class>`), each a `what` (12 sentences) / `example`
# (one runnable, mode-correct line) / `concept` (the relational idea —
# the teaching part). Phase B seeds the three approved exemplars; the
# rest are authored in Phase C.
cmd:
insert:
what: "Add one or more rows to a table."
example: "insert into Customers values ('Ann', 'ann@example.io')"
concept: "A row is one record; each value lines up with a column, in order. Columns typed `serial`/`shortid` fill themselves — leave them out."
add_relationship:
what: "Link two tables so a parent row can own many child rows."
example: "add 1:n relationship from Customers.id to Orders.customer_id"
concept: "The \"1:n\" means one parent, many children. The child column holds the foreign key; add `--create-fk` to create that column if it doesn't exist yet."
# 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"
version:
what: "Print the application version."
example: "version"
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; `save as` copies it to a new name or location."
example: "save as"
concept: "On a temporary project, `save` opens a prompt to give it a permanent name; a named project auto-saves as you work, so `save` on one is already done. `save as` always prompts for a new name or path — use it to copy a project."
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"
# DDL — schema-shaping commands (Phase C batch 2).
create_table:
what: "Create a new table and declare 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`. 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."
# 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."
# 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:
# 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 before a child can reference it. (`on delete` actions like `cascade` or `set null` govern the other direction — what happens to children when their parent is removed — not this one.)"
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 <name>`) 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.
@@ -617,6 +884,8 @@ parse:
# description.
quit: "quit"
help: "help [<command>]"
hint: "hint"
version: "version"
rebuild: "rebuild"
save: "save | save as"
new: "new"
@@ -883,14 +1152,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 +1175,20 @@ 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"
hint: "hint"
history: "history"
home_end: "home/end"
del_word: "del word"
# ---- mode / messages banners (app-level commands) -------------------
mode:
+224 -68
View File
@@ -201,11 +201,7 @@ impl TranslateContext {
/// Combine schema-resolved facts with operation and
/// verbosity to build the full translator input.
#[must_use]
pub fn from_facts(
operation: Operation,
verbosity: Verbosity,
facts: FailureContext,
) -> Self {
pub fn from_facts(operation: Operation, verbosity: Verbosity, facts: FailureContext) -> Self {
Self {
operation: Some(operation),
table: facts.table,
@@ -234,15 +230,15 @@ pub fn translate(error: &DbError, ctx: &TranslateContext) -> FriendlyError {
// refusal sites). Catalog entries exist for the typed
// invalid-value cases but the migration sweep
// (ADR-0019 §9) is what wires them. For now, passthrough.
DbError::Unsupported(message) | DbError::InvalidValue(message) => {
passthrough(message)
}
DbError::Unsupported(message) | DbError::InvalidValue(message) => passthrough(message),
DbError::PersistenceFatal { message, .. }
| DbError::RebuildRowFailed { detail: message, .. }
| DbError::RebuildRowFailed {
detail: message, ..
}
| DbError::Io(message) => passthrough(message),
DbError::WorkerGone => passthrough(
"the database worker is no longer available — the application must restart",
),
DbError::WorkerGone => {
passthrough("the database worker is no longer available — the application must restart")
}
};
// Attach the row pinpoint when the runtime resolved one.
// The translator never builds the table itself — it only
@@ -253,11 +249,74 @@ pub fn translate(error: &DbError, ctx: &TranslateContext) -> FriendlyError {
fe
}
fn translate_sqlite(
/// The tier-3 hint class (`hint.err.<class>`) for an error.
///
/// The same classification [`translate`] performs, surfaced as a
/// stable key for the contextual `hint` (H2 / ADR-0053 D5). Returns
/// `None` for internal / fatal errors that carry no learner-facing
/// hint (persistence, IO, worker-gone).
///
/// **Keep in sync with [`translate`] / `translate_sqlite` /
/// `translate_constraint` / `translate_foreign_key`** — the unit tests
/// below pin each class.
#[must_use]
pub fn error_hint_class(error: &DbError, ctx: &TranslateContext) -> Option<&'static str> {
match error {
DbError::Sqlite { message, kind } => sqlite_hint_class(message, *kind, ctx),
DbError::Unsupported(_) | DbError::InvalidValue(_) => Some("invalid_value"),
DbError::PersistenceFatal { .. }
| DbError::RebuildRowFailed { .. }
| DbError::Io(_)
| DbError::WorkerGone => None,
}
}
fn sqlite_hint_class(
message: &str,
kind: SqliteErrorKind,
ctx: &TranslateContext,
) -> FriendlyError {
) -> 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, ctx: &TranslateContext) -> FriendlyError {
// `change column ... --dont-convert` lets the engine
// accept or refuse each cell. Whatever the engine returns
// (constraint, datatype mismatch, …) means "the new type
@@ -325,8 +384,8 @@ fn translate_constraint(message: &str, ctx: &TranslateContext) -> FriendlyError
// ---- UNIQUE -----------------------------------------------------
fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError {
let (table, column) = parse_qualified_target(message)
.unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
let (table, column) =
parse_qualified_target(message).unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
let value = ctx_value(ctx);
match ctx.operation {
Some(Operation::Update) => fe(
@@ -338,11 +397,7 @@ fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError {
),
verbose_hint(
ctx,
t!(
"error.unique.update.hint",
table = table,
column = column
),
t!("error.unique.update.hint", table = table, column = column),
),
),
// Default to the INSERT variant — it's the most common
@@ -358,11 +413,7 @@ fn translate_unique(message: &str, ctx: &TranslateContext) -> FriendlyError {
),
verbose_hint(
ctx,
t!(
"error.unique.insert.hint",
table = table,
column = column
),
t!("error.unique.insert.hint", table = table, column = column),
),
),
}
@@ -475,8 +526,8 @@ fn fk_parent_side_update(ctx: &TranslateContext) -> FriendlyError {
// ---- NOT NULL --------------------------------------------------
fn translate_not_null(message: &str, ctx: &TranslateContext) -> FriendlyError {
let (table, column) = parse_qualified_target(message)
.unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
let (table, column) =
parse_qualified_target(message).unwrap_or_else(|| (ctx_table(ctx), ctx_column(ctx)));
match ctx.operation {
Some(Operation::Update) => fe(
t!(
@@ -509,9 +560,17 @@ fn translate_check(_message: &str, ctx: &TranslateContext) -> FriendlyError {
let column = ctx_column(ctx);
let is_update = matches!(ctx.operation, Some(Operation::Update));
let headline = if is_update {
t!("error.check.update.headline", table = table, column = column)
t!(
"error.check.update.headline",
table = table,
column = column
)
} else {
t!("error.check.insert.headline", table = table, column = column)
t!(
"error.check.insert.headline",
table = table,
column = column
)
};
let hint = ctx.check_rule.as_ref().map_or_else(
|| {
@@ -546,8 +605,7 @@ fn translate_check(_message: &str, ctx: &TranslateContext) -> FriendlyError {
// ---- not_found / already_exists --------------------------------
fn translate_not_found_table(message: &str, ctx: &TranslateContext) -> FriendlyError {
let name = parse_after_colon(message)
.map_or_else(|| ctx_table(ctx), str::to_string);
let name = parse_after_colon(message).map_or_else(|| ctx_table(ctx), str::to_string);
headline_only(t!("error.not_found.table.headline", name = name))
}
@@ -589,17 +647,11 @@ fn translate_already_exists(message: &str, ctx: &TranslateContext) -> FriendlyEr
column = column
));
}
return headline_only(t!(
"error.already_exists.table.headline",
name = name
));
return headline_only(t!("error.already_exists.table.headline", name = name));
}
// No backticks — engine-style "table T already exists".
if let Some(name) = parse_after_word(message, "table") {
return headline_only(t!(
"error.already_exists.table.headline",
name = name
));
return headline_only(t!("error.already_exists.table.headline", name = name));
}
if let Some(name) = parse_after_word(message, "relationship") {
return headline_only(t!(
@@ -629,36 +681,25 @@ fn translate_generic(message: &str, ctx: &TranslateContext) -> FriendlyError {
if lower.contains("misuse of aggregate") {
return headline_only(t!("engine.aggregate_misuse", name = "?"));
}
if lower.contains("group by")
|| lower.contains("must appear in")
{
if lower.contains("group by") || lower.contains("must appear in") {
return headline_only(t!("engine.group_by_required"));
}
if (lower.contains("union")
|| lower.contains("intersect")
|| lower.contains("except"))
if (lower.contains("union") || lower.contains("intersect") || lower.contains("except"))
&& lower.contains("result columns")
{
// Last-resort safety net — the pre-flight pass in 2d.1
// catches this in most cases; if the engine surfaces it
// anyway, route it through the engine-neutral key.
return headline_only(t!(
"engine.compound_arity_mismatch",
op = "set operator"
));
return headline_only(t!("engine.compound_arity_mismatch", op = "set operator"));
}
if lower.contains("scalar subquery") || lower.contains("more than one row") {
return headline_only(t!("engine.scalar_subquery_too_many_rows"));
}
if lower.contains("recursive")
&& (lower.contains("cte") || lower.contains("union"))
{
if lower.contains("recursive") && (lower.contains("cte") || lower.contains("union")) {
return headline_only(t!("engine.recursive_cte_malformed"));
}
let operation = ctx
.operation
.map_or("operation", Operation::keyword);
let operation = ctx.operation.map_or("operation", Operation::keyword);
// F2 (ADR-0035 Amendment 1): when no table is in context, use the
// table-less hint so a contextless `friendly_message()` (replay, undo,
// rebuild, export) never renders a literal `{table}` placeholder.
@@ -722,23 +763,33 @@ fn ctx_table(ctx: &TranslateContext) -> String {
}
fn ctx_column(ctx: &TranslateContext) -> String {
ctx.column.clone().unwrap_or_else(|| "the column".to_string())
ctx.column
.clone()
.unwrap_or_else(|| "the column".to_string())
}
fn ctx_value(ctx: &TranslateContext) -> String {
ctx.value.clone().unwrap_or_else(|| "that value".to_string())
ctx.value
.clone()
.unwrap_or_else(|| "that value".to_string())
}
fn ctx_parent_table(ctx: &TranslateContext) -> String {
ctx.parent_table.clone().unwrap_or_else(|| "the referenced table".to_string())
ctx.parent_table
.clone()
.unwrap_or_else(|| "the referenced table".to_string())
}
fn ctx_parent_column(ctx: &TranslateContext) -> String {
ctx.parent_column.clone().unwrap_or_else(|| "the referenced column".to_string())
ctx.parent_column
.clone()
.unwrap_or_else(|| "the referenced column".to_string())
}
fn ctx_child_table(ctx: &TranslateContext) -> String {
ctx.child_table.clone().unwrap_or_else(|| "the referencing table".to_string())
ctx.child_table
.clone()
.unwrap_or_else(|| "the referencing table".to_string())
}
/// Extract `T.col` from a message like
@@ -780,11 +831,7 @@ fn parse_after_word<'a>(message: &'a str, keyword: &str) -> Option<&'a str> {
let rest = message[pos..].trim_start();
let token_end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
let token = rest[..token_end].trim_matches(|c: char| c == '`' || c == '\'');
if token.is_empty() {
None
} else {
Some(token)
}
if token.is_empty() { None } else { Some(token) }
}
#[cfg(test)]
@@ -798,6 +845,107 @@ 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(),
@@ -896,14 +1044,22 @@ mod tests {
ctx.parent_column = Some("country, code".to_string());
ctx.value = Some("7, 8".to_string());
let f = translate(&err, &ctx);
assert!(f.headline.contains("no parent row"), "child-side: {}", f.headline);
assert!(
f.headline.contains("no parent row"),
"child-side: {}",
f.headline
);
assert!(f.headline.contains("Region"));
assert!(
f.headline.contains("country, code"),
"both parent columns must appear: {}",
f.headline
);
assert!(f.headline.contains("`7, 8`"), "joined value: {}", f.headline);
assert!(
f.headline.contains("`7, 8`"),
"joined value: {}",
f.headline
);
}
#[test]
+139 -143
View File
@@ -25,9 +25,9 @@
use ratatui::style::{Color, Modifier, Style};
use crate::dsl::parser::{parse_command_with_schema, parse_command_with_schema_in_mode};
use crate::mode::Mode;
use crate::dsl::walker;
use crate::dsl::{ParseError, parse_command};
use crate::mode::Mode;
use crate::theme::Theme;
/// A run of text with its byte range in the source and the
@@ -85,7 +85,16 @@ pub fn render_input_runs_in_mode(
mode: Mode,
) -> Vec<StyledRun> {
// Identity feedback view — highlight/overlay the whole input.
render_input_runs_feedback(input, cursor_byte, theme, cache, mode, input, cursor_byte, 0)
render_input_runs_feedback(
input,
cursor_byte,
theme,
cache,
mode,
input,
cursor_byte,
0,
)
}
/// [`render_input_runs_in_mode`] with a separate **feedback view** for
@@ -121,12 +130,14 @@ pub fn render_input_runs_feedback(
byte_range: (0, offset),
style: ratatui::style::Style::default().fg(theme.fg),
}];
r.extend(lex_to_runs_in_mode(view, theme, mode).into_iter().map(|run| {
StyledRun {
byte_range: (run.byte_range.0 + offset, run.byte_range.1 + offset),
..run
}
}));
r.extend(
lex_to_runs_in_mode(view, theme, mode)
.into_iter()
.map(|run| StyledRun {
byte_range: (run.byte_range.0 + offset, run.byte_range.1 + offset),
..run
}),
);
r
};
if let InputState::DefiniteErrorAt(pos) =
@@ -150,7 +161,11 @@ pub fn render_input_runs_feedback(
walker::Severity::Error => theme.tok_error,
walker::Severity::Warning => theme.warning,
};
overlay_span(&mut runs, (diag.span.0 + offset, diag.span.1 + offset), colour);
overlay_span(
&mut runs,
(diag.span.0 + offset, diag.span.1 + offset),
colour,
);
}
inject_cursor(&mut runs, input, cursor_byte, theme);
runs
@@ -234,9 +249,7 @@ pub fn classify_input_with_schema_in_mode(
))
}
fn classify_parse_result(
result: Result<crate::dsl::Command, ParseError>,
) -> InputState {
fn classify_parse_result(result: Result<crate::dsl::Command, ParseError>) -> InputState {
match result {
Ok(_) => InputState::Valid,
Err(ParseError::Empty) => InputState::Empty,
@@ -372,8 +385,7 @@ pub fn advanced_alternative_note(
// carries a blocking ERROR diagnostic such as a value-count
// mismatch. Incomplete input (still being typed) and empty input are
// excluded so the pointer doesn't flicker mid-keystroke.
let definite_dsl_error = match classify_input_with_schema_in_mode(input, cache, Mode::Simple)
{
let definite_dsl_error = match classify_input_with_schema_in_mode(input, cache, Mode::Simple) {
InputState::DefiniteErrorAt(_) => true,
InputState::Valid => {
crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Simple)
@@ -386,8 +398,7 @@ pub fn advanced_alternative_note(
}
// The validity-verdict-driven gate (ADR-0033 Amendment 5): the
// line must be fully valid (verdict `None`) in advanced mode.
if crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Advanced).is_some()
{
if crate::dsl::walker::input_verdict_in_mode(input, Some(cache), Mode::Advanced).is_some() {
return None;
}
Some(crate::t!("advanced_mode.also_valid_sql"))
@@ -714,8 +725,7 @@ fn ambient_hint_core_in_mode(
// narrows column candidates to the active table and runs the
// §10.6 look-ahead, so it is the authoritative "what can go
// here" set.
let completion =
crate::completion::candidates_at_cursor_in_mode(input, cursor, cache, mode);
let completion = crate::completion::candidates_at_cursor_in_mode(input, cursor, cache, mode);
// Schema-aware diagnostics (ADR-0027 §2). `input_diagnostics`
// is non-empty only for a command that *structurally parses*
@@ -834,7 +844,9 @@ fn ambient_hint_core_in_mode(
// keyword set.
return Some(AmbientHint::Prose(crate::friendly::translate(key, &[])));
}
Some(crate::dsl::grammar::HintMode::SuppressProse | crate::dsl::grammar::HintMode::Default)
Some(
crate::dsl::grammar::HintMode::SuppressProse | crate::dsl::grammar::HintMode::Default,
)
| None => {}
}
@@ -855,7 +867,8 @@ fn ambient_hint_core_in_mode(
// Invalid identifier: cursor sits in a known-set slot but
// the typed prefix matches nothing in the schema. (Stage
// 8e / the user's #5.)
if let Some(inv) = crate::completion::invalid_ident_at_cursor_in_mode(input, cursor, cache, mode)
if let Some(inv) =
crate::completion::invalid_ident_at_cursor_in_mode(input, cursor, cache, mode)
{
let kind = match inv.source {
crate::dsl::grammar::IdentSource::Tables => "table",
@@ -1036,11 +1049,7 @@ pub fn lex_to_runs(input: &str, theme: &Theme) -> Vec<StyledRun> {
/// with `Mode::Advanced` so SQL keywords past the entry word
/// match and get highlighted (ADR-0030 §8).
#[must_use]
pub fn lex_to_runs_in_mode(
input: &str,
theme: &Theme,
mode: Mode,
) -> Vec<StyledRun> {
pub fn lex_to_runs_in_mode(input: &str, theme: &Theme, mode: Mode) -> Vec<StyledRun> {
base_runs(input, theme, mode)
}
@@ -1076,12 +1085,7 @@ fn base_runs(input: &str, theme: &Theme, mode: Mode) -> Vec<StyledRun> {
runs
}
fn inject_cursor(
runs: &mut Vec<StyledRun>,
input: &str,
cursor_byte: usize,
theme: &Theme,
) {
fn inject_cursor(runs: &mut Vec<StyledRun>, input: &str, cursor_byte: usize, theme: &Theme) {
let cursor_byte = cursor_byte.min(input.len());
// End-of-input cursor: append the empty-range sentinel.
@@ -1164,9 +1168,10 @@ mod tests {
let mut cache = SchemaCache::default();
cache.tables.push("Customers".into());
cache.columns.push("name".into());
cache
.table_columns
.insert("Customers".into(), vec![TableColumn::new("name", Type::Text)]);
cache.table_columns.insert(
"Customers".into(),
vec![TableColumn::new("name", Type::Text)],
);
let input = ": select name from Customers";
let view = "select name from Customers";
let offset = 2; // ": "
@@ -1362,9 +1367,10 @@ mod tests {
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.table_columns.insert(
"users".to_string(),
vec![TableColumn::new("email", Type::Text)],
);
cache
}
@@ -1392,7 +1398,10 @@ mod tests {
}
// Tab candidates remain available (completion is independent).
let comp = crate::completion::candidates_at_cursor_in_mode(
input, input.len(), &cache, Mode::Simple,
input,
input.len(),
&cache,
Mode::Simple,
)
.expect("completion remains available");
let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect();
@@ -1424,7 +1433,10 @@ mod tests {
&hint,
Some(AmbientHint::Prose(p)) if p.contains("row count")
);
assert!(!is_count_prose, "count hint must not show for {input:?}; got {hint:?}");
assert!(
!is_count_prose,
"count hint must not show for {input:?}; got {hint:?}"
);
}
}
@@ -1502,14 +1514,12 @@ mod tests {
use crate::dsl::types::Type;
let mut cache = SchemaCache::default();
cache.tables.push("Customers".to_string());
let tc = vec![
TableColumn {
name: "Age".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
},
];
let tc = vec![TableColumn {
name: "Age".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
}];
for c in &tc {
cache.columns.push(c.name.clone());
}
@@ -1551,9 +1561,7 @@ mod tests {
p.contains("No such") && p.contains("Agx"),
"a genuine column typo before FROM must warn at typing time; got: {p:?}",
),
other => panic!(
"`select Agx` must surface a typing-time typo hint; got: {other:?}",
),
other => panic!("`select Agx` must surface a typing-time typo hint; got: {other:?}",),
}
}
@@ -1652,8 +1660,7 @@ mod tests {
// ADR-0022 Amendment 1: advanced-mode ambient assistance
// surfaces SQL completion candidates (here the FROM-slot
// table) instead of the simple-mode "this is SQL" gate.
let cache =
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let input = "select * from ";
match ambient_hint_in_mode(
input,
@@ -1678,10 +1685,7 @@ mod tests {
// `INSERT … (` column list. (The simple-mode DSL value-slot
// prose is a separate surface; this pins the §8 advanced claim.)
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
let set_slot = "update Customers set ";
match ambient_hint_in_mode(set_slot, set_slot.len(), None, &cache, Mode::Advanced) {
@@ -1706,16 +1710,10 @@ mod tests {
fn simple_mode_ambient_does_not_surface_sql_candidates() {
// The simple-mode entry point keeps gating SQL — advanced
// assistance is opt-in via mode, never leaked into simple.
let cache =
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let input = "select * from ";
let hint = ambient_hint_in_mode(
input,
input.len(),
None,
&cache,
crate::mode::Mode::Simple,
);
let hint =
ambient_hint_in_mode(input, input.len(), None, &cache, crate::mode::Mode::Simple);
let offers_table = matches!(
&hint,
Some(AmbientHint::Candidates { items, .. })
@@ -1733,8 +1731,7 @@ mod tests {
fn f1_mid_typed_table_prefix_shows_completion_not_error() {
// "select * from c" — `c` prefix-matches `Customers`. The
// hint must offer the completion, not "no such table c".
let cache =
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
match ambient_hint_in_mode(
"select * from c",
"select * from c".len(),
@@ -1753,8 +1750,7 @@ mod tests {
#[test]
fn f1_genuinely_unknown_table_still_shows_error() {
// "zzz" matches no table prefix — the error must still show.
let cache =
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
match ambient_hint_in_mode(
"select * from zzz",
"select * from zzz".len(),
@@ -1773,8 +1769,7 @@ mod tests {
fn f1_simple_mode_dsl_mid_typed_table_completes() {
// The same shadowing affects DSL commands in simple mode:
// "show data c" must offer Customers, not "no such table c".
let cache =
schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
let cache = schema_with_columns("Customers", &[("id", crate::dsl::types::Type::Int)]);
match ambient_hint_in_mode(
"show data c",
"show data c".len(),
@@ -1804,7 +1799,12 @@ mod tests {
cache.columns.push("order_col".to_string());
cache.table_columns.insert(
"Orders".to_string(),
vec![TableColumn { name: "order_col".to_string(), user_type: Type::Int, not_null: false, has_default: false }],
vec![TableColumn {
name: "order_col".to_string(),
user_type: Type::Int,
not_null: false,
has_default: false,
}],
);
let comp = crate::completion::candidates_at_cursor_in_mode(
@@ -1846,9 +1846,7 @@ mod tests {
for c in &columns {
cache.columns.push(c.name.clone());
}
cache
.table_columns
.insert(table.to_string(), columns);
cache.table_columns.insert(table.to_string(), columns);
cache
}
@@ -1860,7 +1858,11 @@ mod tests {
("Customers", &[("id", Type::Serial), ("name", Type::Text)]),
(
"Products",
&[("id", Type::Serial), ("name", Type::Text), ("price", Type::Decimal)],
&[
("id", Type::Serial),
("name", Type::Text),
("price", Type::Decimal),
],
),
(
"OrderLines",
@@ -1873,13 +1875,19 @@ mod tests {
),
(
"Orders",
&[("id", Type::Serial), ("customer_id", Type::Int), ("date", Type::Date)],
&[
("id", Type::Serial),
("customer_id", Type::Int),
("date", Type::Date),
],
),
];
for (t, cols) in tables {
cache.tables.push((*t).to_string());
let tc: Vec<TableColumn> =
cols.iter().map(|(n, ty)| TableColumn::new(*n, *ty)).collect();
let tc: Vec<TableColumn> = cols
.iter()
.map(|(n, ty)| TableColumn::new(*n, *ty))
.collect();
for c in &tc {
cache.columns.push(c.name.clone());
}
@@ -1914,17 +1922,11 @@ mod tests {
#[test]
fn ambient_hint_at_insert_first_value_shows_int_prose() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
let input = "insert into Customers values (";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
assert!(
p.contains("integer"),
"expected int-slot prose, got: {p:?}",
);
assert!(p.contains("integer"), "expected int-slot prose, got: {p:?}",);
}
other => panic!("expected Prose, got {other:?}"),
}
@@ -1942,7 +1944,11 @@ mod tests {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Serial), ("Name", Type::Text), ("Email", Type::Text)],
&[
("id", Type::Serial),
("Name", Type::Text),
("Email", Type::Text),
],
);
let input = "insert into Customers values (1, 'Alice', 'a@b.c')";
match ambient_hint(input, input.len(), None, &cache) {
@@ -1966,10 +1972,7 @@ mod tests {
// A valid simple-mode DSL command gets no advanced pointer —
// it isn't an error, and there is nothing to switch modes for.
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Serial), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Serial), ("Name", Type::Text)]);
let input = "insert into Customers values ('Alice')";
if let Some(AmbientHint::Prose(p)) = ambient_hint(input, input.len(), None, &cache) {
assert!(
@@ -2010,10 +2013,7 @@ mod tests {
#[test]
fn ambient_hint_at_insert_second_value_shows_text_prose() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
let input = "insert into Customers values (1, ";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
@@ -2029,10 +2029,8 @@ mod tests {
#[test]
fn ambient_hint_at_update_set_shows_per_column_prose() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Birthday", Type::Date)],
);
let cache =
schema_with_columns("Customers", &[("id", Type::Int), ("Birthday", Type::Date)]);
let input = "update Customers set Birthday=";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
@@ -2057,9 +2055,7 @@ mod tests {
// hasn't committed), the Optional propagates Incomplete
// and the user sees no error overlay until they submit.
assert_eq!(
classify_input(
"insert into Orders (id, CustId, Total) values (42, 89, 17.59"
),
classify_input("insert into Orders (id, CustId, Total) values (42, 89, 17.59"),
InputState::IncompleteAtEof,
);
assert_eq!(
@@ -2071,18 +2067,12 @@ mod tests {
#[test]
fn ambient_hint_at_insert_first_value_mentions_column_name() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
let input = "insert into Customers values (";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
assert!(p.contains("id"), "expected column name `id`, got {p:?}");
assert!(
p.contains("integer"),
"expected int prose, got {p:?}",
);
assert!(p.contains("integer"), "expected int prose, got {p:?}",);
}
other => panic!("expected Prose, got {other:?}"),
}
@@ -2091,10 +2081,7 @@ mod tests {
#[test]
fn ambient_hint_at_update_set_mentions_column_name() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Email", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Email", Type::Text)]);
let input = "update Customers set Email=";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
@@ -2131,10 +2118,7 @@ mod tests {
#[test]
fn ambient_hint_at_second_insert_value_mentions_second_column() {
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("id", Type::Int), ("Name", Type::Text)],
);
let cache = schema_with_columns("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
let input = "insert into Customers values (1, ";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
@@ -2165,14 +2149,20 @@ mod tests {
use crate::dsl::types::Type;
let cases: &[(&[(&str, Type)], &str)] = &[
// string first value (the report's case): first col text.
(&[("Name", Type::Text), ("Age", Type::Int)],
"insert into Customers values ('Oli'"),
(
&[("Name", Type::Text), ("Age", Type::Int)],
"insert into Customers values ('Oli'",
),
// integer first value: first col int.
(&[("Age", Type::Int), ("Name", Type::Text)],
"insert into Customers values (42"),
(
&[("Age", Type::Int), ("Name", Type::Text)],
"insert into Customers values (42",
),
// real first value: first col real.
(&[("Score", Type::Real), ("Name", Type::Text)],
"insert into Customers values (3.5"),
(
&[("Score", Type::Real), ("Name", Type::Text)],
"insert into Customers values (3.5",
),
];
for (cols, input) in cases {
let cache = schema_with_columns("Customers", cols);
@@ -2232,10 +2222,7 @@ mod tests {
// is nothing left to fill. Guards against over-correcting the
// fix into never suggesting the close paren.
use crate::dsl::types::Type;
let cache = schema_with_columns(
"Customers",
&[("Name", Type::Text), ("Age", Type::Int)],
);
let cache = schema_with_columns("Customers", &[("Name", Type::Text), ("Age", Type::Int)]);
let input = "insert into Customers values ('Oli', 52";
match ambient_hint(input, input.len(), None, &cache) {
Some(AmbientHint::Prose(p)) => {
@@ -2384,10 +2371,7 @@ mod tests {
match ambient_hint("show data Missing", 17, None, &cache) {
Some(AmbientHint::Prose(p)) => {
assert!(p.contains("Missing"), "got {p:?}");
assert!(
p.to_lowercase().contains("no such table"),
"got {p:?}",
);
assert!(p.to_lowercase().contains("no such table"), "got {p:?}",);
}
other => panic!("expected Prose, got {other:?}"),
}
@@ -2440,8 +2424,7 @@ mod tests {
use crate::dsl::types::Type;
// Two type-mismatch WARNINGs; the hint names the column
// whose offending literal the cursor sits in.
let cache =
schema_with_columns("Events", &[("a", Type::Int), ("b", Type::Int)]);
let cache = schema_with_columns("Events", &[("a", Type::Int), ("b", Type::Int)]);
let input = "delete from Events where a = 'x' or b = 'y'";
let on_x = input.find("'x'").expect("'x' literal") + 1;
let on_y = input.find("'y'").expect("'y' literal") + 1;
@@ -2460,8 +2443,16 @@ mod tests {
inserted_range: (5, 5),
original_text: String::new(),
candidates: vec![
Candidate { text: "data".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both },
Candidate { text: "table".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both },
Candidate {
text: "data".to_string(),
kind: CandidateKind::Keyword,
mode: crate::completion::ModeClass::Both,
},
Candidate {
text: "table".to_string(),
kind: CandidateKind::Keyword,
mode: crate::completion::ModeClass::Both,
},
],
selection_idx: 1,
};
@@ -2494,8 +2485,16 @@ mod tests {
// produce — proves the memo's list is being used,
// not a recomputed one.
candidates: vec![
Candidate { text: "data".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both },
Candidate { text: "table".to_string(), kind: CandidateKind::Keyword, mode: crate::completion::ModeClass::Both },
Candidate {
text: "data".to_string(),
kind: CandidateKind::Keyword,
mode: crate::completion::ModeClass::Both,
},
Candidate {
text: "table".to_string(),
kind: CandidateKind::Keyword,
mode: crate::completion::ModeClass::Both,
},
],
selection_idx: 1,
};
@@ -2564,10 +2563,7 @@ mod tests {
fn classify_trailing_whitespace_does_not_create_definite_error() {
// Trailing whitespace alone shouldn't promote an
// incomplete-at-EOF state into a definite error.
assert_eq!(
classify_input("create "),
InputState::IncompleteAtEof,
);
assert_eq!(classify_input("create "), InputState::IncompleteAtEof,);
}
#[test]
+3 -4
View File
@@ -60,8 +60,8 @@ pub fn init(path: Option<&Path>) -> Result<PathBuf> {
.with_context(|| format!("create log directory {}", parent.display()))?;
}
let file = open_log_file(&chosen)?;
let filter = EnvFilter::try_from_env("RDBMS_PLAYGROUND_LOG")
.unwrap_or_else(|_| EnvFilter::new("info"));
let filter =
EnvFilter::try_from_env("RDBMS_PLAYGROUND_LOG").unwrap_or_else(|_| EnvFilter::new("info"));
let layer = fmt::layer()
.with_writer(file)
.with_ansi(false)
@@ -95,8 +95,7 @@ fn home_dir() -> Option<PathBuf> {
if let Some(p) = std::env::var_os("HOME") {
return Some(PathBuf::from(p));
}
if let (Some(drive), Some(path)) =
(std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH"))
if let (Some(drive), Some(path)) = (std::env::var_os("HOMEDRIVE"), std::env::var_os("HOMEPATH"))
{
let mut combined = PathBuf::from(drive);
combined.push(path);
+6 -1
View File
@@ -1,6 +1,6 @@
use std::process::ExitCode;
use rdbms_playground::cli::{help_text, Args};
use rdbms_playground::cli::{Args, help_text, version_text};
use rdbms_playground::{logging, runtime};
fn main() -> ExitCode {
@@ -22,6 +22,11 @@ fn main() -> ExitCode {
}
};
if args.version {
println!("{}", version_text());
return ExitCode::SUCCESS;
}
if args.help {
print!("{}", help_text());
return ExitCode::SUCCESS;
+69 -42
View File
@@ -172,7 +172,10 @@ fn constraint_lines(desc: &TableDescription) -> Vec<String> {
/// A `detail` matching no marker renders neutral — the engine's
/// plan vocabulary may grow (ADR-0028 §4).
const PLAN_TAXONOMY: &[(&str, OutputStyleClass)] = &[
("USING AUTOMATIC COVERING INDEX", OutputStyleClass::AutomaticIndex),
(
"USING AUTOMATIC COVERING INDEX",
OutputStyleClass::AutomaticIndex,
),
("USING AUTOMATIC INDEX", OutputStyleClass::AutomaticIndex),
("USING COVERING INDEX", OutputStyleClass::Efficient),
("USING INTEGER PRIMARY KEY", OutputStyleClass::Efficient),
@@ -225,8 +228,7 @@ fn render_plan_subtree(
emitted: &mut HashSet<i64>,
mode: Mode,
) {
let children: Vec<&ExplainRow> =
rows.iter().filter(|r| r.parent == parent).collect();
let children: Vec<&ExplainRow> = rows.iter().filter(|r| r.parent == parent).collect();
let last_idx = children.len().saturating_sub(1);
for (idx, row) in children.iter().enumerate() {
if !emitted.insert(row.id) {
@@ -235,8 +237,7 @@ fn render_plan_subtree(
let is_last = idx == last_idx;
let connector = if is_last { "└─ " } else { "├─ " };
out.push(plan_node_line(prefix, connector, &row.detail, mode));
let child_prefix =
format!("{prefix}{}", if is_last { " " } else { "" });
let child_prefix = format!("{prefix}{}", if is_last { " " } else { "" });
render_plan_subtree(rows, row.id, &child_prefix, out, emitted, mode);
}
}
@@ -343,13 +344,8 @@ pub fn render_diagnostic_table(
const fn alignment_for(ty: Option<Type>) -> Alignment {
match ty {
Some(Type::Int | Type::Real | Type::Decimal | Type::Serial) => Alignment::Right,
Some(Type::Text)
| Some(Type::Bool)
| Some(Type::Date)
| Some(Type::DateTime)
| Some(Type::Blob)
| Some(Type::ShortId)
| None => Alignment::Left,
Some(Type::Text) | Some(Type::Bool) | Some(Type::Date) | Some(Type::DateTime)
| Some(Type::Blob) | Some(Type::ShortId) | None => Alignment::Left,
}
}
@@ -406,11 +402,7 @@ fn cell_width(s: &str) -> usize {
/// Render a single bordered table given header cells, body
/// rows, and per-column alignment. Outer frame +
/// header-underline only.
fn render_table(
headers: &[String],
body: &[Vec<String>],
alignments: &[Alignment],
) -> Vec<String> {
fn render_table(headers: &[String], body: &[Vec<String>], alignments: &[Alignment]) -> Vec<String> {
debug_assert_eq!(headers.len(), alignments.len());
// Compute column widths: max(header, all body cells).
@@ -792,13 +784,12 @@ fn gutter_seg(i: usize, child_rows: &[usize], parent_rows: &[usize], w: usize) -
}
// The vertical bus spans the full range of endpoint rows.
let bounds = child_rows
.iter()
.chain(parent_rows)
.copied()
.fold(None, |acc: Option<(usize, usize)>, r| {
let bounds = child_rows.iter().chain(parent_rows).copied().fold(
None,
|acc: Option<(usize, usize)>, r| {
Some(acc.map_or((r, r), |(lo, hi)| (lo.min(r), hi.max(r))))
});
},
);
if let Some((top, bot)) = bounds
&& i >= top
&& i <= bot
@@ -1138,7 +1129,10 @@ mod tests {
assert!(out.contains("customer_id ●"), "FK marker:\n{out}");
assert!(out.contains("id (PK) ●"), "parent endpoint marker:\n{out}");
assert!(out.contains('▶'), "arrowhead:\n{out}");
assert!(out.contains('n') && out.contains('1'), "cardinality:\n{out}");
assert!(
out.contains('n') && out.contains('1'),
"cardinality:\n{out}"
);
assert!(
out.contains("on delete cascade · on update no action"),
"actions:\n{out}"
@@ -1237,7 +1231,10 @@ mod tests {
let (r_out, r_in) = blank_rels();
let region = TableDescription {
name: "Region".to_string(),
columns: vec![col("country", Type::Int, true, false), col("code", Type::Int, true, false)],
columns: vec![
col("country", Type::Int, true, false),
col("code", Type::Int, true, false),
],
outbound_relationships: r_out,
inbound_relationships: r_in,
indexes: Vec::new(),
@@ -1277,7 +1274,10 @@ mod tests {
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("region_code ●"), "child endpoint 2:\n{text}");
assert!(text.contains("(PK) ●"), "parent endpoint is PK + marked:\n{text}");
assert!(
text.contains("(PK) ●"),
"parent endpoint is PK + marked:\n{text}"
);
assert!(
text.contains("(country, region_code) ▶ Region.(country, code)"),
"pairing line:\n{text}",
@@ -1412,11 +1412,7 @@ mod tests {
let data = DataResult {
table_name: "Customers".to_string(),
columns: vec!["id".to_string(), "Name".to_string(), "Email".to_string()],
column_types: vec![
Some(Type::Serial),
Some(Type::Text),
Some(Type::Text),
],
column_types: vec![Some(Type::Serial), Some(Type::Text), Some(Type::Text)],
rows: vec![
vec![
Some("1".to_string()),
@@ -1634,7 +1630,10 @@ mod tests {
assert!(out.contains("Indexes:"), "got:\n{out}");
assert!(out.contains("idx_email (Email)"), "got:\n{out}");
// A plain index carries no uniqueness marker.
assert!(!out.contains("[unique]"), "plain index unmarked; got:\n{out}");
assert!(
!out.contains("[unique]"),
"plain index unmarked; got:\n{out}"
);
}
#[test]
@@ -1677,7 +1676,10 @@ mod tests {
indexes: Vec::new(),
unique_constraints: vec![vec!["a".to_string(), "b".to_string()]],
check_constraints: vec![
crate::persistence::TableCheck { name: None, expr: "a < b".to_string() },
crate::persistence::TableCheck {
name: None,
expr: "a < b".to_string(),
},
crate::persistence::TableCheck {
name: Some("a_lt_b".to_string()),
expr: "a <> b".to_string(),
@@ -1691,7 +1693,10 @@ mod tests {
// (ADR-0035 Amendment 1) so the user can `drop constraint <name>`.
assert!(out.contains("unique_a_b: unique (a, b)"), "got:\n{out}");
assert!(out.contains("check (a < b)"), "unnamed check; got:\n{out}");
assert!(out.contains("check a_lt_b (a <> b)"), "named check shows its name; got:\n{out}");
assert!(
out.contains("check a_lt_b (a <> b)"),
"named check shows its name; got:\n{out}"
);
}
#[test]
@@ -1732,17 +1737,37 @@ mod tests {
let plan = QueryPlan {
display_sql: "SELECT 1".to_string(),
rows: vec![
ExplainRow { id: 1, parent: 0, detail: "root".to_string() },
ExplainRow { id: 2, parent: 1, detail: "child-a".to_string() },
ExplainRow { id: 3, parent: 1, detail: "child-b".to_string() },
ExplainRow {
id: 1,
parent: 0,
detail: "root".to_string(),
},
ExplainRow {
id: 2,
parent: 1,
detail: "child-a".to_string(),
},
ExplainRow {
id: 3,
parent: 1,
detail: "child-b".to_string(),
},
],
};
let lines = render_explain_plan(&plan, Mode::Simple);
// display SQL + 3 plan nodes.
assert_eq!(lines.len(), 4);
assert!(lines[1].text.contains("root"));
assert!(lines[2].text.contains("├─ child-a"), "got {:?}", lines[2].text);
assert!(lines[3].text.contains("─ child-b"), "got {:?}", lines[3].text);
assert!(
lines[2].text.contains("─ child-a"),
"got {:?}",
lines[2].text
);
assert!(
lines[3].text.contains("└─ child-b"),
"got {:?}",
lines[3].text
);
// The single root uses `└─`; its children are indented
// by three spaces (no `│` spine, the root being last).
assert!(lines[1].text.starts_with("└─ root"));
@@ -1775,7 +1800,10 @@ mod tests {
fn render_explain_plan_colours_a_full_scan_expensive() {
let plan = one_node_plan("SCAN Customers");
let lines = render_explain_plan(&plan, Mode::Simple);
assert_eq!(span_class_for(&lines[1], "SCAN"), OutputStyleClass::Expensive);
assert_eq!(
span_class_for(&lines[1], "SCAN"),
OutputStyleClass::Expensive
);
// The table name stays neutral (ADR-0028 §6).
assert_eq!(
span_class_for(&lines[1], "Customers"),
@@ -1801,8 +1829,7 @@ mod tests {
#[test]
fn render_explain_plan_flags_an_automatic_index() {
let plan =
one_node_plan("SEARCH Orders USING AUTOMATIC COVERING INDEX (CustId=?)");
let plan = one_node_plan("SEARCH Orders USING AUTOMATIC COVERING INDEX (CustId=?)");
let lines = render_explain_plan(&plan, Mode::Simple);
assert_eq!(
span_class_for(&lines[1], "USING AUTOMATIC COVERING INDEX"),
+29 -13
View File
@@ -150,7 +150,9 @@ fn encode_cell(ty: Type, value: &CellValue) -> Result<Cell, String> {
other => Err(format!("expected date/datetime (text), got {other:?}")),
},
Type::Blob => match value {
CellValue::Blob(bytes) => Ok(Cell::Plain(base64::engine::general_purpose::STANDARD.encode(bytes))),
CellValue::Blob(bytes) => Ok(Cell::Plain(
base64::engine::general_purpose::STANDARD.encode(bytes),
)),
other => Err(format!("expected blob, got {other:?}")),
},
Type::Serial => match value {
@@ -169,7 +171,11 @@ fn format_real(f: f64) -> String {
if f.is_nan() {
"nan".to_string()
} else if f.is_infinite() {
if f > 0.0 { "inf".to_string() } else { "-inf".to_string() }
if f > 0.0 {
"inf".to_string()
} else {
"-inf".to_string()
}
} else {
// Default `{}` formatting on f64 emits a shortest
// round-tripping decimal — exactly what the ADR asks
@@ -318,8 +324,7 @@ fn parse_field(bytes: &[u8]) -> Result<(RawCell, usize), CsvError> {
_ => i += 1,
}
}
let content =
String::from_utf8(bytes[..i].to_vec()).map_err(|_| CsvError::InvalidUtf8)?;
let content = String::from_utf8(bytes[..i].to_vec()).map_err(|_| CsvError::InvalidUtf8)?;
Ok((
RawCell {
content,
@@ -435,7 +440,10 @@ mod tests {
name: "T".to_string(),
columns: vec![col("n", Type::Int), col("r", Type::Real)],
rows: vec![
vec![CellValue::Integer(42), CellValue::Real(std::f64::consts::PI)],
vec![
CellValue::Integer(42),
CellValue::Real(std::f64::consts::PI),
],
vec![CellValue::Integer(-7), CellValue::Real(0.0)],
],
})
@@ -452,10 +460,7 @@ mod tests {
let body = serialize_table(&TableSnapshot {
name: "T".to_string(),
columns: vec![col("b", Type::Bool)],
rows: vec![
vec![CellValue::Integer(1)],
vec![CellValue::Integer(0)],
],
rows: vec![vec![CellValue::Integer(1)], vec![CellValue::Integer(0)]],
})
.unwrap();
let s = String::from_utf8(body).unwrap();
@@ -555,13 +560,21 @@ mod tests {
let body = serialize_table(&table).unwrap();
let parsed = parse_csv(std::str::from_utf8(&body).unwrap()).unwrap();
let row = &parsed.rows[0];
assert!(matches!(decode_cell(Type::Int, &row[0]).unwrap(), CellValue::Integer(42)));
assert!(matches!(
decode_cell(Type::Int, &row[0]).unwrap(),
CellValue::Integer(42)
));
match decode_cell(Type::Real, &row[1]).unwrap() {
CellValue::Real(f) => assert!((f - std::f64::consts::PI).abs() < 1e-12),
other => panic!("got {other:?}"),
}
assert!(matches!(decode_cell(Type::Bool, &row[2]).unwrap(), CellValue::Integer(1)));
assert!(matches!(decode_cell(Type::Blob, &row[3]).unwrap(), CellValue::Blob(b) if b == b"hi"));
assert!(matches!(
decode_cell(Type::Bool, &row[2]).unwrap(),
CellValue::Integer(1)
));
assert!(
matches!(decode_cell(Type::Blob, &row[3]).unwrap(), CellValue::Blob(b) if b == b"hi")
);
}
#[test]
@@ -572,7 +585,10 @@ mod tests {
#[test]
fn decode_cell_reports_friendly_error_for_bad_int() {
let cell = RawCell { content: "abc".to_string(), was_quoted: false };
let cell = RawCell {
content: "abc".to_string(),
was_quoted: false,
};
let err = decode_cell(Type::Int, &cell).expect_err("must error");
assert!(err.contains("integer"));
assert!(err.contains("abc"));
+145 -22
View File
@@ -28,7 +28,35 @@ use super::PersistenceError;
pub(super) const STATUS_OK: &str = "ok";
pub(super) const STATUS_ERR: &str = "err";
/// Format a successful-command record. Pure; no I/O.
/// The optional status suffix marking an advanced-mode submission
/// (ADR-0052, issue #30): `ok:adv` / `err:adv`. Recorded so that
/// hydration can reconstruct the `:`-prefixed runnable form of an
/// advanced command, making advanced history reusable in simple mode.
pub(super) const ADV_SUFFIX: &str = "adv";
/// Build the status token for a `base` (`ok`/`err`) and submission mode.
pub(super) fn status_token(base: &str, advanced: bool) -> String {
if advanced {
format!("{base}:{ADV_SUFFIX}")
} else {
base.to_string()
}
}
/// Parse a status token into `(is_ok, advanced)` (ADR-0052). The base
/// (`ok` ⇒ replayable, anything else ⇒ skip) precedes an optional
/// `:adv` mode suffix. An unknown base degrades to `(false, _)`, so
/// replay skips it rather than mis-running it.
pub(super) fn parse_status(status: &str) -> (bool, bool) {
let (base, suffix) = status.split_once(':').unwrap_or((status, ""));
(base == STATUS_OK, suffix == ADV_SUFFIX)
}
/// Format a successful-command record. Pure; no I/O. (Simple-mode
/// convenience used by tests; production threads the mode through
/// [`format_record_with_status`] + [`status_token`], so this is
/// test-only since ADR-0052.)
#[cfg(test)]
pub(super) fn format_record(command_text: &str, timestamp_iso: String) -> String {
format_record_with_status(command_text, timestamp_iso, STATUS_OK)
}
@@ -80,10 +108,7 @@ pub(super) fn read_recent_sources(
});
}
};
let mut sources: Vec<String> = body
.lines()
.filter_map(parse_record_source)
.collect();
let mut sources: Vec<String> = body.lines().filter_map(parse_record_source).collect();
if sources.len() > max_n {
let skip = sources.len() - max_n;
sources.drain(0..skip);
@@ -100,9 +125,20 @@ fn parse_record_source(line: &str) -> Option<String> {
// characters) is preserved.
let mut parts = line.splitn(3, '|');
let _ts = parts.next()?;
let _status = parts.next()?;
let status = parts.next()?;
let source = parts.next()?;
Some(unescape_command(source))
let (_is_ok, advanced) = parse_status(status);
let command = unescape_command(source);
// ADR-0052: an advanced record is hydrated in its `:`-prefixed
// simple-mode runnable form, so cross-session recall matches the
// in-session ring (and recall strips the `:` again in advanced
// mode). A simple record hydrates bare. Old `ok`/`err` logs have no
// `:adv` suffix → read as simple, unchanged.
Some(if advanced {
format!(": {command}")
} else {
command
})
}
/// A parsed journal record (ADR-0034 §3). `source` is already
@@ -129,8 +165,11 @@ pub(super) fn parse_journal_record(line: &str) -> Option<JournalRecord> {
if !looks_like_iso8601(ts) {
return None;
}
// ADR-0052: the status may carry a `:adv` mode suffix; replayability
// keys off the base token only (`ok` / `ok:adv` are both ok).
let (status_is_ok, _advanced) = parse_status(status);
Some(JournalRecord {
status_is_ok: status == STATUS_OK,
status_is_ok,
source: unescape_command(source),
})
}
@@ -145,12 +184,26 @@ fn looks_like_iso8601(s: &str) -> bool {
return false;
}
let digit = |i: usize| b[i].is_ascii_digit();
digit(0) && digit(1) && digit(2) && digit(3) && b[4] == b'-'
&& digit(5) && digit(6) && b[7] == b'-'
&& digit(8) && digit(9) && b[10] == b'T'
&& digit(11) && digit(12) && b[13] == b':'
&& digit(14) && digit(15) && b[16] == b':'
&& digit(17) && digit(18) && b[19] == b'Z'
digit(0)
&& digit(1)
&& digit(2)
&& digit(3)
&& b[4] == b'-'
&& digit(5)
&& digit(6)
&& b[7] == b'-'
&& digit(8)
&& digit(9)
&& b[10] == b'T'
&& digit(11)
&& digit(12)
&& b[13] == b':'
&& digit(14)
&& digit(15)
&& b[16] == b':'
&& digit(17)
&& digit(18)
&& b[19] == b'Z'
}
fn unescape_command(s: &str) -> String {
@@ -279,10 +332,8 @@ mod tests {
#[test]
fn parse_journal_record_ok_extracts_unescaped_source() {
let rec = parse_journal_record(
"2026-05-24T10:00:00Z|ok|create table T with pk id(int)",
)
.expect("valid ok journal record");
let rec = parse_journal_record("2026-05-24T10:00:00Z|ok|create table T with pk id(int)")
.expect("valid ok journal record");
assert!(rec.status_is_ok);
assert_eq!(rec.source, "create table T with pk id(int)");
}
@@ -328,8 +379,8 @@ mod tests {
fn parse_journal_record_preserves_pipe_in_source() {
// `|` is not escaped by the writer (it's a valid SQL char);
// `splitn(3, '|')` keeps everything after the second `|`.
let rec = parse_journal_record("2026-05-24T10:00:00Z|ok|select 'a|b' from t")
.expect("ok record");
let rec =
parse_journal_record("2026-05-24T10:00:00Z|ok|select 'a|b' from t").expect("ok record");
assert_eq!(rec.source, "select 'a|b' from t");
}
@@ -364,7 +415,10 @@ mod tests {
#[test]
fn iso8601_known_seconds() {
assert_eq!(iso8601_from_unix_secs(0), "1970-01-01T00:00:00Z");
assert_eq!(iso8601_from_unix_secs(1_778_112_000), "2026-05-07T00:00:00Z");
assert_eq!(
iso8601_from_unix_secs(1_778_112_000),
"2026-05-07T00:00:00Z"
);
}
#[test]
@@ -395,7 +449,10 @@ mod tests {
.collect();
std::fs::write(&path, body).unwrap();
let got = read_recent_sources(&path, 3).unwrap();
assert_eq!(got, vec!["cmd7".to_string(), "cmd8".to_string(), "cmd9".to_string()]);
assert_eq!(
got,
vec!["cmd7".to_string(), "cmd8".to_string(), "cmd9".to_string()]
);
}
#[test]
@@ -436,4 +493,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()]);
}
}
+37 -43
View File
@@ -82,7 +82,10 @@ impl Default for MigratorRegistry {
#[derive(Debug)]
pub enum MigrateError {
VersionParse(String),
NewerThanSupported { file: u32, latest: u32 },
NewerThanSupported {
file: u32,
latest: u32,
},
NoMigratorForVersion(u32),
StepFailed {
from: u32,
@@ -108,10 +111,9 @@ impl std::fmt::Display for MigrateError {
file = file,
latest = latest,
)),
Self::NoMigratorForVersion(v) => f.write_str(&crate::t!(
"persistence.migrate.no_migrator",
version = v,
)),
Self::NoMigratorForVersion(v) => {
f.write_str(&crate::t!("persistence.migrate.no_migrator", version = v,))
}
Self::StepFailed { from, to, source } => f.write_str(&crate::t!(
"persistence.migrate.step_failed",
from = from,
@@ -192,8 +194,11 @@ pub fn migrate_to_latest(
// Write the .bak before any transformation runs so a
// mid-migration crash leaves the original recoverable.
let bak_path =
project_path.join(format!("{}.v{}.bak", crate::project::PROJECT_YAML, file_version));
let bak_path = project_path.join(format!(
"{}.v{}.bak",
crate::project::PROJECT_YAML,
file_version
));
std::fs::write(&bak_path, body).map_err(|source| MigrateError::Io {
path: bak_path.clone(),
source,
@@ -214,8 +219,8 @@ pub fn migrate_to_latest(
// Sanity: the new body must declare the next version.
// If a migrator forgets to bump, we'd loop endlessly
// through the chain — catch it here.
let advertised = read_version(&next_body)
.map_err(|e| MigrateError::BadOutput(e.to_string()))?;
let advertised =
read_version(&next_body).map_err(|e| MigrateError::BadOutput(e.to_string()))?;
if advertised != v + 1 {
return Err(MigrateError::BadOutput(format!(
"v{v}→v{} migrator left version field at {advertised}",
@@ -281,9 +286,8 @@ fn read_version(body: &str) -> Result<u32, MigrateError> {
struct VersionOnly {
version: u32,
}
let v: VersionOnly = serde_norway::from_str(body).map_err(|e| {
MigrateError::VersionParse(e.to_string())
})?;
let v: VersionOnly =
serde_norway::from_str(body).map_err(|e| MigrateError::VersionParse(e.to_string()))?;
Ok(v.version)
}
@@ -309,12 +313,8 @@ mod tests {
#[test]
fn no_migration_runs_when_body_already_latest() {
let tmp = tempdir();
let outcome = migrate_to_latest(
&v1_body(),
&MigratorRegistry::production(),
tmp.path(),
)
.unwrap();
let outcome =
migrate_to_latest(&v1_body(), &MigratorRegistry::production(), tmp.path()).unwrap();
assert_eq!(outcome.body, v1_body());
assert_eq!(outcome.migrated_from, None);
// No .bak written when nothing migrated.
@@ -328,7 +328,13 @@ mod tests {
let err = migrate_to_latest(body, &MigratorRegistry::production(), Path::new("/tmp"))
.expect_err("must reject");
assert!(
matches!(err, MigrateError::NewerThanSupported { file: 99, latest: 1 }),
matches!(
err,
MigrateError::NewerThanSupported {
file: 99,
latest: 1
}
),
"got: {err:?}",
);
}
@@ -366,12 +372,7 @@ mod tests {
#[test]
fn migrate_runs_chain_and_writes_bak() {
let tmp = tempdir();
let outcome = migrate_to_latest(
&v1_body(),
&registry_with_v1_to_v2(),
tmp.path(),
)
.unwrap();
let outcome = migrate_to_latest(&v1_body(), &registry_with_v1_to_v2(), tmp.path()).unwrap();
assert_eq!(outcome.migrated_from, Some(1));
assert!(outcome.body.contains("version: 2"));
let bak = tmp.path().join("project.yaml.v1.bak");
@@ -396,11 +397,8 @@ mod tests {
let tmp = tempdir();
let yaml_path = tmp.path().join("project.yaml");
std::fs::write(&yaml_path, v1_body()).unwrap();
let outcome = ensure_project_yaml_migrated(
tmp.path(),
&MigratorRegistry::production(),
)
.unwrap();
let outcome =
ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap();
assert_eq!(outcome.migrated_from, None);
// File unchanged.
let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
@@ -413,36 +411,32 @@ mod tests {
let tmp = tempdir();
let yaml_path = tmp.path().join("project.yaml");
std::fs::write(&yaml_path, v1_body()).unwrap();
let outcome = ensure_project_yaml_migrated(
tmp.path(),
&registry_with_v1_to_v2(),
)
.unwrap();
let outcome = ensure_project_yaml_migrated(tmp.path(), &registry_with_v1_to_v2()).unwrap();
assert_eq!(outcome.migrated_from, Some(1));
let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
assert!(on_disk.contains("version: 2"), "got: {on_disk}");
let bak = tmp.path().join("project.yaml.v1.bak");
assert!(bak.exists());
assert!(std::fs::read_to_string(&bak).unwrap().contains("version: 1"));
assert!(
std::fs::read_to_string(&bak)
.unwrap()
.contains("version: 1")
);
}
#[test]
fn ensure_yaml_migrated_handles_missing_yaml() {
let tmp = tempdir();
// No project.yaml exists.
let outcome = ensure_project_yaml_migrated(
tmp.path(),
&MigratorRegistry::production(),
)
.unwrap();
let outcome =
ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap();
assert_eq!(outcome.migrated_from, None);
assert!(outcome.body.is_empty());
}
#[test]
fn migrator_that_returns_internal_error_propagates() {
let bad: MigrateFn =
|_| Err(MigrateError::VersionParse("simulated".to_string()));
let bad: MigrateFn = |_| Err(MigrateError::VersionParse("simulated".to_string()));
let registry = MigratorRegistry {
migrators: vec![bad],
};
+43 -20
View File
@@ -368,12 +368,11 @@ impl Persistence {
path: data_dir.clone(),
source,
})?;
let body =
csv_io::serialize_table(table).map_err(|message| PersistenceError::Encode {
kind: "CSV",
path: data_dir.join(format!("{}.csv", table.name)),
message,
})?;
let body = csv_io::serialize_table(table).map_err(|message| PersistenceError::Encode {
kind: "CSV",
path: data_dir.join(format!("{}.csv", table.name)),
message,
})?;
atomic_write(&data_dir.join(format!("{}.csv", table.name)), &body)
}
@@ -395,11 +394,23 @@ 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 +421,19 @@ 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 line = history::format_record_with_status(
command_text,
history::utc_iso8601_now(),
history::STATUS_ERR,
let status = history::status_token(history::STATUS_ERR, advanced);
let line =
history::format_record_with_status(command_text, history::utc_iso8601_now(), &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)
}
@@ -508,8 +524,14 @@ mod tests {
#[test]
fn extension_with_tmp_appends_to_existing_extension() {
assert_eq!(extension_with_tmp(Path::new("a/b/project.yaml")), "yaml.tmp");
assert_eq!(extension_with_tmp(Path::new("a/b/Customers.csv")), "csv.tmp");
assert_eq!(
extension_with_tmp(Path::new("a/b/project.yaml")),
"yaml.tmp"
);
assert_eq!(
extension_with_tmp(Path::new("a/b/Customers.csv")),
"csv.tmp"
);
assert_eq!(extension_with_tmp(Path::new("a/b/lockfile")), "tmp");
}
@@ -577,8 +599,9 @@ 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);

Some files were not shown because too many files have changed in this diff Show More