Merge branch 'main' into website

This commit is contained in:
claude@clouddev1
2026-06-15 17:22:46 +00:00
111 changed files with 7635 additions and 884 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
+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"
+39
View File
@@ -0,0 +1,39 @@
# The CI gate. Runs inside the prebuilt nix toolchain image (built + pushed by
# build-ci-image.yaml), so the pinned 1.95.0 toolchain is already warm — steps
# just enter the flake devShell and run cargo.
#
# Gate = clippy + test. fmt is deliberately NOT gated yet (ADR-ci-002: the tree
# isn't clean under stock rustfmt; revisit on main). The release job (static
# binary for D2) and the platform matrix layer on later, step by step.
name: ci
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 docs-only changes — markdown can't affect clippy/test.
# A push touching code *and* docs still runs (not all files are ignored).
# Note: flake/toolchain changes are NOT ignored — they can shift the
# toolchain and thus lint/test outcomes.
paths-ignore:
- 'docs/**'
- '**/*.md'
pull_request:
paths-ignore:
- 'docs/**'
- '**/*.md'
jobs:
gate:
runs-on: ci-public
# Public package → anonymous pull, no credentials needed.
container:
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
steps:
- uses: actions/checkout@v4
- name: clippy (warnings denied)
run: nix develop -c cargo clippy --all-targets -- -D warnings
- name: test
run: nix develop -c cargo test --no-fail-fast
+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
+92
View File
@@ -0,0 +1,92 @@
# 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)
# macOS is deferred — its arboard/AppKit link needs Apple's SDK (see ADR-ci-001).
# 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.
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: 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"
+6
View File
@@ -2,6 +2,12 @@
/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
+25 -2
View File
@@ -108,6 +108,23 @@ Current decisions at a glance (each backed by an ADR):
SQL `select` / `with` / `insert` / `update` / `delete`
(ADR-0039). `EXPLAIN QUERY PLAN` never executes, so
explaining a destructive command is safe.
- **Continuous integration & release** (built on the `ci` branch,
2026-06-15; decisions in `docs/ci/adr/` — **ADR-ci-001/002/003**,
a namespace kept separate from the main ADR sequence to avoid
cross-branch number collisions, like the website's): a self-hosted
**Gitea Actions** pipeline built on a **nix flake** (pinned Rust
`1.95.0` — one source of toolchain for dev *and* CI) plus a
prebuilt CI image. **Gate** (`ci.yaml`): `clippy -D warnings` +
`cargo test` on every branch push / PR. **Release** on a `v*` tag
(`release.yaml`): the four non-macOS **D1** targets cross-built
with `cargo-zigbuild` (Linux musl static + standalone Windows
`.exe`); the two macOS targets via the **dispatched**
`release-macos.yaml` on a Tart Apple-Silicon runner (de-nix the
`libiconv` load path + ad-hoc re-sign). All published to a Gitea
release with `.sha256`s. **`fmt` is intentionally not gated yet**
(the tree isn't stock-`rustfmt`-clean). `workflow_dispatch` is
Gitea-default-branch-only, so `release-macos` is dispatchable once
this lands on `main`.
## Repository layout
@@ -347,8 +364,14 @@ not yet implemented:
Ctrl-Enter submits.
- **Tab completion** (I3), **syntax highlighting** (I4).
- **ER diagram export** (V3).
- **CI** (TT5): test infrastructure exists; CI workflow not
yet configured.
- **Full TT5** (CI): the pipeline is live (see the CI decision
above / `docs/ci/adr/`), but "all tiers on all OSes" isn't
complete — **Windows is build-only** (cross-compiled, not
executed: no Windows runner) and **Tier 4** (PTY, TT4) isn't
wired in CI.
- **D3 packaging**: prebuilt binaries + checksums ship to Gitea
releases, but the Homebrew / Scoop / winget / `cargo binstall`
manifests are not done.
## Handoff notes
+6
View File
@@ -68,6 +68,12 @@ tempfile = "3.27.0"
incremental = false
debug = "line-tables-only"
# Release builds back the distributed binaries (D2: single static binary).
# strip = "symbols" drops the symbol table at link time so the shipped artifact
# is lean (≈13 MB → 10 MB for the musl build) without a separate strip step.
[profile.release]
strip = "symbols"
[lints.rust]
unsafe_code = "forbid"
unreachable_pub = "warn"
+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>
+62
View File
@@ -70,3 +70,65 @@ True UUIDs are intentionally **not** in the type set.
- Learners who later need a true UUID column will find that the
app does not provide one; this is a deliberate trade-off in
favour of TUI legibility.
## Amendment 1 — display rounding of coerced doubles (2026-06-12)
Issue #32. The Decision keeps `decimal` exact by storing it as
TEXT, noting that "numeric ops require casts" — the engine has no
native decimal/BCD type (SQLite's storage classes are only NULL /
INTEGER / REAL / TEXT / BLOB; `NUMERIC` is an affinity, not a
type). What the original wording did not anticipate is that the
engine performs that cast **implicitly**: `sum(price * qty)` over
TEXT decimals coerces to an IEEE-754 double with no explicit cast,
and the computed result carries no playground type (ADR-0030 §6),
so it rendered with the double's full noise —
`298.59999999999997` for `298.60`. For a teaching tool that is a
confusing, off-topic lesson about float representation.
### Decision
**Round floating-point values to 15 significant figures for
display only.** A double carries ~1517 significant decimal digits
and the noise lives in the last one or two; rounding to 15 then
taking the shortest round-tripping form of the rounded value
collapses `298.59999999999997``298.6` and
`0.30000000000000004``0.3`. A clean value rounds to itself, so
the result is never longer than before; non-finite values pass
through. Implemented as `format_real_display` in `db.rs`.
The rounding is wired into **exactly one place — `format_cell`,
the result-set / `show data` cell formatter** — because that is
the only surface where the IEEE-754 noise actually appears: noise
arises from *arithmetic/aggregation*, whose results flow through
`format_cell`. Every other `f64`-to-string path deliberately keeps
full precision, and the distinction is **semantic, not cosmetic**:
- **Persistence stays exact.** The CSV encoder
(`persistence::csv_io::format_real`) keeps the shortest
round-tripping form so a stored `real` survives save/load
byte-for-byte — rounding there would corrupt data.
- **Uniqueness dry-runs key on exact values.** `render_value`
(the diagnostic/echo formatter) is reused as a *canonical
identity key* by `dry_run_unique` (ADR-0029 §5) and
`check_uniqueness_collisions` (ADR-0017 §4.3): they group rows
by this string to predict the duplicates the engine would
reject. Rounding there would merge two distinct doubles into one
key and report a collision the engine — which compares exact
values — would not. So `render_value` keeps `format!("{r}")`.
(It also never displays a *computed* value, so it has no noise
to trim.)
- **FK-key matching and EXPLAIN-SQL literals keep full
precision** — neither is a data-cell display.
Within `format_cell` the rounding applies to **all** REAL cells
(stored `real` columns and computed results alike), for one
consistent rule; the lost digits are at the double's precision
limit, not real information, and a stored `real` typed by the user
is itself noise-free so its display is unchanged in practice. Raw
`decimal` columns are unaffected — they are TEXT and render
verbatim, trailing zeros and all (`100.10`). Exact decimal
*arithmetic* (a SQLite extension exposing
`decimal_mul`/`decimal_sum`) was considered and rejected: it would
require rewriting the user's standard-SQL operators into function
calls, defeating both the "validated SQL runs verbatim" model and
the goal of teaching ordinary SQL.
+8
View File
@@ -213,6 +213,14 @@ working copy.
### 6. Persistence ordering
> **Amended by ADR-0052 (2026-06-13, issue #30):** `history.log` is no
> longer written inside the worker transaction. It is a *journal* of typed
> commands, not state, so success journaling moved to the dispatch layer
> (next to the already-top-level failure journaling); `commit-db-last` now
> governs the three **state** targets only (db + `project.yaml` +
> `data/*.csv`), which still commit atomically in the worker. The journal
> write is best-effort (amends ADR-0040).
A successful user command produces effects in four targets:
the SQLite database, `project.yaml`, the relevant
`data/<table>.csv` file(s), and `history.log`. INV-2 from the
+10
View File
@@ -197,6 +197,16 @@ Referenced by:
The relationship sections retain today's plain-text format
to leave room for the future relationship-rendering ADR.
> **Superseded.** ADR-0044 replaced this prose block with compact
> diagrams on relationship-subject surfaces (`show table`,
> `add`/`drop relationship`). **ADR-0050 (2026-06-12, issue #28)** then
> removed the relationship block entirely from incidental-DDL structure
> echoes (`create table`, `add`/`drop`/`rename`/`change column`,
> `add`/`drop index`) — those render structure only — and **deleted the
> prose renderer**. The `References:` / `Referenced by:` format above is
> retained here as documentation/provenance should the OOS-7
> always-prose display setting ever be built.
### 6. Theme integration
Theme colors apply to the box-drawing characters via the
@@ -772,6 +772,58 @@ invalid_ident_does_not_fire_for_column_prefix_at_sql_expr_slot}`;
`theme::function_colour_is_distinct_from_keyword_identifier_and_type`.
See ADR-0031's status note for the grammar-side anchor.
## Amendment 7 — optional positional args reach the hint panel (2026-06-12)
Issue #26. At `seed <table> ▮` the hint panel showed only the
`set` / `--seed` continuation chips and never mentioned the
**optional row count** — even though a count (`seed users 50`) is
the most common next move. The count is a bare positional
`NumberLit` with no keyword/candidate text, so the candidate ladder
can't surface it; and `seed <table>` is already a *complete*
command, so the hint resolver short-circuits (empty expected set).
The existing `IntroProse` `HintMode` (ADR-0024 §HintMode-per-node;
issue #4's CREATE-TABLE element hint) is the right tool — it shows
prose that *introduces* a position whose first-class move has no
candidate, with the keyword alternatives folded into the prose and
Tab still cycling them. But it did not reach this position: a
`Node::Hinted`'s mode lives in `pending_hint_mode`, which the very
next match clears — including the **empty** match of a skipped
`Optional`. The CREATE-TABLE element survives only because it sits
in a *required* `Repeated(min:1)`; an optional positional followed
by more optionals (the seed count) is cleared before the resolver
reads it.
### Mechanism
A small, general carry: when `walk_optional` skips its inner (the
inner didn't engage), it stashes any `IntroProse` key the inner
left in `pending_hint_mode` into a new `WalkContext` field,
`surviving_intro_hint: Option<(key, position)>`, **before** the
empty match clears `pending_hint_mode`. The trailing optionals,
which are not `IntroProse`, don't overwrite it. The hint snapshot
keeps the key **only when `position == cursor`** (the slice end),
so it shows while the cursor sits at the count slot but not once a
later clause (`set …`) consumes input past it, nor once the count
itself is supplied. The resolver returns that `IntroProse` even for
an otherwise-complete command (ahead of the empty-expected
short-circuit).
The seed grammar wraps the count in
`Hinted { IntroProse("hint.seed_count"), NumberLit }`; the prose
names the count (with its default 20) plus the `.column`
column-fill form and the `set` / `--seed` keywords (user-chosen
scope: mention every option). Only `IntroProse` is carried —
`ProseOnly` / `ForceProse` mark *active* slots and reach the
resolver through the normal path, unchanged. The CREATE-TABLE
element (in a `Repeated`, not an `Optional`) is untouched.
This is a refinement of ADR-0024 §HintMode-per-node and a sibling
of issue #4; no `AmbientHint` / renderer change. Covered by
`input_render::{seed_count_is_advertised_at_the_optional_position,
seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given,
seed_count_hint_also_fires_after_a_column_fill_target}`.
## Out of scope
Deliberately deferred to keep this ADR shippable as a single
+70
View File
@@ -1480,6 +1480,76 @@ accumulators), the per-keystroke re-walk (ADR-0027's
debounced cadence), and the ORDER BY no-fixup-needed
clarification.
## Amendment 3 — bare table aliases in expression slots (2026-06-12)
Issue #31. A bare in-scope table alias typed where the grammar
expects a column — `… GROUP BY o`, with `o` aliasing
`FROM Orders o` — was a blind spot in two surfaces:
- **Completion (§10).** §10.5 narrows columns *past* a
`qualifier .`, but the bare-ident slot before the dot offered
only columns and function names, never the aliases themselves.
A learner mid-typing `o` toward `o.<column>` got no Tab help.
- **Diagnostics (§11.2).** §11.2 added `projection_alias_misplaced`
for a *projection* alias used in a forbidden clause, but a bare
*table* alias fell through to the generic `unknown_column`
bare-reference check (§11.2's `matched.len() == 0` arm), which
reported `no such column \`o\` on table \`Orders, …\`` — calling
an in-scope alias an unknown column.
### What changes
1. **Completion offers in-scope FROM qualifiers at a bare
`sql_expr_ident` slot** (one not already past a `qualifier .`).
Each binding contributes its *qualifier* — the alias if it has
one, else the table name (an aliased source must be referenced
by its alias). Folded into the existing `IdentSource::Columns`
candidate list so it sorts / dedups / colours uniformly. When
the partial *exactly* matches an in-scope qualifier the alias
source steps aside: discoverability is already served, and
suppressing sibling aliases lets the diagnostic below surface
(rather than being hidden by the `typing_over_diag` path).
2. **A bare ident matching an in-scope qualifier now emits a
targeted diagnostic** instead of `unknown_column`, checked in
the `matched.len() == 0` arm *after* the projection-alias check
(so an ORDER-BY projection-alias reference still wins). It is a
drop-in replacement at the same span and `Error` severity — only
the message text changes — so the validity verdict, token
overlay, and hint-panel paths behave exactly as they did for
`unknown_column`:
- `diagnostic.alias_used_as_column` — `` `o` is a table alias —
write `o.<column>` to reference one of its columns `` (the
binding has an alias), or
- `diagnostic.table_used_as_column` — same shape, "is a table"
(an un-aliased table source).
Two guards keep the qualified-form advice correct (both covered
by regression tests):
- **SQL only.** The branch fires only for `role ==
"sql_expr_ident"`. The DSL `Expr` (role `expr_column`) reaches
the same arm but has no `table.column` syntax, so a DSL bare
table-name ref keeps the generic `unknown_column` — advising
the qualified form there would be wrong.
- **Effective-qualifier match.** It matches the binding's
*effective qualifier* — the alias if present, else the table
name — not the table name independently. An aliased source
must be referenced by its alias (`FROM a x … GROUP BY a` is
invalid SQL), so the shadowed real name `a` falls through to
`unknown_column` rather than being advised as `a.<column>`.
This mirrors the completion side's qualifier rule exactly.
A genuine unknown column (matching no alias, table, or column)
still reports `unknown_column` verbatim.
The message tail is deliberately clause-neutral ("to reference
one of its columns") rather than GROUP-BY-specific, because the
bare-reference arm fires across the projection, `WHERE`,
`GROUP BY`, and `HAVING`.
This is an additive refinement of §10 and §11.2; no grammar node
changes.
## See also
- ADR-0005 — the ten-type vocabulary §10 resolves back to.
@@ -2,7 +2,13 @@
## Status
Accepted
Accepted. **Amended by ADR-0052 (2026-06-13, issue #30):** the status
field gains an optional `:adv` mode suffix (`ok:adv` / `err:adv`) — the
"non-breaking future extension" this ADR reserved — and **success
journaling moves out of the worker to the dispatch layer**
(`spawn_dsl_dispatch` / `run_replay` / app-command sites), next to the
failure path, where the submission mode is in scope. `status_is_ok` keys
off the base token, so `ok:adv` replays like `ok`.
## Context
@@ -5,7 +5,11 @@
**Accepted** — 2026-05-30 (issue #9). Amends the output conventions of
ADR-0014 (data operations), ADR-0028 (query plans / `explain`), and
ADR-0019 (failure rendering); builds on ADR-0037's mode-tagged echo
line.
line. **Amended by ADR-0052 (2026-06-13, issue #30):** a `history.log`
*journal*-write failure on a **successful** command is no longer fatal —
journaling moved to the dispatch layer (after the db commit), so it is
best-effort (logged + ignored), consistent with the failure-journal path.
State-write failures (yaml/csv/db) remain fatal.
## Context
@@ -103,6 +103,10 @@ Prose-retained surfaces (**unchanged** from ADR-0016 §5):
`add`/`drop index` — keep the terse `References:` /
`Referenced by:` prose. A simple `add column` on a heavily-related
table should not print a wall of diagrams.
*(**Superseded 2026-06-12 by ADR-0050** (issue #28): these incidental
DDL echoes now render **structure only** — no relationship block at
all, neither prose nor diagram. The prose renderer was deleted. The
diagram surfaces below are unchanged.)*
So this **partially supersedes ADR-0016 §5**: the prose block is
replaced by diagrams on the relationship-subject surfaces and
@@ -525,7 +525,9 @@ All tiers green, zero skips; clippy clean (nursery).
submits over a multi-logical-line buffer. DA3/DA4 keep a single
logical line; this remains a separate, deferred feature.
- **Readline shortcuts (I1b)** — Ctrl-A/E/W/K/U stay reserved-deferred;
not touched here.
not touched here. *(Superseded 2026-06-12: I1b is now in scope and
decided by **ADR-0049** — Esc-clear + Ctrl-A/E/W/K/U in the input
field, issue #29.)*
- **Cross-session sidebar persistence** — visibility is session-only
(DB1); persisting it would amend ADR-0015.
- **The output panel as a third navigation focus target** — navigation
@@ -554,3 +556,27 @@ All tiers green, zero skips; clippy clean (nursery).
and is accepted: 90 is the screencast width, real terminals sit well
to one side of it, and `Ctrl-O` peek covers the in-between case. The
`90` threshold is a tunable constant.
## Amendment 1 — focus accent is a colour, not bold (2026-06-12)
Issue #25. DC3's "accent border" on the focused sidebar panel was
first implemented as bright `theme.fg` **plus `Modifier::BOLD`** on
the box-drawing border. Bold box-drawing glyphs render as broken /
gapped line-art in the asciinema player used for the website casts
(vertical strokes don't connect to the corner glyphs) and are
fragile in some terminals.
**`panel_border_style` now marks focus with a non-bold accent
colour — `theme.mode_simple` (blue) — and never `Modifier::BOLD` on
a border.** The unfocused border stays muted `theme.border`. This
makes the ADR's "accent border (lazygit convention)" wording
literal — it is now a true accent hue rather than bold bright-fg —
and is what renders cleanly in casts. Bold remains fine on *text*
spans (titles, key hints); the constraint is specifically that
box-drawing borders carry no bold attribute.
Note: this is a pure style change. The Tier-2 snapshots are
text-only (`render_to_string` captures cell symbols, not styles),
so none needed re-accepting; the Tier-1 `panel_border_style`
assertion was updated and a render-level test now checks the actual
border cells carry the accent colour and no bold.
@@ -317,6 +317,8 @@ with the implementation):
| `url`/`website`/`homepage` · `color`/`colour` | URL / hex colour | text |
| `price`/`amount`/`cost`/`salary`/`balance`/`total` | currency-range number | numeric |
| `age` · `quantity`/`qty`/`stock`/`count` | 1880 · small int | numeric |
| `year`/`*_year`/`published`/`founded` (Amendment 1) | bounded year (birth window for `birth`/`born`/`dob`, else 19502025) | int |
| `priority`/`prio` · `severity` · `rating`/`stars` (Amendment 1) | built-in `PickFrom` value set | text/int |
| `date`/`*_date` | date, recent ~3 yr window | date |
| `dob`/`birthday` | date, adult window (1880 yr ago) | date |
| `timestamp`/`datetime` · `created_at`/`updated_at`/`*_at` | datetime, recent window (`updated_at``created_at`) | datetime |
@@ -675,3 +677,66 @@ the regression floor.
derive-`IN`-else-friendly-fail tier.
- **`set`-driven NULL / per-column report / recursive parent seed:**
deferred — see Out of scope.
## Amendment 1 — year-as-int + conventional choice sets (2026-06-12)
Two SD2-style refinements to the D7 catalogue, surfaced while writing
the website `seed` docs. Both are additive name rules; no change to D8
(type fallback), the executor, or the grammar.
### Issue #33 — year-like `int` columns
A column such as `published` or `birth_year` was just an `int`, so it
fell through to the unbounded type-based `int` path (D8) and produced
nonsense like `9419` or `1426` — implausible as years, undercutting the
"realistic data" pedagogy. Added an **`int`-gated** year rule, placed
*after* the quantity rule (so `year_count` stays a count):
- `year` / `*_year` / `published` / `founded` → **`YearRecent`**, a
bounded window of **19502025** (75 years relative to the fixed
`REF_YEAR`, wide enough for published books / founding years /
release years; matches the issue's own `between 1950 and 2020`
workaround).
- the same with a `birth` / `born` / `dob` token (e.g. `birth_year`) →
**`YearBirth`**, mirroring the existing `dob → DateAdult` adult birth
window as years (**19452007**).
Both emit a plain `int`. `published` / `founded` are included
(user-confirmed): an `int` so named is almost always a year (a flag
would be `is_published`). The generators are **not** added to the D9
named-generator vocabulary — explicit control stays with `set <col>
between <lo> and <hi>`.
### Issue #34 — built-in value sets for conventional choice names
D12 deliberately does not guess values for enum-ish names. For a few,
though, there is a near-canonical small set that reads far better than
lorem text. Added a **type-gated `PickFrom`** lookup (reusing the
existing generator — no new machinery), placed ahead of the enum-ish
fallthrough:
| Name (tokens) | text | int |
|---|---|---|
| `priority` / `prio` | `low`/`medium`/`high` | `1`/`2`/`3` |
| `severity` | `low`/`medium`/`high`/`critical` | `1`/`2`/`3`/`4` |
| `rating` / `stars` | — | `1``5` |
A user-declared `IN`-CHECK (D17) still wins — it is resolved before the
heuristics. Any name that gains a set is **removed from the enum-ish
advisory trigger** (`priority` left `ENUM_TOKENS`); since the advisory
(D13) only fires on `Generator::Generic`, a `PickFrom` name is excluded
either way, but the removal keeps `is_enum_ish` semantically "names seed
still can't guess".
**`status` is deliberately excluded** (user-confirmed on the issue): its
real values are too domain-specific (`active/inactive`,
`open/closed/pending`, `draft/published`, …), so it keeps the D12
"don't guess" stance — generic text + the advisory pointing at `set
status in (…)`. `state` stays its US-state-name generator (D7);
`type`/`kind`/`category`/`stage`/`gender` and `size`/`tier`/`plan` were
considered and left to the advisory.
**Website follow-up** (tracked on the `website` branch, not here): the
`seed` cast exercises a `tickets` table with `priority`; it should be
re-recorded so the table tightens once `priority` collapses to a short
value — likely subsumed by the pre-publication cast sweep.
@@ -0,0 +1,114 @@
# ADR-0049: Input-field readline keymap — Esc-clear + Ctrl-A/E/W/K/U (I1b)
## Status
**Accepted + implemented 2026-06-12 (issue #29).** Closes Gitea **#29**
("Command input keystroke support") and the deferred **I1b** readline
requirement in `requirements.md`. Every fork below was escalated to the
user and user-chosen before any code was written; implemented test-first
(22 new Tier-1 tests in `src/app.rs`, all green; clippy nursery clean).
This ADR **amends ADR-0046**, which explicitly listed "readline
shortcuts (I1b)" in its out-of-scope set: that item is now in scope and
decided here. It is orthogonal to ADR-0003's input-*mode* model (simple
vs advanced, the `:` sigil) — these are editing keys within the input
field, not mode or sigil changes — and it extends the single-line cursor
editing already shipped under requirement **I1a** (Left/Right/Home/End/
Backspace/Delete, `app.rs`).
## Context
The input field already supported in-line cursor editing (I1a): Left/
Right by char (UTF-8 aware), Home/End to the extremes, Backspace/Delete.
Two gaps remained, raised in issue #29:
1. No way to **clear a partly-typed command** in one keystroke — a user
who started typing the wrong thing had to hold Backspace.
2. No **readline cursor/kill shortcuts** (Ctrl-A/Ctrl-E and friends) for
keyboards without Home/End and for muscle-memory in a command-driven
workflow. This is requirement I1b, deferred by ADR-0046.
`Esc` was free in the input field except that a *live Tab-completion
memo* consumes it first (to undo the completion in one keystroke,
ADR-0022). Ctrl-A/E/W/K/U were unbound. The existing chords are Ctrl-C
(quit), Ctrl-O (nav focus cycle, ADR-0046), and Ctrl-`]` (demo caption
toggle, ADR-0047) — none collide with a/e/w/k/u.
## Decision
Bind the following in the input field (non-modal, non-navigation,
both input modes), in `App::handle_key`:
| Key | Action |
|-----------|---------------------------------------------------|
| `Esc` | Clear the input (empty buffer, cursor→0, scroll→0)|
| `Ctrl-A` | Cursor to line start (alias of Home) |
| `Ctrl-E` | Cursor to line end (alias of End) |
| `Ctrl-W` | Delete the word before the cursor |
| `Ctrl-K` | Kill from the cursor to end of line |
| `Ctrl-U` | Kill from start of line to the cursor |
Behavioural rules:
- **Esc precedence.** A live completion memo still wins: the first Esc
undoes the completion (ADR-0022), and Esc only *clears* when no memo
is alive. This is a natural progression — Esc once to back out the
completion, Esc again to clear.
- **Esc does not clear while navigating the sidebar.** When a sidebar
panel is focused (Ctrl-O, ADR-0046 DC3), `handle_key` routes every
key to the navigation handler *before* the input-field keymap, where
Esc exits navigation mode (`nav_exit`). Entering nav mode never
touched the input buffer, so Esc-to-close-the-panel returns focus to
the input with the partly-typed command intact — it cannot reach the
clear binding. Locked by a regression test.
- **Single Esc clears** (user-chosen over double-Esc). Discoverable and
fast; the trade-off (an accidental Esc wipes an unsubmitted line) was
accepted. A submitted line is always recoverable from history; only
*unsubmitted* draft text is lost.
- **Cursor-only keys don't touch history navigation.** Ctrl-A/Ctrl-E,
like Home/End, move the cursor without ending history recall.
- **Buffer-mutating keys end history navigation.** Esc-clear and
Ctrl-W/K/U call `cancel_history_navigation` (the cleared/edited line
*is* the new draft), matching Backspace/Delete.
- **Ctrl-W is readline-style and UTF-8 safe.** It eats any run of
trailing whitespace, then the preceding run of non-whitespace; word
boundaries are found on char boundaries so multi-byte words delete
cleanly. It only ever deletes back to the cursor (a mid-line Ctrl-W
leaves the suffix intact).
Helpers added: `clear_input`, `delete_prev_word`, `kill_to_end`,
`kill_to_start` (`src/app.rs`), mirroring the existing `cursor_left` /
`delete_before_cursor` style.
## Forks (all user-chosen)
- **Esc semantics:** single-Esc-clears, *not* double-Esc — discoverable
over accident-proof.
- **Scope:** the *full* I1b set (Esc-clear + Ctrl-A/E/W/K/U), not just
the issue's literal Ctrl-A/E + Esc — closes the whole I1b requirement
in one pass rather than leaving Ctrl-W/K/U for a follow-up.
- **Documentation:** a new ADR (this one), recording the input-field
keymap convention and amending ADR-0046's OOS list — over folding it
into ADR-0046 or shipping it I1a-style with no ADR.
## Consequences
- I1b is complete; `requirements.md` I1b moves to `[x]`.
- The new keys are **not yet advertised on screen.** Surfacing per-focus
keybindings in the bottom status line is issue #27's domain (a
separate, in-design UX change); this ADR makes the keys *work*, #27
will make them *discoverable*.
- **Demo-mode badges** (ADR-0047) are *not* extended to the new Ctrl-
chords here. Esc already badges as `[ESC]`; Ctrl-A/E/W/K/U are
glyph-less and would be invisible in an asciinema cast. Whether to add
`[CTRL-A]``[CTRL-U]` badges is left to ADR-0047's scope and flagged
as a follow-up — it is a cast-polish concern, not a #29 requirement.
## Out of scope
- On-screen keybinding hints for the input field (issue #27).
- Demo badges for the new chords (ADR-0047 follow-up; flagged above).
- Multi-line input (I1) and its Ctrl-Enter submit — unrelated, still
deferred.
- Word-wise *cursor motion* (Alt-B/Alt-F) and transpose/yank — not
requested; not part of I1b.
@@ -0,0 +1,119 @@
# ADR-0050: Incidental-DDL confirmations omit relationship info (structure-only)
## Status
**Accepted + implemented 2026-06-12 (issue #28).** Closes Gitea **#28**.
Both forks below were escalated to the user and user-chosen before any
code was written; implemented test-first. **Supersedes** the
incidental-DDL clause of **ADR-0044 §1** and the part of **ADR-0016 §5**
that placed a relationship block under every structure echo. The
diagram behaviour ADR-0044 introduced for relationship-subject surfaces
is unchanged.
## Context
ADR-0016 §5 rendered a structure box followed by a plain-text
`References:` / `Referenced by:` relationship block under **every**
structure echo. ADR-0044 §1 split that by surface:
- **Relationship-subject surfaces**`show table <T>`,
`add 1:n relationship`, `drop relationship`, `show relationship <name>`
— render relationships as compact **diagrams** (the user asked for, or
acted on, a relationship).
- **Incidental DDL auto-shows**`create table`, `add`/`drop`/`rename`/
`change column`, `add`/`drop index` — kept the terse **prose** block,
with the rationale *"a simple `add column` on a heavily-related table
should not print a wall of diagrams."*
Issue #28 reconsiders the deeper question ADR-0044 did not ask: should
an incidental-DDL confirmation show relationship information **at all**?
Owner preference: **no.** A confirmation echo should focus on the change
just made — the new / updated structure — not re-print the table's
relationships, which the user did not touch. The terse prose was the
lesser of "prose vs diagram", but the right answer for these surfaces is
**neither**.
## Decision
**Incidental-DDL confirmation echoes render the structure only** — the
table-name header, the column / type / constraints box, the `Indexes:`
section, and the constraint section — with **no relationship section**
(neither prose nor diagram).
- **Scope: all incidental DDL** (user-chosen, over "just `add column`"):
`create table`, `add column`, `drop column`, `rename column`,
`change column`, `add index`, `drop index`. The rule is uniform — a
structural edit confirms structure, never relationships. (For a
freshly `create`d table the relationship section was empty anyway; the
rule still applies for consistency of the mental model.)
- **Relationship-subject surfaces are unchanged.** `show table`,
`add`/`drop relationship`, and `show relationship <name>` still render
diagrams. Relationships appear **only** when the user asks for them
(`show table` / `show relationship`) or acts on one
(`add`/`drop relationship`).
- **No information is lost.** Anything dropped from an incidental echo is
one `show table <T>` away.
### Mechanism
The `handle_dsl_success` routing (`app.rs`) is **unchanged**: it still
sends relationship-subject commands to the diagram renderer and
everything else to `render_structure`. The change is entirely inside
`render_structure` (`output_render.rs`): it no longer appends the
relationship block — `render_structure` = structure box + indexes +
constraints. All of `render_structure`'s callers are incidental DDL
(verified), so this single edit covers the whole scope with no
per-command branching.
### Prose renderer disposition
The orphaned prose renderer (`relationship_prose_lines`, and its
sole helper `cols_disp`) is **deleted** (user-chosen, over retaining it
dormant). After this change no shipped surface renders the prose form,
so keeping it would be dead code. The prose format remains documented in
**ADR-0016 §5** and in git history; if ADR-0044's OOS-7 user-configurable
"always-prose" display setting is ever built, it re-introduces the ~30
lines from that provenance.
## Forks (all user-chosen)
- **Scope:** *all incidental DDL*, not just `add column` — the owner's
rationale ("confirm the change, not untouched relationships") applies
uniformly, gives a clean mental model, and is the simpler edit (remove
one call vs a per-command flag).
- **Prose renderer:** *delete* it — no dead code — over retaining a
public, tested-but-uncalled renderer for the speculative OOS-7 setting.
## Consequences
- Incidental confirmations are shorter and on-topic; a heavily-related
table no longer prints a relationship wall after `add column`.
- One relationship renderer (prose) leaves the codebase; the diagram
renderer (ADR-0044) is the only relationship render path that ships.
- `requirements.md` is unaffected (this is an ADR-tracked refinement of a
decided area, like ADR-0044 itself); the change is cross-referenced
from the commit + this ADR.
## Tests
- **Unit (`output_render.rs`):** the prose-asserting test
`render_structure_with_relationships` (+ its snapshot) is removed; a
new test asserts `render_structure` on a description **carrying** both
inbound and outbound relationships emits the structure box but **no**
`References:` / `Referenced by:` lines. The box/index/constraint tests
are unaffected (their descriptions have no relationships).
- **Integration (`walking_skeleton.rs`):** the misnamed
`add_relationship_flow_shows_inbound_section_on_parent` (which sends an
`AddColumn` and asserted the inbound prose) is inverted + renamed to
assert the add-column confirmation shows the structure but **omits**
the relationship prose.
- **Unchanged:** the diagram tests (`show_list.rs` `show table`,
`walking_skeleton.rs` `add relationship`) still pass — they already
assert prose is absent and diagrams are present.
## Out of scope
- The diagram form and its per-surface defaults (ADR-0044) — unchanged.
- The OOS-7 user-configurable display setting (always-prose / -diagram /
auto-by-width) — still a future follow-up; this ADR removes the prose
*renderer*, not the *idea* of a prose mode.
@@ -0,0 +1,147 @@
# ADR-0051: Bottom keybinding strip — context- and state-aware
## Status
**Accepted 2026-06-13 (issue #27).** Closes Gitea **#27**. All forks
below were escalated to the user and user-chosen before any code was
written; to be implemented test-first. Builds on ADR-0046 (nav focus),
ADR-0003 (input modes), ADR-0049 (the #29 readline keys this strip now
advertises), and ADR-0022 (the Tab-completion memo).
## Context
The bottom status line (`render_status_bar`, `ui.rs`) mixed keystrokes
with typed-command words: `Enter submit · : advanced once · mode
advanced switch · Ctrl-C quit`. That is redundant — the hint panel
already teaches `help` and `Enter` when the input is empty — and it is
static apart from a three-way mode branch, so it never reflects what the
user can actually do *right now* (navigating the sidebar, cycling a
completion, browsing history, editing a line).
Issue #27: repurpose the line as a **keybindings-only** strip that is
**context-sensitive to nav focus** and **state-aware of the current
transient interaction**, and move mode discovery into the empty-input
hint.
## Decision
### 1. The strip is keybindings-only and state-selected
A single pure function `status_bar_bindings(app) -> Vec<Binding>`
computes the strip from app state; `render_status_bar` is a thin
renderer over it (so the binding sets are unit-testable without a
Frame). `history_cursor` is private to `App`, so a small
`pub fn is_browsing_history(&self) -> bool` accessor exposes the
history-navigation predicate; `mode` / `nav_focus` / `last_completion`
are already `pub` and `effective_mode()` is a `pub` method. The state is
chosen by **priority — first match wins**:
| Priority | State (predicate) | Strip |
|---|---|---|
| 1 | **Sidebar focus** (`nav_focus` in a sidebar) | `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input` |
| 2 | **Completion memo live** (`last_completion.is_some()`) | `Tab/Shift-Tab cycle · Esc cancel · Enter run` |
| 3 | **History navigation** (`history_cursor.is_some()`) | `↑↓ browse · Esc clear · Enter run` |
| 4 | **Editing** (Input focus, input non-empty) | `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` |
| 5 | **Default** (Input focus, input empty) | `Ctrl-O sidebar · Tab complete · ↑ history · Enter run` |
Priority order matters: a completion memo or history navigation is a
non-empty-input situation, so states 2 and 3 must precede state 4. The
sidebar overlay occludes the input entirely (ADR-0046), so state 1 wins
outright.
### 2. Mode discovery moves off the strip, into the empty-input hint
The typed-command advertisements (`mode advanced` / `mode simple`
switch, the `:` one-shot) leave the strip — they are not keystrokes.
Mode discovery moves to the **empty-input hint** (`resolve_hint_lines`'s
`(None, None)` arm), in **simple mode only**:
- **Simple:** `… · \`mode advanced\` for SQL`
- **Advanced (persistent):** no pointer.
The pointer omits the verb "type" — the surrounding prompt already
implies it (we don't say "type `help`" either). Advanced mode shows
**no** pointer (user decision, post-trial): a user who switched into
advanced mode knows how they got there, and `help` covers the way back —
a "switch back" pointer only reads naturally in the moment right after
switching, so it earns its space poorly.
The one-shot advanced state's old `Backspace cancel one-shot` label is
**subsumed** by the editing state (the input is non-empty in one-shot;
Esc-clear and Backspace both cancel it). No behaviour is lost — only the
dedicated label.
### 3. Width: no drop machinery; a budget test instead
The longest strip (state 4, editing) is ≈ **65 display columns**, which
fits every supported width (90-col screencasts, 80-col terminals) with
margin — so the priority-drop / abbreviation machinery considered would
never trigger and is not built (user-confirmed). Ratatui's existing
**clip-at-edge** is the trivial fallback for pathologically narrow
(< 65-col) terminals. Instead, a **width-budget unit test** pins the
longest rendered strip within an 80-col budget, keeping the strip lean
*by construction* — a future over-long strip fails the test rather than
silently clipping in a cast.
## Forks (all user-chosen)
- **Editing state — yes:** when the input has text, surface the #29
readline keys (Esc-clear, Ctrl-A/E, Ctrl-W); the strip stays lean
(nav/complete/history) when empty. (vs not advertising the #29 keys.)
- **`Ctrl-C quit` — omitted** from the strip (vs always shown): quit is
a near-universal convention; omitting it keeps the strips lean and
matches the issue's sketch.
- **Width — budget test, no drop logic** (vs graceful priority-drop /
abbreviation): the strips fit at supported widths, so the machinery
would be dead weight (user's own observation).
## Consequences
- The strip now teaches the keys for the *current* situation; learners
see `Tab/Shift-Tab cycle` exactly while cycling, the editing keys
exactly while editing, etc.
- The #29 readline keys (ADR-0049) gain their on-screen advertisement,
closing that ADR's deferred item.
- 15 existing full-panel insta snapshots churn (the bottom line — and,
on empty-input views, the hint pointer — changes in every one,
including the rebuild-confirm modal view, whose modal box is itself
unchanged); each diff was reviewed, not blind-accepted.
- `requirements.md` is unaffected (an ADR-tracked UI refinement); the
change is cross-referenced from the commit + this ADR.
## Tests
- **Tier-1 (`ui.rs` unit):** `status_bar_bindings` returns the expected
key set for each of the five states (sidebar, completion-live,
history-nav, editing, default) — the completion/history states driven
through real key events (`update`) so the predicate transitions are
exercised, the others by setting `App` fields; plus the width-budget
assertion across states. (Per-state coverage is these unit tests, not
snapshots — a one-line strip is asserted more precisely by its exact
key list than by a full-panel snapshot.)
- **Tier-1:** the empty-input hint appends the correct mode pointer in
Simple vs Advanced, and does **not** append it when an ambient hint is
showing (non-empty input).
- **Tier-3 (`walking_skeleton`):** the old `status_bar_lists_quit_and_
submit_in_all_modes` (which asserted the pre-ADR strip) is rewritten +
renamed to assert the keystroke-only, state-aware strip end-to-end
through the real render path (default → editing transition).
- **Tier-2 (insta):** the 15 full-panel snapshots re-accepted (each diff
reviewed — strip line and/or hint pointer only).
## Out of scope
- **Modal-aware strip.** While a modal is open (load picker, rebuild /
undo confirm) it owns the keyboard and carries its own in-box key
hints; the bottom strip under a modal computes from input state
exactly as it does today (modals render *over* the status bar). This
issue does not redesign the modal case — pre-existing behaviour,
unchanged and not worsened.
- A persistent/togglable help overlay listing *all* keys (the strip is a
contextual subset, not a cheatsheet).
- Per-key colour theming beyond the existing key/label/separator styles.
- Localisation of the new label strings beyond adding catalog entries.
- The remaining I1b kill keys' (Ctrl-K/Ctrl-U) advertisement — the
editing strip shows the highest-value subset (Esc/Ctrl-A/E/Ctrl-W) to
stay within the width budget; Ctrl-K/U remain unadvertised muscle
memory.
@@ -0,0 +1,250 @@
# ADR-0052: Mode-tagged history for cross-mode recall
## Status
**Accepted + implemented 2026-06-13 (issue #30).** Closes Gitea **#30** —
both the feature ("reuse advanced history commands in simple mode by
prepending `:`") and the bug reported in its comment (the `:` one-shot
prefix lost across sessions). All forks user-chosen before any code.
**Amends ADR-0034** (journal status field gains a `:adv` tag; *journaling
moves from the worker to the dispatch layer*), **ADR-0015 §5/§6**
(history.log leaves the worker transaction — `commit-db-last` now scopes
yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure
is best-effort, no longer fatal); references ADR-0003 (the `:` one-shot
sigil). Plan: `docs/plans/20260613-issue-30-top-of-chain-journaling.md`
(pre-build `/runda`, then a second `/runda` that drove the journaling
relocation + the app-command exclusion). **2471 tests pass / 0 fail / 0
skip (1 ignored), clippy clean.**
> **Why journaling moved (the key architectural turn).** The first draft
> kept journaling in the worker and threaded the mode down to it (~30-site
> plumbing). On review the user asked the right question: why is the
> journal written deep in the worker at all, when the failure path already
> journals at the top of the chain where command + mode + outcome are all
> in scope? It shouldn't — `history.log` is a *journal of typed commands*,
> not *state*. So success journaling moved up next to the failure path
> (`spawn_dsl_dispatch` / `run_replay` / the app-command sites), the
> mode-plumbing dilemma dissolved, and the worker's `finalize_persistence`
> now writes only the state sources (yaml/csv). Consequence: the journal
> write is best-effort (the command is already committed), consistent with
> the failure path — see §5.
## Context
The input-history ring and `history.log` carry **no mode information**,
which causes two coupled problems:
1. **Feature gap.** A command typed in advanced mode (`select * from T`)
is stored bare. Recalled in simple mode it is not valid DSL → it just
errors. There is no way to know it was an advanced (SQL) command and
offer it back in a runnable form.
2. **Bug (issue #30 comment).** A `:`-one-shot advanced command in simple
mode recalls correctly **in-session** (the in-memory ring stores the
raw `:select 1`), but after quit+resume it comes back **without** the
`:` and is unusable. Root cause: the ring stores the raw input
(`:select 1`), but the worker journals the **stripped** `effective_input`
(`select 1`) — submission strips the `:` before dispatch (ADR-0003) —
so the on-disk `source` never carried the `:`, and hydration loses it.
Both reduce to: **history does not record the submission mode**, and the
in-memory and on-disk representations disagree about the `:`.
## Decision
Record the **submission mode** per history entry, keep the on-disk
`source` **canonical** (stripped — replay is unaffected), and have
**recall reconstruct the runnable line** for the current mode.
### 1. In-memory ring stores the `:`-prefixed runnable form
`App.history` stays `Vec<String>` — no type change, so the public ring,
the `ProjectSwitched` payload, and `seed_history` are untouched. An
**advanced** entry is stored in its **simple-mode runnable form**, the
`: `-prefixed string (e.g. `: select * from T`); a **simple** entry is
stored bare. This is exactly what the in-session one-shot ring already
does (`:select 1` recalls as typed) — generalised to *persistent*-advanced
commands too, and made reconstructable on hydration. Because a simple
DSL command can never begin with `:` (the sole sigil, ADR-0003), a
leading `:` unambiguously marks an advanced entry.
`submit` builds the stored line from the submission: advanced →
`": " + effective_input` (the `: ` matches the auto-space the typed
one-shot inserts), simple → `effective_input`. This is computed **after**
`effective_input` (today `push_history` runs on the raw `trimmed` before
stripping; the reorder also drops a bare `:`, which never executed). The
draft (`history_draft`) stays a plain `String`. `push_history` itself is
unchanged — it still takes one `&str`.
### 2. Recall strips the `:` for advanced mode
`history_back` / `history_forward` set `self.input` from the stored
string, then strip a leading `:` **iff the current persistent mode is
Advanced**:
```
if self.mode == Mode::Advanced && stored.starts_with(':') { stored[1..].trim_start() } else { stored }
```
So an advanced entry recalls as `: select * from T` in **simple** mode
(runs via the one-shot escape — the feature, and the cross-session bug
fix) and bare `select * from T` in **advanced** mode (runs as SQL). A
simple entry recalls bare in either mode (simple DSL already runs in
advanced mode — issue #30). In-session and cross-session paths share the
same stored form, so they finally agree.
### 3. On-disk: a mode tag in the status field
The record stays three pipe-separated fields `<ts>|<status>|<source>`
(so `source` remains the last, pipe-tolerant, canonical field — replay
reads it unchanged). The **status token** gains an optional `:adv`
suffix:
| Submission | Success | Failure |
|---|---|---|
| Simple | `ok` | `err` |
| Advanced (persistent or one-shot) | `ok:adv` | `err:adv` |
ADR-0034 §1 already reserved the status field for "additional values …
a non-breaking future extension"; this is that extension. The status
parser splits the token on `:`: the base (`ok`/`err`) gives replayability
(`status_is_ok` ⇔ base == `ok`), the `adv` suffix gives the mode — so an
unknown future token degrades to "not ok, simple" rather than mis-parsing.
### Journaling location: the dispatch layer, not the worker
Both tags are written **at the dispatch layer**, where command + mode +
outcome are all in scope — so the mode needs no plumbing into the worker:
- **Success:** `spawn_dsl_dispatch`, immediately after
`execute_command_typed` returns `Ok`, calls
`append_history(source, submission_mode.is_advanced())` (best-effort).
`run_replay` does the same per replayed line (tagged simple — replay is
mode-agnostic), and the app-command sites (`perform_switch` /
`spawn_export` / `spawn_rebuild`) journal **simple** (`advanced = false`
— app commands run in any mode, so no `:` on recall; this also avoids a
redundant `: undo`).
- **Failure:** unchanged location (the App→`JournalFailure`→runtime path,
already at the top), now carrying the mode — `JournalFailure` gains
`advanced`, and `DslFailed` gains `submission_mode` for the
worker-rejection sub-path (the parse-failure sub-path has it in
`dispatch_dsl`). `Ok`/`Err` are exclusive, so success-in-spawn and
failure-in-App-path never double-journal.
The worker's `finalize_persistence` and the four no-op-skip / three
read-only sites **no longer journal** — they leave the state writes
(yaml/csv) in the worker transaction and let the dispatch layer journal
the `Ok` outcome.
### 4. Hydration reconstructs the `:`-prefixed form
`read_recent_sources` parses each record's status tag and, for an
advanced record, **reconstructs** the `: `-prefixed string from the
canonical `source` (`format!(": {source}")`); simple records pass through
bare. It still returns `Vec<String>`, so `read_history_seed`,
`seed_history`, and the `ProjectSwitched` payload are **unchanged**. A
hydrated entry is therefore byte-identical to its in-session form, and
recall behaves identically.
### Back-compatibility
Old `history.log` files have only `ok` / `err` tokens → parsed as
`advanced = false` (simple). Their advanced commands stay un-`:`-able on
recall — the pre-existing behaviour, not a regression; nothing migrates.
`status_is_ok` keys off the base token, so `ok:adv` records replay
exactly as `ok` does today (source is canonical either way).
### Journal write is best-effort (amends ADR-0040)
Because the journal is now written *after* the worker replies (i.e. after
`tx.commit`), a journal-write failure can no longer roll the command back.
It is **best-effort** — logged and ignored, exactly like the failure path
already is (ADR-0034 §4) — so the two journal paths are finally
consistent. State integrity is unchanged: yaml/csv/db still commit
atomically in the worker (a *state*-write failure still rolls back and is
fatal). The only property given up: on a rare journal-write failure (disk
full) a committed command may be missing from `history.log` — not
recallable/replayable next session, but the state is correct. User-chosen
over keeping journaling coupled in the worker (which would have needed the
~30-site mode plumbing). See the plan's §2 for the full trade-off.
## Forks (user-chosen)
- **Format = mode tag in the status field** (`ok:adv`/`err:adv`), over a
new 4th field (ambiguous with unescaped pipes in old `source`s without
a version bump) or a `:`-prefix in `source` (would make `source`
non-canonical and force replay to strip it).
- **Scope = unified** (bug + feature) over bug-only: one mechanism does
both, and keeping `source` canonical for replay needs the mode tag
regardless, so bug-only is barely smaller and leaves the main ask open.
- **Journaling location = dispatch layer, best-effort** over keeping it
worker-coupled-and-fatal (which needed the ~30-site mode plumbing). The
user's architectural call (§Status).
## Consequences
- Advanced history is reusable in simple mode; the `:` one-shot survives
resume. The in-memory and on-disk representations agree.
- **Journaling left the worker.** `finalize_persistence` and the
no-op-skip / read-only sites no longer journal; success is journalled at
the dispatch layer (`spawn_dsl_dispatch` / `run_replay` / app-command
sites). The ring stays `Vec<String>`; `seed_history` / `ProjectSwitched`
are untouched. The vestigial worker `source` plumbing has since been
**fully unwound** (2026-06-14 follow-up): `_source` removed from
`finalize_persistence` / `do_rebuild_from_text`; the three read-only
`*_request` wrappers inlined and deleted; and — because the cascade ran
deeper than first estimated — the now-dead `source` param dropped from
the ~30 worker handlers (leaf + composite) that only forwarded it, plus
the `source` field removed from the `DescribeTable` / `QueryData` /
`RunSelect` requests and the matching `DatabaseHandle` method parameters
(the ~164 call-site churn was mostly tests). The only `source` left in
the worker is the snapshot/undo label (`snapshot_then` /
`stage_pre_mutation` / `begin_batch`), passed at the match-arm level.
Purely mechanical, compiler-guided, no behaviour change.
- **App commands recall bare.** Because they are dispatched outside the
`ExecuteDsl`/spawn path, app commands journal **simple** (`advanced =
false`) at their own sites, and `submit` excludes them from the ring's
`advanced` flag (`!is_app_command`) — so `mode advanced` / `undo` recall
bare and run fine in simple mode, with no redundant `:`.
- **Journaling is now uniform (user-confirmed).** The spawn journals on
`outcome.is_ok()`, so **every** successful command is recorded — closing
a pre-existing gap where `show table` / `show data` / `select` journalled
but `show tables`/`show relationships`/`show indexes`, `show relationship
<name>`, and `explain` did **not** (their worker arms carried no
`source` / no journal call). The new behaviour matches ADR-0034 §1
("record every submitted command"); those reads are now recallable and
are re-run harmlessly on replay (`explain` never executes; shows produce
output, no state change). A DA finding, accepted as the more-correct
behaviour over re-adding command-outcome gating to preserve the old
inconsistency.
- **Replay re-journaling.** When `replay` re-dispatches a line, the
re-written record is tagged from how replay dispatched (mode-agnostic →
`ok`), so a replayed advanced command may be re-journalled without
`:adv`. Replay correctness of execution is unchanged (it already parses
mode-agnostically); this only affects the *tag* of the re-written line.
Noted; not addressed here (replay's own mode-fidelity is out of scope).
## Tests
- **Tier-1 (`app.rs`):** an advanced one-shot / persistent-advanced
submission is stored `: `-prefixed; it recalls as `: …` in simple mode
and bare in advanced mode; a simple entry recalls bare in both; a bare
`:` is not stored; a parse-failure is still recallable; dedup/cap hold.
- **Tier-1 (`history.rs`):** the writer emits `ok:adv`/`err:adv`;
`read_recent_sources` reconstructs the `: `-prefix for `:adv` records
and leaves `ok`/`err` records bare (so old logs read as simple);
`status_is_ok` is true for `ok` and `ok:adv`.
- **Tier-3 (`iteration6_resume_history` / it):** the headline
**regression** — type a `:`-one-shot advanced command, journal +
hydrate, and assert it recalls **with** `:` in simple mode (fails on
current code); plus a persistent-advanced command round-tripping to a
`: …` recall.
## Out of scope
- Replay re-journaling mode-fidelity (above).
- Special-casing app commands to avoid the redundant recall `: `.
- Distinguishing one-shot from persistent advanced on recall (both are
simply "advanced" — the `:` is what simple mode needs either way).
- A format version marker / pipe-escaping in `source` (unneeded — the
status-tag approach keeps `source` last and canonical).
@@ -0,0 +1,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).
+10 -5
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).
+135
View File
@@ -0,0 +1,135 @@
# 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).
+195
View File
@@ -0,0 +1,195 @@
# 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 — 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).
- [ADR-ci-003 — Cross-platform release builds (the D1 matrix)](20260613-adr-ci-003.md) — **Accepted 2026-06-13** (implemented + a real matrix release verified the same day — tag `v.0.0.0-citest3` published 8 assets). Cross-compiles the **four non-macOS D1 targets** from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + libc as one universal cross cc/linker, incl. rusqlite's bundled SQLite C; added to the flake devShell, replacing the single-target musl cc): **`x86_64`/`aarch64-unknown-linux-musl`** (musl + crt-static → fully static, **D2**) and **`x86_64-pc-windows-gnu`** / **`aarch64-pc-windows-gnullvm`** (Zig statically links libc → standalone `.exe`). **Windows `synchronization` stub:** Rust std links `-lsynchronization` (WaitOnAddress thread-parking), an import lib rust-overlay's toolchain doesn't ship and Zig's mingw lacks; the symbols are forwarded by `kernel32`, so an **empty 8-byte stub** `libsynchronization.a` (`ci/winstub/`, wired via `.cargo/config.toml` for the Windows targets only) satisfies the linker. **Workflow:** `release.yaml` = **`test` once (host) → `build` matrix** over the four targets (`needs: test`, `fail-fast: false`); each job packages binary (`.exe` for Windows) + `.sha256` and uploads to the **shared release** via idempotent create-or-get. **macOS** (2026-06-14 amendment) — built natively on a **Tart (Apple-Silicon) runner** (`runs-on: macos`), which makes the SDK fully licensed and dissolves the grey-area/public-image problem; `release-macos.yaml` is **dispatch-only** (intermittent runner; becomes triggerable once CI is on `main`), de-nixes the binary's libiconv load path (`install_name_tool``/usr/lib`) + re-signs ad-hoc, and uploads to the tagged release. **D1 complete (all six targets).** Alternatives rejected: `cross` (no macOS, needs DinD), per-target nix cross (Windows-aarch64 unpackaged, macOS-from-Linux unsupported), a real `libsynchronization.a` (more machinery, doesn't cover Windows-aarch64). Runtime-verified by the user (2026-06-13): Linux x86_64 + Windows aarch64 run correctly; Linux aarch64 + Windows x86_64 are the outstanding runtime checks. Builds on ADR-ci-002 (flake) and fills in ADR-ci-001 §3 (Release).
+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`.
+173
View File
@@ -0,0 +1,173 @@
# Session handoff — 2026-06-12 (68)
Sixty-eighth handover. Continues directly from handoff-67 (which
triaged a manual-testing pass into fixes + filed issues). This was an
**issue-burndown session**: six Gitea issues closed across five
commits, each landed with the full phased workflow + a `/runda` +
Devil's-Advocate pass before commit. Net: **six issues closed, five
commits, +29 tests, zero regressions.**
## §1. State at handoff
**Branch:** `main`. Working tree **clean**; all work committed.
**Five unpushed commits** (push is the user's step).
**Tests: 2436 passing / 0 failing / 0 skipped / 1 ignored** (the
long-standing `friendly` doctest). **Clippy clean** (nursery, all
targets). Breakdown: 1730 lib + 506 integration (`it`) + 200
typing-surface-matrix. +29 over handoff-67's 2407.
**Commits since handoff-67:**
```
ee3ccd8 feat(hint): advertise the optional seed count in the hint panel (#26)
deb0948 feat(seed): year-as-int + conventional choice-set heuristics (#33, #34)
fde50ce fix(ui): mark sidebar focus with an accent colour, not bold (#25)
3d4a0fd fix(render): trim IEEE-754 noise from displayed decimal arithmetic (#32)
7e4bc12 fix(completion): treat a bare in-scope table alias as an alias, not an unknown column (#31)
```
## §2. Issues closed this session (all committed, all tested, all `/runda`-reviewed)
Each closed on `git.lazyeval.net/oli/rdbms-playground` with a summary
comment. The `/runda` pass earned its keep on every one — see the
"DA caught" notes.
1. **#31 (`7e4bc12`) — bare table alias treated as unknown column.**
A bare in-scope table alias in a SQL expression (`… GROUP BY o`,
`o` aliasing `FROM Orders o`) got `no such column o on table …` and
zero completions. Now: completion offers each FROM source's
*qualifier* (alias-if-present-else-table) at a bare `sql_expr_ident`
slot; the `matched.len()==0` arm emits a targeted
`alias_used_as_column` / `table_used_as_column` hint after the
projection-alias check. **DA caught** two real bugs pre-commit: a
DSL leak (the hint fired for simple-mode `expr_column` refs, which
have no `table.column` syntax) and wrong advice for an
aliased-table-by-real-name — both fixed by gating on
`role == "sql_expr_ident"` + matching the *effective qualifier*.
ADR-0032 Amendment 3.
2. **#32 (`3d4a0fd`) — decimal aggregation float noise.** `decimal`
is exact TEXT, but SQLite has no decimal type, so arithmetic
coerces to IEEE-754 double; `sum(price*qty)` rendered
`298.59999999999997`. Now `format_real_display` (db.rs) rounds REAL
to 15 sig figs **for display only**, wired into `format_cell`.
**DA caught** a real regression: I'd also wired it into
`render_value`, which is a *canonical identity key* for the
uniqueness dry-runs (`dry_run_unique`, `check_uniqueness_collisions`)
— rounding there would report collisions the exact-valued engine
wouldn't. Reverted `render_value` to exact; locked with a
regression test. CSV/FK-key/EXPLAIN paths stay exact. ADR-0005
Amendment 1.
3. **#25 (`fde50ce`) — sidebar focus accent colour, not bold.** Bold
box-drawing glyphs render broken in asciinema casts.
`panel_border_style` now uses a non-bold accent colour
(`theme.mode_simple`); bold stays fine on text spans. **DA caught**
that the issue's "Tier-2 snapshots need re-accepting" was wrong —
`render_to_string` is text-only, so no snapshot changed. Added a
render-level test that inspects the actual border *cells*.
User visually confirmed. ADR-0046 Amendment 1.
4. **#33 + #34 (`deb0948`) — seed heuristics: year-as-int + choice
sets.** Two additive D7 catalogue rules. **#33:** `year`/`*_year`/
`published`/`founded` → bounded `int` year (19502025, or the
`dob`-style birth window 19452007 for `birth`/`born`/`dob`); new
`YearRecent`/`YearBirth` generators. Placed *after* the quantity
rule so `year_count` stays a count. **#34:** type-gated `PickFrom`
sets for `priority`/`prio`, `severity`, `rating`/`stars`; `status`
**deliberately excluded** (user-confirmed on the issue — values too
domain-specific). `priority` left `ENUM_TOKENS`. A user `IN`-CHECK
still wins. **DA/process caught** that I'd skipped reading the issue
*comments* (where the `status` decision + a website cast note lived)
**lesson: always read issue comments**. Also closed a
pre-existing column-fill integration-test gap. ADR-0048 Amendment 1.
5. **#26 (`ee3ccd8`) — optional `count` advertised in the hint panel.**
At `seed <table> ▮` only `set`/`--seed` chips showed; the optional
row count (a bare positional number) was invisible, and the prior
`IntroProse` attempt was reverted because `pending_hint_mode` is
cleared by the trailing optionals. Now `walk_optional` stashes a
skipped inner's `IntroProse` key into a new
`WalkContext.surviving_intro_hint` (key + position) before the empty
match clears it; a **position guard** (`pos == cursor`) stops it
leaking past a later `set …` clause or once the count is given. Tab
still cycles the keywords. Prose mentions the count, `.column`
column-fill, `set`, and `--seed` (user-chosen scope). **DA caught**
a coverage gap (advanced-mode path untested — seed runs in both
modes); added the test. ADR-0022 Amendment 7.
## §3. Open issues — next session's candidates
Four open, all on `git.lazyeval.net/oli/rdbms-playground`. **All four
are interaction/UX design changes that need a decision or two from the
user up front — none is a pure mechanical fix.** Read each issue body
**and its comments** before starting (the #33/#34 lesson).
- **#28 — Reconsider relationship prose in `add column` (incidental
DDL) confirmations** *(enhancement)*. **Revisits a decided area**
needs a **new ADR** superseding the relevant part of ADR-0016 §5 /
ADR-0044 §1. User preference (from the issue): do **not** show the
`References:` / `Referenced by:` block in the add-column
confirmation. Confirm scope with the user (just `add column`, or all
incidental DDL). The highest-ceremony of the four.
- **#27 — Bottom status line: keybindings-only, context- and
state-aware; add `mode advanced` to empty hint** *(enhancement)*.
Per-nav-focus keybindings (Input vs sidebar), **including transient
states** (Tab-cycle, history) per user preference. May warrant a
small ADR. Touches `src/ui.rs` rendering + the nav-focus model
(ADR-0046).
- **#29 — Command input keystroke support.** Esc / double-Esc to clear
a partly-typed command; possibly Ctrl-A/Ctrl-E (Home/End). Relates
to the deferred **I1b readline shortcuts** (`requirements.md`).
**Needs a key-set decision** from the user before coding.
- **#30 — History brings back all commands in both modes.**
Advanced-mode history entries can't replay in simple mode; proposal:
if we can distinguish them, prepend `:` to reuse advanced history
from simple mode. Interaction design; touches the input-history +
mode model (ADR-0003).
No strong ordering. **#28** is the only one that *must* produce an ADR.
**#29** is closest to "small once the key-set is decided." **#27** and
**#30** are medium UX work.
## §4. Carried-over follow-up (not a `main`-branch task)
- **Website `seed` cast re-record** (from #34's comment thread). The
`website` branch ships a `seed` cast exercising a `tickets` table
with `priority`; now that `priority` collapses to `low/medium/high`,
the cast should be re-recorded (`cd website && pnpm casts seed`,
needs a `../target/debug` binary) so the table tightens. The issue
comment notes it is **likely redundant** — casts get a full
re-record sweep before publication. Tracked on the `website` branch,
**not** here. `website/` is not in the `main` tree.
## §5. Other open roadmap (unchanged from handoff-67 §5)
`seed` is feature-complete (`requirements.md` SD1/SD2 `[x]`, now with
the #33/#34 catalogue refinements noted inline). User's call:
- **H2 `hint`** — the last A1 gap (its own ADR).
- **TT5 CI** — test infra exists; no CI workflow yet (the `ci` branch
exists — check its state before starting).
- **TT4 PTY (Tier-4)** — ADR-0008 specifies it; not wired.
- Larger: **V4 journal**, **tutorial/lesson system** (each needs an ADR).
## §6. How to take over
1. Read handoffs 66 → 67 → 68, `CLAUDE.md`, `docs/requirements.md`.
2. Confirm green baseline: `cargo test` (expect 2436 pass / 1 ignored)
+ `cargo clippy --all-targets` (clean).
3. Pick from §3 (#28/#27/#29/#30). **For each, read the issue body AND
its comments** before designing, and **escalate the design fork to
the user** before coding — all four have genuine UX decisions. #28
needs a new ADR.
4. Follow the project workflow: phased (requirements → divergent →
eval → execute → verify), test-first (failing test before the fix),
`/runda` + DA pass before every commit, ADR amendment for any
decided-area change + the README index-upkeep rule, and confirm the
commit message with the user before committing.
5. Consider a `cargo sweep` at this milestone (`target/` grows across
sessions; see CLAUDE.md "Build hygiene").
+203
View File
@@ -0,0 +1,203 @@
# Session handoff — 2026-06-14 (69)
Sixty-ninth handover. Continues from handoff-68 (an issue-burndown that
closed #25/#26/#31/#32/#33/#34). This session **closed the four
remaining open issues** — #29, #28, #27, #30 — each landed with the full
phased workflow + `/runda` + Devil's-Advocate passes before commit, and
each producing a new ADR. Net: **four issues closed, four commits, four
new ADRs (00490052), +63 tests, zero regressions, the tracker is now
empty.**
The four interlock: **#29** added the input-field readline keys, **#27**
advertises them in a state-aware status strip, and **#30**'s history
recall now respects modes. **#30** also turned into a real architecture
change (journaling relocation) — read §2.4 carefully before touching that
area.
## §1. State at handoff
**Branch:** `main`. Working tree **clean**; all work committed. The two
most recent commits are local (normal working state — push is the user's
step).
**Tests: 2471 passing / 0 failing / 0 skipped / 1 ignored** (the
long-standing `friendly` doctest). **Clippy clean** (nursery, all
targets). Breakdown: 1771 lib + 500 integration (`it`) + 200
typing-surface-matrix. **+35 over handoff-68's 2436** (net: #29 +22, #28
+0, #27 +9, #30 +4 — its new history.rs/app.rs/iteration6 tests minus the
15 retired worker-journaling tests; trust the live `cargo test` count).
**Commits this session:**
```
4aeea55 feat(history): mode-tagged history + top-of-chain journaling (#30)
eceedc1 feat(ui): context- and state-aware bottom keybinding strip (#27)
8ac3537 feat(render): incidental-DDL confirmations show structure only, no relationships (#28)
66c8bda feat(input): readline keymap — Esc-clear + Ctrl-A/E/W/K/U (#29)
```
**Open Gitea issues: none.** `tea issues list --state open` is empty.
## §2. Issues closed this session (all committed, tested, `/runda`-reviewed)
Each closed on `git.lazyeval.net/oli/rdbms-playground` with a summary
comment.
### 2.1 — #29 (`66c8bda`) — input-field readline keymap (ADR-0049)
Implements the deferred **I1b** readline shortcuts: `Esc` clears a
partly-typed command (only when no completion memo is alive — the memo
wins first, ADR-0022); `Ctrl-A`/`Ctrl-E` = Home/End; `Ctrl-W` deletes
the previous word (readline-style, UTF-8 safe); `Ctrl-K`/`Ctrl-U` kill to
end/start. Cursor-only keys leave history nav intact; buffer-mutating
keys end it. **DA caught** the need for the `Ctrl-O`+`Esc` (sidebar
nav-exit) interaction not to clear the draft — locked with a regression
test. `requirements.md` I1b → `[x]`.
### 2.2 — #28 (`8ac3537`) — incidental-DDL confirmations: structure-only (ADR-0050)
Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/
`rename`/`change column`, `add`/`drop index`) now render **structure
only** — no `References:` / `Referenced by:` block. Relationship-subject
surfaces (`show table`, `add`/`drop relationship`) keep their ADR-0044
diagrams. The prose renderer (`relationship_prose_lines` + `cols_disp`)
was deleted. **Supersedes** ADR-0044 §1's incidental-DDL prose clause and
the relationship-block half of ADR-0016 §5 (both annotated).
### 2.3 — #27 (`eceedc1`) — context- and state-aware keybinding strip (ADR-0051)
The bottom status line is now keystrokes-only and **state-selected** by
priority (sidebar focus / completion-memo / history-nav / editing /
default). The editing state surfaces the #29 keys (closing ADR-0049's
deferred advertisement). Mode-switch advertisements left the strip; the
empty-input hint gained a simple-mode `` `mode advanced` for SQL `` pointer
(advanced mode shows none — user decision). New `App::is_browsing_history()`
exposes the private `history_cursor`. 15 full-panel snapshots re-accepted.
### 2.4 — #30 (`4aeea55`) — mode-tagged history + top-of-chain journaling (ADR-0052) **← read before touching journaling**
Closed both the feature (advanced history reusable in simple mode) and
the bug (the `:` one-shot prefix lost across sessions). Two halves:
1. **Mode-tagged history.** The `history.log` status token gains an
optional `:adv` suffix (`ok` / `ok:adv` / `err` / `err:adv`); `source`
stays last + canonical so replay is unaffected. The in-memory ring
(still `Vec<String>`) stores advanced entries in their `: `-prefixed
simple-mode runnable form; recall **strips the `:` in advanced mode**
and keeps it in simple; hydration reconstructs the prefix from the tag.
App commands journal simple and are excluded from the ring's advanced
flag, so they recall bare.
2. **Journaling relocation (the architecture change).** Success
journaling **moved out of the worker** to the dispatch layer
(`spawn_dsl_dispatch` / `run_replay` / the app-command sites), next to
the already-top-level failure journaling — so the submission mode is in
scope with no worker plumbing. `finalize_persistence` now writes only
the **state** sources (yaml/csv); the journal write is **best-effort**
(the command is already committed — consistent with the failure path).
**Amends ADR-0015 §6** (history.log out of the worker tx; commit-db-last
scopes yaml/csv/db only), **ADR-0034** (status tag + journaling
location), **ADR-0040** (journal-write best-effort, not fatal).
**Two DA findings, both resolved:** (a) the app-command `advanced` flag
must exclude app commands (else `: save as` diverges); (b) the spawn
journals on `outcome.is_ok()`, so journaling is now **uniform** — read
commands that didn't journal before (`show tables`/`show relationships`/
`show indexes`, `show relationship <name>`, `explain`) now do, matching
ADR-0034 §1. **User-confirmed** as the more-correct behaviour (harmless
on replay — reads/`explain` don't mutate).
**Test migration:** 15 worker-level journaling tests were retired (the
worker no longer journals — their yaml/csv/operation assertions were
kept) and re-covered at the new layer: `history.rs` status-tag +
`:`-reconstruct; `app.rs` recall matrix; the cross-session regression
`advanced_command_journalled_then_hydrated_recalls_with_colon_in_simple`
in `iteration6_resume_history`; the replay tests cover `run_replay`
journaling.
Plan: `docs/plans/20260613-issue-30-top-of-chain-journaling.md`.
## §3. Next session — start here
The user's stated plan for the next session, in order:
1. **Pick up the ADR-0052 follow-up** (below).
2. **Check for any newly-filed open issues** (`tea issues list --state
open`) — none at handoff, but check fresh.
3. **Then** take on remaining open tasks from the general requirements
(`docs/requirements.md`) — see §5.
### The ADR-0052 follow-up — unwind the vestigial worker `source` plumbing
When journaling moved out of the worker, the `source` that the worker
threaded purely for journaling became dead. To avoid orphaning the param
across ~28 handlers, the refactor **left it in place** as vestigial:
- `finalize_persistence(conn, persistence, _source, changes)` — the
`_source` param is now unused (kept so its ~28 callers still pass
`source`, which they otherwise also use for `snapshot_then`).
- `do_rebuild_from_text(conn, _persistence, _source, project_path)`
both `_persistence` and `_source` vestigial.
- Three thin read-only wrappers in `db.rs`
`do_describe_table_request`, `do_query_data_request`,
`do_run_select_request` — now just delegate to their non-`_request`
twin (`do_describe_table` / `do_query_data` / `do_run_select`) with
vestigial `_persistence` / `_source` params and one caller each
(`db.rs` Request arms ~2409 / ~2749 / ~2759).
**The cleanup:** remove `_source` from `finalize_persistence` + drop the
arg at its ~28 callers (the callers keep `source` for `snapshot_then`, so
only the `finalize_persistence(...)` call loses the arg); remove the
`_persistence`/`_source` params from `do_rebuild_from_text`; and inline
the three `*_request` wrappers at their single call sites (replace
`do_describe_table_request(conn, persistence, source, name)` with
`do_describe_table(conn, &name)`, etc.), deleting the wrappers. Purely
mechanical, compiler-guided, no behaviour change. Establish the green
baseline first (`cargo test`), then verify nothing moved.
## §4. Carried-over follow-up (website branch, not `main`)
- **Website `seed` cast re-record** (from #34, handoff-68 §4) — still
tracked on the `website` branch, not here. Likely redundant (full
re-record sweep before publication).
## §5. Remaining roadmap — `docs/requirements.md` (next session's §3-step 3)
With the issue tracker empty, the next work comes from the document-based
requirements. Open / partial items worth weighing (the user picks):
- **H2 `hint`** — the last A1 gap (contextual help for the current
command); its own ADR. (`requirements.md` H2.)
- **TT5 CI** — runs all tiers on Linux/macOS/Windows; no CI workflow yet
(a `ci` branch reportedly exists — check its state first). Couples with
**D1D3** (cross-platform prebuilt binaries + Homebrew/Scoop).
- **TT4 PTY (Tier-4)** — ADR-0008 specifies the PTY harness + four
critical flows; still not wired (no PTY deps/tests).
- **I1 multi-line input** (Ctrl-Enter submits, Enter inserts newline) and
**I5 / B3 in-flight cancellation** (Ctrl-C cancels a running command).
- **V4 session journal** — scrollable per-session log + Markdown export
(the bigger UX project; own ADR).
- **TU1 tutorial / lesson system** — design + ADR pending (acknowledged
in scope).
- Smaller partials: **C3a** modify relationship (drop+add covers it
today), **C4** m:n convenience, **V3** ER-diagram export, the **NFR-***
performance/visual targets (mostly unmeasured), **N4** global rolling
history (OOS for v1).
No strong ordering — these are the user's call. Several need a new ADR
(H2, V4, TU1); CI/release (TT5/D1D3) is the most "shippable-product"
track if that's the priority.
## §6. How to take over
1. Read handoffs 67 → 68 → 69, `CLAUDE.md`, `docs/requirements.md`.
2. Confirm green baseline: `cargo test` (expect **2471 pass / 1 ignored**)
+ `cargo clippy --all-targets` (clean).
3. `tea issues list --state open` — pick up anything new first.
4. Then the ADR-0052 follow-up (§3), then requirements (§5).
5. Follow the project workflow: phased (requirements → divergent → eval →
execute → verify), test-first, `/runda` + DA pass before every commit,
ADR amendment for any decided-area change + the README index-upkeep
rule, and confirm the commit message with the user before committing.
6. Consider a `cargo sweep` at this milestone (`target/` grows across
sessions; see CLAUDE.md "Build hygiene"). (`sweep.timestamp` was
removed this session.)
+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.
@@ -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.
```
+61 -14
View File
@@ -61,11 +61,32 @@ since ADR-0027.)
## Distribution and install
- [ ] **D1** Cross-platform binaries: Linux, macOS, Windows on
- [x] **D1** Cross-platform binaries: Linux, macOS, Windows on
x86_64 and aarch64.
- [ ] **D2** Single static binary, no runtime dependencies.
*(Done 2026-06-15 — CI produces all six. The four non-macOS
targets (Linux musl + Windows gnu/gnullvm × x86_64/aarch64) are
cross-built from the Linux runner with `cargo-zigbuild` on a `v*`
tag (`release.yaml`); the two `*-apple-darwin` targets build
natively on a Tart Apple-Silicon runner via the dispatched
`release-macos.yaml`. All uploaded to the Gitea release with a
`.sha256` each. Decisions in `docs/ci/adr/` (ADR-ci-001/002/003).
Runtime-verified by the user: Linux x86_64 + Windows aarch64; the
others are link-clean / valid format.)*
- [x] **D2** Single static binary, no runtime dependencies.
*(Done 2026-06-15, per platform: **Linux** is fully static (musl +
`crt-static`); **Windows** is a standalone `.exe` (Zig statically
links libc — no mingw runtime DLLs); **macOS** links only system
libraries (`libSystem` + the AppKit/Foundation frameworks —
inherent on every Mac, never user-installed; the build rewrites the
one nix-store `libiconv` path to `/usr/lib` and re-signs ad-hoc).
No target requires anything the user must install. ADR-ci-003.)*
- [ ] **D3** Released via prebuilt binaries plus Homebrew, Scoop,
`winget`, and `cargo binstall`.
*(Prebuilt binaries + checksums now published to Gitea releases
(D1); the package-manager manifests (Homebrew / Scoop / winget /
`cargo binstall`) remain to do. The asset naming
`rdbms-playground-<tag>-<target>` is already binstall-friendly.
Tracked under ADR-ci-003 "Deferred".)*
## TUI shell
@@ -147,11 +168,19 @@ since ADR-0027.)
cursor editing and is complete on its own terms; the separate
**multi-line** entry goal is tracked under I1, which is
genuinely not started.)*
- [ ] **I1b** Readline-style cursor shortcuts: Ctrl-A / Ctrl-E
- [x] **I1b** Readline-style cursor shortcuts: Ctrl-A / Ctrl-E
as aliases for Home / End for users on keyboards without those
keys (and for ergonomics in command-driven workflows). Likely
followed by Ctrl-W (delete previous word), Ctrl-K (delete to
end), Ctrl-U (delete to start). Pending.
end), Ctrl-U (delete to start).
*(Done 2026-06-12 — ADR-0049, issue #29: the full set —
Esc-clear + Ctrl-A/E/W/K/U — wired in `App::handle_key`
(`src/app.rs`) with helpers `clear_input` / `delete_prev_word`
/ `kill_to_end` / `kill_to_start`; Esc clears only when no
completion memo is alive (the memo wins first, ADR-0022);
cursor-only keys leave history navigation intact, kill keys
end it; 22 Tier-1 tests. On-screen advertisement of these keys
is issue #27's bottom-status-line work.)*
- [x] **I2** Persistent navigable input history (project-scoped).
*(Implemented across Iterations 2 + 6: per-command append to
`history.log` (Iter 2); on project open, the in-memory
@@ -242,16 +271,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
@@ -696,7 +722,10 @@ since ADR-0027.)
`Generator`, and full completion / highlight / validity / help /
parse-error-pedagogy wiring. Deferred SD2 increments:
user-defined custom generators, NULL injection, multi-locale,
recursive parent auto-seed.)*
recursive parent auto-seed. Later catalogue refinements:
**#33** year-as-int (`year`/`*_year`/`published`/`founded`) and
**#34** conventional choice sets (`priority`/`severity`/`rating`,
`status` excluded) — ADR-0048 Amendment 1.)*
## Query analysis
@@ -782,8 +811,16 @@ since ADR-0027.)
`returning `) still shows the raw expression first-set —
typing-time completion already offers the right candidates
there, so the payoff is small.
- [ ] **H2** `hint` provides contextual help for the current
- [x] **H2** `hint` provides contextual help for the current
input or the most recent error.
*(Done 2026-06-15, ADR-0053: an **F1** keybinding gives a tier-3
teaching hint for the live partial input (read-only overlay), and a
submitted **`hint`** command expands on the most recent runtime error.
A new `hint.cmd.<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).)*
- [x] **H3** `help` provides general reference and per-command
help.
*(Done 2026-06-07: the **general reference** is `help` (no arg) —
@@ -867,8 +904,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
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",
]
+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
+894 -24
View File
File diff suppressed because it is too large Load Diff
+110
View File
@@ -753,6 +753,51 @@ pub fn candidates_at_cursor_with_in_mode(
);
}
// Source 1.95: in-scope table aliases (issue #31). At a bare
// `sql_expr_ident` slot — one *not* already past a `qualifier .`
// (handled by §10.5 column narrowing) — the partial may be a
// FROM-source the learner is mid-typing as a qualifier
// (`group by o` → `o.<column>`). Offer each binding's *qualifier*:
// its alias if it has one, else the table name (an aliased source
// must be referenced by its alias, not the raw table name). This
// makes aliases Tab-discoverable and — since a non-empty candidate
// set overlapping the partial suppresses the under-cursor error
// (the `typing_over_diag` path) — keeps the alias from flashing as
// a bogus "unknown column" while typing. Mixed into `identifiers`
// so it sorts/dedups/colours uniformly with column candidates.
let alias_candidates: Vec<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.
// `NewName` / `Types` / `Free` sources don't query the
@@ -788,6 +833,10 @@ pub fn candidates_at_cursor_with_in_mode(
})
.filter(|name| matches_prefix(name))
.collect();
// Fold in the in-scope alias qualifiers (Source 1.95). They are
// already prefix-filtered; dedup against any column of the same
// spelling happens via the shared sort/dedup below.
identifiers.extend(alias_candidates);
identifiers.sort();
identifiers.dedup();
// If an identifier shares its name with a keyword candidate
@@ -1930,6 +1979,67 @@ mod tests {
cache
}
fn two_table_alias_cache() -> SchemaCache {
use crate::dsl::types::Type;
let mut cache = schema_with_table("a", &[("id", Type::Int), ("name", Type::Text)]);
cache.tables.push("b".to_string());
cache.columns.push("total".to_string());
cache.table_columns.insert(
"b".to_string(),
vec![
TableColumn::new("id", Type::Int),
TableColumn::new("total", Type::Real),
],
);
cache
}
#[test]
fn bare_expr_slot_offers_in_scope_aliases() {
// Issue #31: at a bare SQL-expression slot (here GROUP BY) the
// in-scope FROM aliases are Tab-discoverable, so a learner can
// reach `o.<column>` without guessing the alias.
let cache = two_table_alias_cache();
let input = "select a.id from a o join b z on o.id = z.id group by ";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"o".to_string()), "alias `o` must be offered; got {cs:?}");
assert!(cs.contains(&"z".to_string()), "alias `z` must be offered; got {cs:?}");
}
#[test]
fn bare_expr_slot_narrows_aliases_by_partial_prefix() {
// A partial that prefix-matches several aliases offers each;
// an exact match (`o`) is the learner's whole alias — no
// sibling-alias noise, so the `alias_used_as_column` hint can
// surface instead (issue #31).
let cache = two_table_alias_cache();
let input = "select a.id from a aa join b ab on aa.id = ab.id group by a";
let cs = cands_with(input, input.len(), &cache);
assert!(cs.contains(&"aa".to_string()), "alias `aa` must be offered; got {cs:?}");
assert!(cs.contains(&"ab".to_string()), "alias `ab` must be offered; got {cs:?}");
// Exact-alias partial: the alias source steps aside.
let exact = "select aa.id from a aa join b ab on aa.id = ab.id group by aa";
let cs2 = cands_with(exact, exact.len(), &cache);
assert!(
!cs2.iter().any(|c| c == "ab"),
"an exact-alias partial must not surface sibling aliases; got {cs2:?}",
);
}
#[test]
fn alias_not_offered_after_a_qualifier_dot() {
// Past `o.` the §10.5 column-narrowing owns the slot; aliases
// are not candidates there.
let cache = two_table_alias_cache();
let input = "select a.id from a o join b z on o.id = z.id group by o.";
let cs = cands_with(input, input.len(), &cache);
assert!(
!cs.iter().any(|c| c == "o" || c == "z"),
"aliases must not be offered after a qualifier dot; got {cs:?}",
);
}
#[test]
fn update_set_offers_only_current_table_columns() {
use crate::dsl::types::Type;
+257 -307
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -552,6 +552,11 @@ pub enum AppCommand {
Help {
topic: Option<String>,
},
/// Show a contextual tier-3 hint (H2 / ADR-0053). No argument:
/// when submitted, it expands on the most recent runtime error
/// (the buffer is empty post-submit). The live-input surface is
/// the F1 keybinding, handled in `App::handle_key`, not here.
Hint,
/// Rebuild `playground.db` from `project.yaml` + data/, with
/// confirmation modal.
Rebuild,
@@ -1013,6 +1018,7 @@ impl Command {
Self::App(app) => match app {
AppCommand::Quit => "quit",
AppCommand::Help { .. } => "help",
AppCommand::Hint => "hint",
AppCommand::Rebuild => "rebuild",
AppCommand::Save => "save",
AppCommand::SaveAs => "save as",
+25
View File
@@ -177,6 +177,9 @@ const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result<Command, Va
const fn build_undo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Undo))
}
const fn build_hint(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Hint))
}
const fn build_redo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Redo))
@@ -263,6 +266,7 @@ pub static QUIT: CommandNode = CommandNode {
shape: EMPTY_SEQ,
ast_builder: build_quit,
help_id: Some("app.quit"),
hint_ids: &["quit"],
usage_ids: &["parse.usage.quit"],};
pub static HELP: CommandNode = CommandNode {
@@ -270,13 +274,24 @@ pub static HELP: CommandNode = CommandNode {
shape: HELP_TOPIC_OPT,
ast_builder: build_help,
help_id: Some("app.help"),
hint_ids: &["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"),
hint_ids: &["rebuild"],
usage_ids: &["parse.usage.rebuild"],};
pub static SAVE: CommandNode = CommandNode {
@@ -284,6 +299,7 @@ pub static SAVE: CommandNode = CommandNode {
shape: SAVE_AS_OPT,
ast_builder: build_save,
help_id: Some("app.save"),
hint_ids: &["save"],
usage_ids: &["parse.usage.save"],};
pub static NEW: CommandNode = CommandNode {
@@ -291,6 +307,7 @@ pub static NEW: CommandNode = CommandNode {
shape: EMPTY_SEQ,
ast_builder: build_new,
help_id: Some("app.new"),
hint_ids: &["new"],
usage_ids: &["parse.usage.new"],};
pub static LOAD: CommandNode = CommandNode {
@@ -298,6 +315,7 @@ pub static LOAD: CommandNode = CommandNode {
shape: EMPTY_SEQ,
ast_builder: build_load,
help_id: Some("app.load"),
hint_ids: &["load"],
usage_ids: &["parse.usage.load"],};
pub static EXPORT: CommandNode = CommandNode {
@@ -305,6 +323,7 @@ pub static EXPORT: CommandNode = CommandNode {
shape: EXPORT_PATH_OPT,
ast_builder: build_export,
help_id: Some("app.export"),
hint_ids: &["export"],
usage_ids: &["parse.usage.export"],};
pub static IMPORT: CommandNode = CommandNode {
@@ -312,6 +331,7 @@ pub static IMPORT: CommandNode = CommandNode {
shape: IMPORT_BODY_OPT,
ast_builder: build_import,
help_id: Some("app.import"),
hint_ids: &["import"],
usage_ids: &["parse.usage.import"],};
pub static MODE: CommandNode = CommandNode {
@@ -319,6 +339,7 @@ pub static MODE: CommandNode = CommandNode {
shape: MODE_VALUE,
ast_builder: build_mode,
help_id: Some("app.mode"),
hint_ids: &["mode"],
usage_ids: &["parse.usage.mode"],};
pub static MESSAGES: CommandNode = CommandNode {
@@ -326,6 +347,7 @@ pub static MESSAGES: CommandNode = CommandNode {
shape: MESSAGES_VALUE_OPT,
ast_builder: build_messages,
help_id: Some("app.messages"),
hint_ids: &["messages"],
usage_ids: &["parse.usage.messages"],};
pub static UNDO: CommandNode = CommandNode {
@@ -333,6 +355,7 @@ pub static UNDO: CommandNode = CommandNode {
shape: EMPTY_SEQ,
ast_builder: build_undo,
help_id: Some("app.undo"),
hint_ids: &["undo"],
usage_ids: &["parse.usage.undo"],};
pub static REDO: CommandNode = CommandNode {
@@ -340,6 +363,7 @@ pub static REDO: CommandNode = CommandNode {
shape: EMPTY_SEQ,
ast_builder: build_redo,
help_id: Some("app.redo"),
hint_ids: &["redo"],
usage_ids: &["parse.usage.redo"],};
pub static COPY: CommandNode = CommandNode {
@@ -347,4 +371,5 @@ pub static COPY: CommandNode = CommandNode {
shape: COPY_VALUE_OPT,
ast_builder: build_copy,
help_id: Some("app.copy"),
hint_ids: &["copy"],
usage_ids: &["parse.usage.copy"],};
+32 -1
View File
@@ -438,6 +438,17 @@ const LIMIT_CLAUSE: Node = Node::Seq(LIMIT_CLAUSE_NODES);
const SEED_COUNT: Node = Node::NumberLit {
validator: Some(LIMIT_VALIDATOR),
};
/// Issue #26: the row count is a bare positional number, so it produces
/// no Tab candidate and was invisible in the hint panel at
/// `seed <table> ▮` (only `set` / `--seed` showed). Wrapping it in
/// `IntroProse` advertises it (and the other options) in prose; the
/// skipped-optional carry (`surviving_intro_hint`) makes the hint reach
/// the resolver despite the trailing optionals. Tab still cycles the
/// keyword candidates.
const SEED_COUNT_HINTED: Node = Node::Hinted {
mode: crate::dsl::grammar::HintMode::IntroProse("hint.seed_count"),
inner: &SEED_COUNT,
};
/// `--seed <n>` — a reproducible-generation flag carrying a numeric
/// seed (ADR-0048 D4). The only flag in the DSL that takes a value;
/// `build_seed` reads the number immediately after the flag.
@@ -567,7 +578,7 @@ const SEED_NODES: &[Node] = &[
// against this table.
TABLE_NAME_WRITES,
SEED_DOT_COLUMN,
Node::Optional(&SEED_COUNT),
Node::Optional(&SEED_COUNT_HINTED),
Node::Optional(&SEED_SET_CLAUSE),
Node::Optional(&SEED_FLAG),
];
@@ -1779,6 +1790,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",
@@ -1794,6 +1812,7 @@ pub static SEED: CommandNode = CommandNode {
shape: SEED_SHAPE,
ast_builder: build_seed,
help_id: Some("data.seed"),
hint_ids: &["seed"],
usage_ids: &["parse.usage.seed"],
};
@@ -1802,6 +1821,8 @@ pub static INSERT: CommandNode = CommandNode {
shape: INSERT_SHAPE,
ast_builder: build_insert,
help_id: Some("data.insert"),
// ADR-0053 Phase-B exemplar.
hint_ids: &["insert"],
usage_ids: &["parse.usage.insert"],};
pub static UPDATE: CommandNode = CommandNode {
@@ -1809,6 +1830,7 @@ pub static UPDATE: CommandNode = CommandNode {
shape: UPDATE_SHAPE,
ast_builder: build_update,
help_id: Some("data.update"),
hint_ids: &["update"],
usage_ids: &["parse.usage.update"],};
pub static DELETE: CommandNode = CommandNode {
@@ -1816,6 +1838,7 @@ pub static DELETE: CommandNode = CommandNode {
shape: DELETE_SHAPE,
ast_builder: build_delete,
help_id: Some("data.delete"),
hint_ids: &["delete"],
usage_ids: &["parse.usage.delete"],};
pub static REPLAY: CommandNode = CommandNode {
@@ -1823,6 +1846,7 @@ pub static REPLAY: CommandNode = CommandNode {
shape: REPLAY_PATH,
ast_builder: build_replay,
help_id: Some("data.replay"),
hint_ids: &["replay"],
usage_ids: &["parse.usage.replay"],};
pub static EXPLAIN: CommandNode = CommandNode {
@@ -1830,6 +1854,7 @@ pub static EXPLAIN: CommandNode = CommandNode {
shape: EXPLAIN_SHAPE,
ast_builder: build_explain,
help_id: Some("data.explain"),
hint_ids: &["explain"],
usage_ids: &["parse.usage.explain"],};
/// `explain` over advanced-mode SQL (ADR-0039).
@@ -1849,6 +1874,7 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode {
// too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE`
// precedent; otherwise `note_help` would print `explain` twice.
help_id: None,
hint_ids: &["explain_sql"],
usage_ids: &[],};
/// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032).
@@ -1864,6 +1890,7 @@ pub static SELECT: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL),
ast_builder: build_select,
help_id: None,
hint_ids: &["select"],
usage_ids: &["parse.usage.select"],};
/// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c).
@@ -1878,6 +1905,7 @@ pub static WITH: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL),
ast_builder: build_select,
help_id: None,
hint_ids: &["with"],
usage_ids: &["parse.usage.with"],};
/// SQL `INSERT` — the `Advanced`-category node of the shared
@@ -1895,6 +1923,7 @@ pub static SQL_INSERT: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE),
ast_builder: build_sql_insert,
help_id: None,
hint_ids: &["sql_insert"],
usage_ids: &[],
};
@@ -1908,6 +1937,7 @@ pub static SQL_UPDATE: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE),
ast_builder: build_sql_update,
help_id: None,
hint_ids: &["sql_update"],
usage_ids: &[],
};
@@ -1923,6 +1953,7 @@ pub static SQL_DELETE: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE),
ast_builder: build_sql_delete,
help_id: None,
hint_ids: &["sql_delete"],
usage_ids: &[],
};
+26
View File
@@ -968,6 +968,13 @@ 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",
@@ -981,6 +988,16 @@ pub static ADD: CommandNode = CommandNode {
shape: ADD_SHAPE,
ast_builder: build_add,
help_id: Some("ddl.add"),
// Per-form (ADR-0053 D3): every form is listed so the form-word
// disambiguation resolves correctly; forms without an authored
// block yet fall back to tier-2 at render. `add_relationship` is
// authored as a Phase-B exemplar.
hint_ids: &[
"add_column",
"add_relationship",
"add_index",
"add_constraint",
],
usage_ids: &[
"parse.usage.add_column",
"parse.usage.add_relationship",
@@ -993,6 +1010,7 @@ pub static RENAME: CommandNode = CommandNode {
shape: RENAME_COLUMN,
ast_builder: build_rename_column,
help_id: Some("ddl.rename"),
hint_ids: &["rename_column"],
usage_ids: &["parse.usage.rename_column"],};
pub static CHANGE: CommandNode = CommandNode {
@@ -1000,6 +1018,7 @@ pub static CHANGE: CommandNode = CommandNode {
shape: CHANGE_COLUMN,
ast_builder: build_change_column,
help_id: Some("ddl.change"),
hint_ids: &["change_column"],
usage_ids: &["parse.usage.change_column"],};
// =================================================================
@@ -1360,6 +1379,7 @@ pub static CREATE: CommandNode = CommandNode {
shape: CREATE_TABLE,
ast_builder: build_create_table,
help_id: Some("ddl.create"),
hint_ids: &["create_table"],
usage_ids: &["parse.usage.create_table"],};
// =================================================================
@@ -1428,6 +1448,7 @@ pub static CREATE_M2N: CommandNode = CommandNode {
shape: CREATE_M2N_SHAPE,
ast_builder: build_create_m2n,
help_id: Some("ddl.create_m2n"),
hint_ids: &["create_m2n"],
usage_ids: &["parse.usage.create_m2n"],
};
@@ -1858,6 +1879,7 @@ pub static SQL_CREATE_TABLE: CommandNode = CommandNode {
shape: Node::Subgrammar(&super::sql_create_table::SQL_CREATE_TABLE_SHAPE),
ast_builder: build_sql_create_table,
help_id: Some("ddl.sql_create_table"),
hint_ids: &["sql_create_table"],
usage_ids: &["parse.usage.sql_create_table"],
};
@@ -1877,6 +1899,7 @@ pub static SQL_DROP_TABLE: CommandNode = CommandNode {
shape: SQL_DROP_TABLE_SHAPE,
ast_builder: build_sql_drop_table,
help_id: Some("ddl.sql_drop_table"),
hint_ids: &["sql_drop_table"],
usage_ids: &["parse.usage.sql_drop_table"],
};
@@ -1896,6 +1919,7 @@ pub static SQL_DROP_INDEX: CommandNode = CommandNode {
shape: SQL_DROP_INDEX_SHAPE,
ast_builder: build_sql_drop_index,
help_id: Some("ddl.sql_drop_index"),
hint_ids: &["sql_drop_index"],
usage_ids: &["parse.usage.sql_drop_index"],
};
@@ -1977,6 +2001,7 @@ pub static SQL_CREATE_INDEX: CommandNode = CommandNode {
shape: SQL_CREATE_INDEX_SHAPE,
ast_builder: build_sql_create_index,
help_id: Some("ddl.sql_create_index"),
hint_ids: &["sql_create_index"],
usage_ids: &["parse.usage.sql_create_index"],
};
@@ -2535,6 +2560,7 @@ pub static SQL_ALTER_TABLE: CommandNode = CommandNode {
shape: SQL_ALTER_TABLE_SHAPE,
ast_builder: build_sql_alter_table,
help_id: Some("ddl.sql_alter_table"),
hint_ids: &["sql_alter_table"],
usage_ids: &["parse.usage.sql_alter_table"],
};
+217 -39
View File
@@ -530,6 +530,18 @@ pub struct CommandNode {
/// so a newly-registered command appears in `help`
/// automatically (ADR-0024 §help_id).
pub help_id: Option<&'static str>,
/// Catalog key stems (`hint.cmd.<id>`) for this command's
/// **tier-3** contextual hints (ADR-0053 / H2), **one per form**,
/// mirroring `usage_ids`. A single-form command carries one; a
/// multi-form command (`add`, `drop`, `show`, `create`) carries
/// one per form so a live-input hint can be specific to the form
/// being typed (`hint.cmd.add_relationship`, not a shared `add`
/// block). `hint_key_for_input_in_mode` disambiguates by the form
/// word, reusing `usage_key_for_input_in_mode`'s logic. Empty
/// until a form's tier-3 block is authored (the surface falls back
/// to tier-2 ambient/error text). Distinct from `help_id` (which is
/// `None` on advanced-SQL forms purely to dedup the `help` list).
pub hint_ids: &'static [&'static str],
/// Catalog keys under `parse.usage.*` to render in the
/// "usage:" block when a parse error fires for this command
/// (ADR-0021 §1, ADR-0024 §architecture). Multi-form families
@@ -574,32 +586,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 +701,7 @@ pub fn usage_keys_for_input_in_mode(
.filter(|(_, _, c)| *c == CommandCategory::Simple)
.collect()
};
// Degenerate guard: an advanced-only word in simple mode (not
// normally reachable — it hits the SQL rail first) leaves
// `selected` empty; fall back to all candidates so a usage block
// still renders rather than the available-commands fallback.
let pick = if selected.is_empty() { candidates } else { selected };
let keys = union(&pick);
if keys.is_empty() {
return None;
}
let entry = pick[0].1.entry.primary;
Some((entry, keys))
if selected.is_empty() { candidates } else { selected }
}
/// The single usage template most relevant to `source`, when
@@ -658,14 +728,24 @@ pub fn usage_key_for_input_in_mode(
source: &str,
mode: crate::mode::Mode,
) -> Option<&'static str> {
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?;
pick_form_key(source, &keys)
}
/// From the form word after the entry keyword, pick the single `keys`
/// entry for the form `source` names.
///
/// A single-entry list resolves to its one key; a multi-form list
/// disambiguates by the form word (`add 1:n relationship` → the
/// `…relationship` key, `create m:n …` → the `…m2n` key, else the
/// identifier form word matched against each key's suffix). Shared by
/// the usage-template and tier-3-hint single-key lookups so they agree.
fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> {
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
let first = *keys.first()?;
if keys.len() == 1 {
return Some(first);
}
// Multi-form: the form is named by the token right after
// the entry keyword.
let start = skip_whitespace(source, 0);
let (_, entry_end) = consume_ident(source, start)?;
let after = skip_whitespace(source, entry_end);
@@ -674,14 +754,12 @@ pub fn usage_key_for_input_in_mode(
return keys.iter().copied().find(|k| k.ends_with("relationship"));
}
// The `create m:n relationship` form (ADR-0045) opens with `m:n`
// — a letter, so the digit branch misses it, and its usage key ends
// `…create_m2n` (not `relationship`).
// — a letter, so the digit branch misses it; its key ends `…m2n`.
if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) {
return keys.iter().copied().find(|k| k.ends_with("m2n"));
}
// Otherwise the form word is an identifier — `column`,
// `index`, `table`, `relationship` — matched against the
// usage key's suffix.
// Otherwise the form word is an identifier — `column`, `index`,
// `table`, `relationship` — matched against each key's suffix.
let (s, e) = consume_ident(source, after)?;
let form = source[s..e].to_ascii_lowercase();
keys.iter().copied().find(|k| k.ends_with(form.as_str()))
@@ -712,6 +790,7 @@ pub fn entry_words_alphabetised() -> Vec<&'static str> {
pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
(&app::QUIT, CommandCategory::Simple),
(&app::HELP, CommandCategory::Simple),
(&app::HINT, CommandCategory::Simple),
(&app::REBUILD, CommandCategory::Simple),
(&app::SAVE, CommandCategory::Simple),
(&app::NEW, CommandCategory::Simple),
@@ -836,6 +915,105 @@ 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}`");
}
}
}
#[cfg(test)]
mod usage_key_tests {
use super::usage_key_for_input;
+13
View File
@@ -134,6 +134,17 @@ pub struct WalkContext<'a> {
/// resolver reads this directly instead of inferring the
/// slot kind from the shape of the expected set.
pub pending_hint_mode: Option<crate::dsl::grammar::HintMode>,
/// An `IntroProse` hint captured from an *optional* slot that
/// the walk skipped (issue #26). Unlike `pending_hint_mode`
/// (cleared on the very next match — including the empty match
/// of a skipped `Optional`), this survives the trailing
/// optional siblings so the hint reaches the resolver for a
/// position like `seed <table> ▮`, where the optional row
/// count is otherwise invisible. Carries the catalog key and
/// the byte position the optional was skipped at; the resolver
/// uses it only when that position is the cursor (so it doesn't
/// leak past a later-consumed clause).
pub surviving_intro_hint: Option<(&'static str, usize)>,
/// The columns the user explicitly listed in
/// `insert into <T> (col1, col2, …) values (…)` (Form A),
/// in declaration order.
@@ -232,6 +243,7 @@ impl<'a> WalkContext<'a> {
pending_value_type: None,
pending_value_column: None,
pending_hint_mode: None,
surviving_intro_hint: None,
user_listed_columns: None,
subgrammar_depth: 0,
from_scope_stack: vec![ScopeFrame::default()],
@@ -254,6 +266,7 @@ impl<'a> WalkContext<'a> {
pending_value_type: None,
pending_value_column: None,
pending_hint_mode: None,
surviving_intro_hint: None,
user_listed_columns: None,
subgrammar_depth: 0,
from_scope_stack: vec![ScopeFrame::default()],
+17
View File
@@ -990,6 +990,21 @@ fn walk_seq(
}
}
/// Issue #26: when an `Optional` is skipped (its inner didn't engage),
/// stash any `IntroProse` hint the inner left in `pending_hint_mode`
/// into the surviving slot before it is cleared by this empty match.
/// `position` is where the optional was skipped — the resolver compares
/// it to the cursor so the hint only shows while the cursor sits at that
/// optional, not after a later clause consumes input past it. Only
/// `IntroProse` is carried (it is the "introduce an optional position"
/// mode); `ProseOnly` / `ForceProse` mark active slots and reach the
/// resolver through the normal `pending_hint_mode` path.
const fn capture_skipped_intro_hint(ctx: &mut WalkContext, position: usize) {
if let Some(crate::dsl::grammar::HintMode::IntroProse(key)) = ctx.pending_hint_mode {
ctx.surviving_intro_hint = Some((key, position));
}
}
fn walk_optional(
source: &str,
position: usize,
@@ -1008,6 +1023,7 @@ fn walk_optional(
// Inner didn't engage at all — skip the Optional
// but carry the inner's expectations so the caller's
// expected-set sees them.
capture_skipped_intro_hint(ctx, position);
path.items.truncate(saved_path_len);
per_byte.truncate(saved_byte_len);
NodeWalkResult::Matched {
@@ -1019,6 +1035,7 @@ fn walk_optional(
// Inner reported Incomplete without consuming
// anything — same as NoMatch from the user's
// perspective. Roll back and skip.
capture_skipped_intro_hint(ctx, position);
path.items.truncate(saved_path_len);
per_byte.truncate(saved_byte_len);
let _ = p;
+198
View File
@@ -116,6 +116,19 @@ pub fn hint_resolution_at_input_in_mode(
use crate::dsl::grammar::HintMode;
let snap = expected_for_hint_snapshot(source, schema, mode);
// Issue #26: an optional positional slot with no candidate text
// (the `seed <table>` row count) left an `IntroProse` hint that
// survived the trailing optionals. It is shown even for an
// otherwise-complete command (empty expected set) — that is exactly
// the `seed users ▮` case where the count is invisible. Checked
// first, before the complete-command short-circuit below.
if let Some(key) = snap.surviving_intro_hint {
return Some(HintResolution {
mode: HintMode::IntroProse(key),
column: None,
form_b_autogen_skipped: Vec::new(),
});
}
// Empty expected set means the command is already complete
// (`WalkOutcome::Match`) — no slot to hint at.
if snap.expected.is_empty() {
@@ -1163,6 +1176,60 @@ fn schema_existence_diagnostics(
// Allowed-clause alias ref — silent.
continue;
}
// Issue #31: the bare ident is itself an
// in-scope FROM source — a table alias
// (`o` from `FROM Orders o`) or, when the
// source is un-aliased, the table name. The
// learner means a *column* of that source
// (`o.<column>`); calling it an "unknown
// column" misleads. Point at the qualified
// form.
//
// Two guards keep the advice correct:
// - SQL only (`role == "sql_expr_ident"`):
// the DSL `Expr` (role `expr_column`)
// has no `table.column` syntax, so the
// qualified-form advice would be wrong;
// it keeps the generic unknown_column.
// - Match the *effective qualifier*
// (alias if present, else table name),
// not the table name independently. An
// aliased source must be referenced by
// its alias — `FROM Orders o … Orders`
// is invalid SQL, so it must NOT be
// advised as `Orders.<column>`. Mirrors
// the completion side's qualifier rule.
let qualifier_binding = (role
== "sql_expr_ident")
.then(|| {
bindings.iter().find(|b| {
let q = b
.alias
.as_deref()
.unwrap_or(b.table.as_str());
q.eq_ignore_ascii_case(&item.text)
})
})
.flatten();
if let Some(binding) = qualifier_binding {
let key = if binding.alias.is_some() {
"diagnostic.alias_used_as_column"
} else {
"diagnostic.table_used_as_column"
};
diagnostics.push(Diagnostic {
severity: Severity::Error,
span: item.span,
message: crate::friendly::translate(
key,
&[(
"name",
&item.text as &dyn std::fmt::Display,
)],
),
});
continue;
}
let table_arg = if bindings.len() == 1 {
bindings[0].table.clone()
} else {
@@ -2545,6 +2612,11 @@ struct HintWalkSnapshot {
/// The grammar-declared `HintMode` at the cursor's slot
/// (`Node::Hinted` annotation, ADR-0024 §HintMode-per-node).
pending_hint_mode: Option<crate::dsl::grammar::HintMode>,
/// An `IntroProse` catalog key for an *optional* positional slot at
/// the cursor that produced no candidate (issue #26 — `seed <table>`
/// row count). Survives the trailing optional siblings that clear
/// `pending_hint_mode`; already filtered to the cursor position.
surviving_intro_hint: Option<&'static str>,
current_table_columns: Option<Vec<crate::completion::TableColumn>>,
/// `Some` when the input used Form A's explicit column list.
/// `None` for Form B (`insert into T values …`) and for
@@ -2571,6 +2643,7 @@ fn expected_for_hint_snapshot(
pending_value_type: None,
pending_value_column: None,
pending_hint_mode: None,
surviving_intro_hint: None,
current_table_columns: None,
user_listed_columns: None,
};
@@ -2598,6 +2671,14 @@ fn expected_for_hint_snapshot(
pending_value_type: ctx.pending_value_type,
pending_value_column: ctx.pending_value_column,
pending_hint_mode: ctx.pending_hint_mode,
// Issue #26: only surface the skipped-optional hint when the
// optional was skipped *at the cursor* (the end of the walked
// slice). Captured earlier (before a later clause consumed past
// it) → stale, so drop it.
surviving_intro_hint: ctx
.surviving_intro_hint
.filter(|(_, pos)| *pos == source.len())
.map(|(key, _)| key),
current_table_columns: ctx.current_table_columns,
user_listed_columns: ctx.user_listed_columns,
}
@@ -6330,6 +6411,121 @@ mod tests {
);
}
// ---- Issue #31 — bare table alias / table used as a column ----
#[test]
fn bare_table_alias_in_group_by_is_alias_hint_not_unknown_column() {
// Issue #31: `… GROUP BY o` where `o` aliases a FROM source.
// The learner means `o.<column>`; the diagnostic must point
// at the qualified form, NOT call `o` an unknown column.
let schema = two_table_schema();
let diags = diag_keys(
"select a.id from a o join b on a.id = b.id group by o",
&schema,
);
assert!(
diags
.iter()
.any(|d| d.contains("`o` is a table alias") && d.contains("o.<column>")),
"expected alias_used_as_column hint; got {diags:?}",
);
assert!(
!diags.iter().any(|d| d.contains("no such column")),
"unknown_column must not fire for an in-scope alias; got {diags:?}",
);
}
#[test]
fn bare_table_alias_in_projection_is_alias_hint() {
// The same applies outside GROUP BY — a bare alias in the
// projection (`SELECT o …`) is equally not a column.
let schema = two_table_schema();
let diags =
diag_keys("select o from a o join b on a.id = b.id", &schema);
assert!(
diags.iter().any(|d| d.contains("`o` is a table alias")),
"expected alias_used_as_column hint in projection; got {diags:?}",
);
}
#[test]
fn bare_unaliased_table_used_as_column_is_table_hint() {
// An un-aliased FROM source referenced bare gets the
// table-form hint (qualify with the table name).
let schema = two_table_schema();
let diags = diag_keys("select id from a group by a", &schema);
assert!(
diags
.iter()
.any(|d| d.contains("`a` is a table") && d.contains("a.<column>")),
"expected table_used_as_column hint; got {diags:?}",
);
}
#[test]
fn genuine_unknown_column_still_reports_no_such_column() {
// Regression guard: the alias branch must not swallow a
// genuine typo. `nope` matches no alias, no table, no column.
let schema = two_table_schema();
let diags = diag_keys(
"select a.id from a o join b on a.id = b.id group by nope",
&schema,
);
assert!(
diags.iter().any(|d| d.contains("no such column") && d.contains("nope")),
"a genuine unknown column must still report no such column; got {diags:?}",
);
}
#[test]
fn aliased_table_referenced_by_real_name_is_not_table_hint() {
// DA guard (issue #31): a source aliased as `x` must be
// referenced by the alias — `FROM a x … GROUP BY a` is invalid
// SQL, so we must NOT advise `a.<column>`. The branch matches
// the *effective qualifier* (the alias when present), so `a`
// (the now-shadowed table name) falls through to the generic
// unknown_column rather than wrong qualified-form advice.
let schema = two_table_schema();
let diags = diag_keys(
"select x.id from a x join b on x.id = b.id group by a",
&schema,
);
assert!(
diags.iter().any(|d| d.contains("no such column") && d.contains("`a`")),
"an aliased table referenced by its real name must fall through to \
unknown_column; got {diags:?}",
);
assert!(
!diags.iter().any(|d| d.contains("is a table")),
"must not advise `a.<column>` when `a` is aliased as `x`; got {diags:?}",
);
}
#[test]
fn dsl_bare_table_name_in_where_keeps_unknown_column() {
// DA guard (issue #31): the alias/table hint is SQL-only
// (role `sql_expr_ident`). The DSL `Expr` (role `expr_column`)
// has no `table.column` syntax, so advising the qualified form
// would be wrong. A DSL bare table-name ref stays the generic
// unknown_column it was before issue #31.
let schema =
schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
for input in [
"show data Customers where Customers = 5",
"update Customers set Name = 'x' where Customers = 5",
] {
let diags = diag_keys_simple(input, &schema);
assert!(
diags.iter().any(|d| d.contains("no such column")),
"DSL bare table ref must stay unknown_column for {input:?}; got {diags:?}",
);
assert!(
!diags.iter().any(|d| d.contains("is a table")),
"DSL must not get SQL qualified-form advice for {input:?}; got {diags:?}",
);
}
}
// ---- ADR-0032 §11.2 — compound_arity_mismatch ----
#[test]
@@ -6714,6 +6910,7 @@ mod dispatch_3a_tests {
shape: Node::Word(Word::keyword("dsltail")),
ast_builder: dsl_builder,
help_id: None,
hint_ids: &[],
usage_ids: &[],
};
static SMOKE_SQL: CommandNode = CommandNode {
@@ -6721,6 +6918,7 @@ mod dispatch_3a_tests {
shape: Node::Word(Word::keyword("sqltail")),
ast_builder: sql_builder,
help_id: None,
hint_ids: &[],
usage_ids: &[],
};
+5
View File
@@ -161,6 +161,11 @@ pub enum AppEvent {
/// commands, so an execution failure would otherwise be
/// lost across sessions.
source: String,
/// Whether the rejected command was submitted in an advanced
/// effective mode (ADR-0052): threaded so the App can tag the
/// `err` record `err:adv` and the failed advanced command
/// hydrates in its `:`-prefixed, simple-mode-recallable form.
advanced: bool,
},
/// Refreshed list of tables in the database.
TablesRefreshed(Vec<String>),
+196 -5
View File
@@ -38,6 +38,7 @@
/// `(key, expected_placeholders)`. Sorted by key for grep-ability.
pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
// ---- Pre-submit diagnostics (ADR-0027) ----
("diagnostic.alias_used_as_column", &["name"]),
("diagnostic.ambiguous_column", &["column", "qualifiers"]),
("diagnostic.auto_column_overridden", &["column", "type"]),
("diagnostic.compound_arity_mismatch", &["op", "left_n", "right_n"]),
@@ -63,6 +64,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("diagnostic.not_null_missing", &["column"]),
("diagnostic.like_numeric", &["column", "type"]),
("diagnostic.projection_alias_misplaced", &["alias", "clause"]),
("diagnostic.table_used_as_column", &["name"]),
("diagnostic.type_mismatch", &["column", "type"]),
("diagnostic.unknown_column", &["name", "table"]),
("diagnostic.unknown_qualifier", &["qualifier"]),
@@ -178,6 +180,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.unknown_topic", &["topic"]),
("help.app.quit", &[]),
("help.app.help", &[]),
("help.app.hint", &[]),
("help.app.rebuild", &[]),
("help.app.save", &[]),
("help.app.new", &[]),
@@ -220,6 +223,183 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
&["message", "usage"],
),
("hint.ambient_expected", &["expected"]),
("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.rebuild.what", &[]),
("hint.cmd.rebuild.example", &[]),
("hint.cmd.rebuild.concept", &[]),
("hint.cmd.save.what", &[]),
("hint.cmd.save.example", &[]),
("hint.cmd.new.what", &[]),
("hint.cmd.new.example", &[]),
("hint.cmd.load.what", &[]),
("hint.cmd.load.example", &[]),
("hint.cmd.export.what", &[]),
("hint.cmd.export.example", &[]),
("hint.cmd.export.concept", &[]),
("hint.cmd.import.what", &[]),
("hint.cmd.import.example", &[]),
("hint.cmd.mode.what", &[]),
("hint.cmd.mode.example", &[]),
("hint.cmd.mode.concept", &[]),
("hint.cmd.messages.what", &[]),
("hint.cmd.messages.example", &[]),
("hint.cmd.messages.concept", &[]),
("hint.cmd.undo.what", &[]),
("hint.cmd.undo.example", &[]),
("hint.cmd.undo.concept", &[]),
("hint.cmd.redo.what", &[]),
("hint.cmd.redo.example", &[]),
("hint.cmd.copy.what", &[]),
("hint.cmd.copy.example", &[]),
// 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"],
@@ -229,6 +409,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
// slot (`create table T (`) so the otherwise-invisible
// column-name role reads as the dominant first move.
("hint.create_table_element", &[]),
("hint.seed_count", &[]),
("hint.value_literal_slot", &[]),
(
"hint.ambient_typing_name_then",
@@ -296,6 +477,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", &[]),
@@ -443,6 +625,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", &[]),
@@ -459,18 +642,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", &[]),
+1 -1
View File
@@ -35,7 +35,7 @@ pub mod translate;
pub use error::{DiagnosticTable, FriendlyError};
pub use format::{catalog, Catalog};
pub use translate::{FailureContext, Operation, TranslateContext, Verbosity};
pub use translate::{error_hint_class, FailureContext, Operation, TranslateContext, Verbosity};
// `translate::translate` and `format::translate` are different
// callables — the former is the structured DbError → FriendlyError
+290 -5
View File
@@ -256,6 +256,8 @@ help:
help: |-
help — show this command list
help <command> — detailed help for one command (e.g. `help insert`)
hint: |-
hint — explain the most recent error (press F1 for a hint on what you're typing)
rebuild: |-
rebuild — rebuild the project database from project.yaml + data/ (with confirmation)
save: |-
@@ -386,6 +388,259 @@ 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"
rebuild:
what: "Rebuild the project database from its saved text files."
example: "rebuild"
concept: "The text files (project.yaml + the data folder) are the source of truth; the database is derived and can always be rebuilt from them."
save:
what: "Save the current project under a name; `save as` copies it to a new one."
example: "save as my-shop"
new:
what: "Close the current project and start a fresh temporary one."
example: "new"
load:
what: "Open the project picker to switch to a saved project."
example: "load"
export:
what: "Write a shareable zip of the project — its text files only, never the database."
example: "export my-shop.zip"
concept: "The zip carries the schema and data as text, so anyone can rebuild the very same database from it."
import:
what: "Unpack a project zip into a new project and switch to it."
example: "import my-shop.zip as shop-copy"
mode:
what: "Switch between simple mode (the guided teaching commands) and advanced mode (raw SQL)."
example: "mode advanced"
concept: "Simple mode uses keyword commands; advanced mode lets you write SQL directly. A leading `:` runs a single advanced command without switching modes."
messages:
what: "Show or set how much detail error messages give."
example: "messages short"
concept: "Verbose (the default) adds a fix-it hint under each error headline; short shows just the headline."
undo:
what: "Undo the most recent change, after a confirmation."
example: "undo"
concept: "Every data or schema change is snapshotted first, so you can step back; `redo` re-applies what you undid."
redo:
what: "Re-apply the most recently undone change."
example: "redo"
copy:
what: "Copy the output panel to the clipboard — all of it, or just the last command's output."
example: "copy last"
# DDL — schema-shaping commands (Phase C batch 2).
create_table:
what: "Create a new table — its columns, their types, and a primary key."
example: "create table Customers with pk id(serial), name(text), email(text)"
concept: "A table is a set of rows that share the same columns. The primary key uniquely identifies each row; a `serial` key numbers the rows for you."
create_m2n:
what: "Create a junction table linking two tables many-to-many."
example: "create m:n relationship from Students to Courses"
concept: "A many-to-many link (a student takes many courses; a course has many students) can't live in either table, so it gets its own junction table holding a foreign key to each side."
add_column:
what: "Add a new column to an existing table."
example: "add column Customers: phone (text)"
concept: "Existing rows take the column's default, or null. A `not null` column with no default can't be added to a table that already has rows — there'd be nothing to put in them."
add_index:
what: "Create an index on one or more columns to speed up lookups."
example: "add index as idx_email on Customers (email)"
concept: "An index is a sorted side-structure that makes a lookup like `where email = …` fast, at the cost of a little space and slightly slower writes."
add_constraint:
what: "Add a constraint — not null, unique, default, or check — to an existing column."
example: "add constraint not null to Customers.email"
concept: "A constraint is a rule the database enforces on every row. Adding one fails if existing rows already break it, so you fix the data first."
drop_table:
what: "Remove a table and all of its rows."
example: "drop table Customers"
concept: "If other tables reference this one through a relationship, drop those relationships (or their child rows) first — the database won't orphan them."
drop_column:
what: "Remove a column from a table."
example: "drop column Customers: phone"
concept: "The column's values are lost. You can't drop a primary-key column, or one a relationship depends on."
drop_relationship:
what: "Remove a relationship between two tables."
example: "drop relationship customer_orders"
concept: "This drops the foreign-key link and stops the database enforcing it; the tables and their rows stay. The foreign-key column itself remains unless you also drop it."
drop_index:
what: "Remove an index by name."
example: "drop index idx_email"
concept: "Only the lookup shortcut goes — the data is untouched. Queries still work, just without that speed-up."
drop_constraint:
what: "Remove a constraint from a column."
example: "drop constraint not null from Customers.email"
concept: "The rule stops being enforced from now on; rows already stored are left as they are."
rename_column:
what: "Rename a column, keeping its values and type."
example: "rename column Customers: email to contact_email"
concept: "Only the name changes — the stored data is the same. References to the column are reconciled so nothing breaks."
change_column:
what: "Change a column's type, converting the existing values."
example: "change column Customers: status (int)"
concept: "The database converts each stored value to the new type; if a value can't convert it refuses the change, so you don't silently lose data. Flags let you force or skip the conversion."
# 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 first. To allow orphans on delete instead, set the relationship's `on delete` to `set null` or `cascade`."
parent_side:
what: "You're deleting or changing a row that other rows point at, which would orphan those children."
example: "Delete the child rows first, or set the relationship's `on delete` to `cascade` (remove them too) or `set null` (keep them, unlinked)."
concept: "A foreign key guarantees every child has a real parent, so the database won't remove a parent out from under its children unless the relationship says what should happen to them."
unique:
what: "A value you're inserting — or updating to — already exists in a column that must be unique."
example: "Pick a different value, or update the existing row instead of inserting a new one."
concept: "A unique constraint (and every primary key) forbids duplicates, so each value identifies at most one row."
not_null:
what: "You left a column empty that is required to have a value."
example: "Supply a value for the column, or give it a default so new rows fill it automatically."
concept: "A `not null` constraint means every row must have a value there — it's how you mark a fact as mandatory."
check:
what: "A value broke a `check` rule defined on the column."
example: "Use a value the rule allows — for example a positive number, or one of the permitted options."
concept: "A `check` constraint is a condition every row must satisfy, so the database enforces business rules like \"price ≥ 0\" for you."
type_mismatch:
what: "A value doesn't fit the column's type — for instance text where a number is expected."
example: "Give a value of the right type: a number for `int`/`real`, a quoted string for `text`, true/false for `bool`."
concept: "Every column has a type, and the database rejects values that don't fit, so a column's data stays consistent and comparable."
not_found:
what: "You named a table or column that doesn't exist."
example: "Check the spelling, or run `show tables` (or `show table <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.
@@ -400,6 +655,12 @@ hint:
# at `create table T (` so the column-name role is visible
# alongside the table-level constraint keywords.
create_table_element: "Type a column name, or a table-level constraint: `primary`, `unique`, `check`, `constraint`, `foreign`"
# Issue #26: the `seed <table> ▮` position. The optional row count is
# a bare number with no Tab candidate, so it (and the `.column`
# column-fill form) would be invisible next to the `set` / `--seed`
# chips. Names every option so the most common next move (a count) is
# discoverable.
seed_count: "Optionally a row count, e.g. `50` (default 20); `.column` to fill one column on existing rows; `set` to pin a column; `--seed` to fix the RNG"
# Value-literal slot — `insert ... values (`, `update ... set
# col=`, `where col=`. Replaces the misleading "null true
# false" keyword candidate list with format guidance for all
@@ -611,6 +872,7 @@ parse:
# description.
quit: "quit"
help: "help [<command>]"
hint: "hint"
rebuild: "rebuild"
save: "save | save as"
new: "new"
@@ -640,6 +902,12 @@ diagnostic:
unknown_qualifier: "no such table or alias in scope: `{qualifier}`"
ambiguous_column: "`{column}` is ambiguous — appears in {qualifiers}"
projection_alias_misplaced: "alias `{alias}` cannot be used in {clause} — aliases are not bound until after `SELECT`'s projection list"
# Issue #31: a bare table alias / table name used where the grammar
# expects a column (e.g. `GROUP BY o`). The name *is* in scope — it
# is the alias of a FROM source — so calling it an "unknown column"
# misleads. Point the learner at the qualified `alias.column` form.
alias_used_as_column: "`{name}` is a table alias — write `{name}.<column>` to reference one of its columns"
table_used_as_column: "`{name}` is a table — write `{name}.<column>` to reference one of its columns"
cte_arity_mismatch: "CTE `{cte}` declares {declared} columns but its body has {actual}"
compound_arity_mismatch: "`{op}` requires both sides to have the same number of columns — left has {left_n}, right has {right_n}"
duplicate_cte: "duplicate `WITH` table name: `{name}`"
@@ -871,14 +1139,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"
@@ -887,10 +1162,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:
+153
View File
@@ -253,6 +253,73 @@ pub fn translate(error: &DbError, ctx: &TranslateContext) -> FriendlyError {
fe
}
/// The tier-3 hint class (`hint.err.<class>`) for an error.
///
/// The same classification [`translate`] performs, surfaced as a
/// stable key for the contextual `hint` (H2 / ADR-0053 D5). Returns
/// `None` for internal / fatal errors that carry no learner-facing
/// hint (persistence, IO, worker-gone).
///
/// **Keep in sync with [`translate`] / `translate_sqlite` /
/// `translate_constraint` / `translate_foreign_key`** — the unit tests
/// below pin each class.
#[must_use]
pub fn error_hint_class(error: &DbError, ctx: &TranslateContext) -> Option<&'static str> {
match error {
DbError::Sqlite { message, kind } => sqlite_hint_class(message, *kind, ctx),
DbError::Unsupported(_) | DbError::InvalidValue(_) => Some("invalid_value"),
DbError::PersistenceFatal { .. }
| DbError::RebuildRowFailed { .. }
| DbError::Io(_)
| DbError::WorkerGone => None,
}
}
fn sqlite_hint_class(
message: &str,
kind: SqliteErrorKind,
ctx: &TranslateContext,
) -> Option<&'static str> {
if matches!(ctx.operation, Some(Operation::ChangeColumnType)) {
return Some("type_mismatch");
}
Some(match kind {
SqliteErrorKind::NoSuchTable | SqliteErrorKind::NoSuchColumn => "not_found",
SqliteErrorKind::AlreadyExists => "already_exists",
SqliteErrorKind::UniqueViolation => constraint_hint_class(message, ctx),
SqliteErrorKind::Other => "generic",
})
}
fn constraint_hint_class(message: &str, ctx: &TranslateContext) -> &'static str {
let lower = message.to_ascii_lowercase();
if lower.contains("unique constraint failed") {
"unique"
} else if lower.contains("foreign key constraint failed") {
fk_hint_class(ctx)
} else if lower.contains("not null constraint failed") {
"not_null"
} else if lower.contains("check constraint failed") {
"check"
} else {
"generic"
}
}
const fn fk_hint_class(ctx: &TranslateContext) -> &'static str {
// Mirrors `translate_foreign_key`'s side disambiguation.
if ctx.parent_table.is_some() {
return "foreign_key.child_side";
}
if ctx.child_table.is_some() {
return "foreign_key.parent_side";
}
match ctx.operation {
Some(Operation::Delete) => "foreign_key.parent_side",
_ => "foreign_key.child_side",
}
}
fn translate_sqlite(
message: &str,
kind: SqliteErrorKind,
@@ -798,6 +865,92 @@ mod tests {
}
}
// ── H2 / ADR-0053: error → tier-3 hint class ────────────────
#[test]
fn hint_class_maps_runtime_error_kinds() {
use crate::db::{DbError, SqliteErrorKind};
let sqlite = |kind, msg: &str| DbError::Sqlite {
message: msg.to_string(),
kind,
};
let d = TranslateContext::default;
assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::NoSuchTable, "no such table: X"), &d()),
Some("not_found")
);
assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::NoSuchColumn, "no such column: X"), &d()),
Some("not_found")
);
assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::AlreadyExists, "already exists"), &d()),
Some("already_exists")
);
assert_eq!(
error_hint_class(&sqlite(SqliteErrorKind::Other, "boom"), &d()),
Some("generic")
);
// Constraint-violation message splitting.
let cv = |msg: &str| sqlite(SqliteErrorKind::UniqueViolation, msg);
assert_eq!(
error_hint_class(&cv("UNIQUE constraint failed: T.c"), &d()),
Some("unique")
);
assert_eq!(
error_hint_class(&cv("NOT NULL constraint failed: T.c"), &d()),
Some("not_null")
);
assert_eq!(
error_hint_class(&cv("CHECK constraint failed: T"), &d()),
Some("check")
);
// change-column op routes any engine error to type_mismatch.
assert_eq!(
error_hint_class(
&sqlite(SqliteErrorKind::Other, "x"),
&ctx_with(Operation::ChangeColumnType)
),
Some("type_mismatch")
);
// App-level refusals and internal/fatal errors.
assert_eq!(
error_hint_class(&DbError::InvalidValue("bad".to_string()), &d()),
Some("invalid_value")
);
assert_eq!(error_hint_class(&DbError::WorkerGone, &d()), None);
}
#[test]
fn hint_class_resolves_foreign_key_sides() {
use crate::db::{DbError, SqliteErrorKind};
let fk = || DbError::Sqlite {
message: "FOREIGN KEY constraint failed".to_string(),
kind: SqliteErrorKind::UniqueViolation,
};
// Enrichment: parent_table populated → child-side.
let ctx = TranslateContext {
parent_table: Some("Parent".to_string()),
..TranslateContext::default()
};
assert_eq!(error_hint_class(&fk(), &ctx), Some("foreign_key.child_side"));
// child_table populated → parent-side.
let ctx = TranslateContext {
child_table: Some("Child".to_string()),
..TranslateContext::default()
};
assert_eq!(error_hint_class(&fk(), &ctx), Some("foreign_key.parent_side"));
// No enrichment: operation is the tiebreaker.
assert_eq!(
error_hint_class(&fk(), &ctx_with(Operation::Delete)),
Some("foreign_key.parent_side")
);
assert_eq!(
error_hint_class(&fk(), &ctx_with(Operation::Insert)),
Some("foreign_key.child_side")
);
}
fn sqlite(message: &str, kind: SqliteErrorKind) -> DbError {
DbError::Sqlite {
message: message.to_string(),
+146
View File
@@ -1356,6 +1356,93 @@ mod tests {
}
}
fn seed_cache() -> crate::completion::SchemaCache {
use crate::completion::TableColumn;
use crate::dsl::types::Type;
let mut cache = crate::completion::SchemaCache::default();
cache.tables.push("users".to_string());
cache.columns.push("email".to_string());
cache
.table_columns
.insert("users".to_string(), vec![TableColumn::new("email", Type::Text)]);
cache
}
#[test]
fn seed_count_is_advertised_at_the_optional_position() {
// Issue #26: `seed users ▮` is a complete command, so the hint
// ladder shows only the `set` / `--seed` continuation chips —
// the optional row count (a bare number with no candidate) was
// invisible. An IntroProse hint that survives the trailing
// optionals now advertises it; Tab still cycles the keywords.
let cache = seed_cache();
let input = "seed users ";
match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple) {
Some(AmbientHint::Prose(p)) => {
assert!(
p.contains("row count") && p.contains("20"),
"prose must mention the row count and the default; got: {p:?}",
);
assert!(
p.contains("set") && p.contains("--seed") && p.contains(".column"),
"prose should fold in the keyword + column-fill options; got: {p:?}",
);
}
other => panic!("expected a Prose count hint; got: {other:?}"),
}
// Tab candidates remain available (completion is independent).
let comp = crate::completion::candidates_at_cursor_in_mode(
input, input.len(), &cache, Mode::Simple,
)
.expect("completion remains available");
let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect();
assert!(
texts.contains(&"set") && texts.contains(&"--seed"),
"Tab must still cycle `set` / `--seed`; got {texts:?}",
);
// `seed` runs in both modes (ADR-0048), so the hint must fire in
// advanced mode too — not only simple.
match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) {
Some(AmbientHint::Prose(p)) => assert!(
p.contains("row count"),
"count hint must also fire in advanced mode; got: {p:?}",
),
other => panic!("expected the count hint in advanced mode; got: {other:?}"),
}
}
#[test]
fn seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given() {
// Position guard: the hint shows only while the cursor sits at
// the count slot. Once the count is supplied — or a later clause
// consumes input past it — it must not reappear.
let cache = seed_cache();
for input in ["seed users 50 ", "seed users set email = 'x' "] {
let hint = ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple);
let is_count_prose = matches!(
&hint,
Some(AmbientHint::Prose(p)) if p.contains("row count")
);
assert!(!is_count_prose, "count hint must not show for {input:?}; got {hint:?}");
}
}
#[test]
fn seed_count_hint_also_fires_after_a_column_fill_target() {
// The count is valid after `seed users.email` too, so the hint
// fires there — `.email` is a real column (no diagnostic).
let cache = seed_cache();
let input = "seed users.email ";
match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple) {
Some(AmbientHint::Prose(p)) => assert!(
p.contains("row count"),
"count hint expected after a column-fill target; got: {p:?}",
),
other => panic!("expected a Prose count hint; got: {other:?}"),
}
}
#[test]
fn genuine_column_typo_in_complete_select_still_hints_via_diagnostic() {
// Issue #6 trade-off lockdown: dropping the typing-time
@@ -1765,6 +1852,65 @@ mod tests {
cache
}
fn issue31_join_cache() -> crate::completion::SchemaCache {
use crate::completion::{SchemaCache, TableColumn};
use crate::dsl::types::Type;
let mut cache = SchemaCache::default();
let tables: &[(&str, &[(&str, Type)])] = &[
("Customers", &[("id", Type::Serial), ("name", Type::Text)]),
(
"Products",
&[("id", Type::Serial), ("name", Type::Text), ("price", Type::Decimal)],
),
(
"OrderLines",
&[
("id", Type::Serial),
("order_id", Type::Int),
("product_id", Type::Int),
("count", Type::Int),
],
),
(
"Orders",
&[("id", Type::Serial), ("customer_id", Type::Int), ("date", Type::Date)],
),
];
for (t, cols) in tables {
cache.tables.push((*t).to_string());
let tc: Vec<TableColumn> =
cols.iter().map(|(n, ty)| TableColumn::new(*n, *ty)).collect();
for c in &tc {
cache.columns.push(c.name.clone());
}
cache.table_columns.insert((*t).to_string(), tc);
}
cache
}
#[test]
fn issue31_group_by_partial_alias_shows_alias_hint() {
// Issue #31 end-to-end: the manual-testing query ended in
// `… group by o`, where `o` aliases `Orders`. The ambient
// hint must guide the learner to `o.<column>`, not claim
// `o` is an unknown column.
let cache = issue31_join_cache();
let input = "select c.name as customer_name, o.id as order_id, o.date, sum(ol.count*p.price) as total from Orders o join OrderLines ol on o.id=ol.order_id join Products p on p.id=ol.product_id join Customers c on c.id=o.customer_id group by o";
match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) {
Some(AmbientHint::Prose(p)) => {
assert!(
p.contains("`o` is a table alias") && p.contains("o.<column>"),
"expected the alias hint; got: {p:?}",
);
assert!(
!p.contains("no such column"),
"must not show the misleading unknown-column message; got: {p:?}",
);
}
other => panic!("expected a Prose alias hint; got: {other:?}"),
}
}
#[test]
fn ambient_hint_at_insert_first_value_shows_int_prose() {
use crate::dsl::types::Type;
+34 -63
View File
@@ -71,27 +71,22 @@ pub fn render_data_table(data: &DataResult) -> Vec<String> {
render_table(&header_cells, &body, &alignments)
}
/// Render a table-structure listing.
/// Render an incidental-DDL structure echo (ADR-0050, issue #28).
///
/// Produces a header line (`<TableName>`), the schema table
/// itself, and — for a structure that has FK relationships
/// — `References:` / `Referenced by:` blocks below as plain
/// indented text (relationship visualization is its own
/// future ADR per §5 OOS-1).
/// Display a relationship-endpoint column list (ADR-0043): the bare
/// column for a single-column FK, `(a, b)` for a compound one.
fn cols_disp(cols: &[String]) -> String {
if cols.len() == 1 {
cols[0].clone()
} else {
format!("({})", cols.join(", "))
}
}
/// Produces a header line (`<TableName>`), the schema table, the
/// `Indexes:` section, and the constraint section — **structure only**.
/// Relationship information is deliberately omitted: a confirmation
/// echo for a structural edit (`create table`, `add`/`drop`/`rename`/
/// `change column`, `add`/`drop index`) reports the change just made,
/// not the table's relationships, which the user did not touch. The
/// relationship-subject surfaces (`show table`, `add`/`drop
/// relationship`) render diagrams via [`render_structure_with_diagrams`]
/// instead; relationships are one `show table <T>` away. ADR-0050
/// supersedes ADR-0044 §1's "incidental DDL keeps prose" and the
/// relationship-block half of ADR-0016 §5.
#[must_use]
pub fn render_structure(desc: &TableDescription) -> Vec<String> {
let mut out = structure_box_lines(desc);
out.extend(relationship_prose_lines(desc));
out.extend(index_lines(desc));
out.extend(constraint_lines(desc));
out
@@ -118,41 +113,6 @@ fn structure_box_lines(desc: &TableDescription) -> Vec<String> {
out
}
/// The `References:` / `Referenced by:` prose blocks (ADR-0016 §5),
/// retained for the incidental DDL echoes (ADR-0044 §1).
fn relationship_prose_lines(desc: &TableDescription) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
if !desc.outbound_relationships.is_empty() {
out.push("References:".to_string());
for r in &desc.outbound_relationships {
out.push(format!(
" {} → {}.{} ({}, on delete {}, on update {})",
cols_disp(&r.local_columns),
r.other_table,
cols_disp(&r.other_columns),
r.name,
r.on_delete,
r.on_update,
));
}
}
if !desc.inbound_relationships.is_empty() {
out.push("Referenced by:".to_string());
for r in &desc.inbound_relationships {
out.push(format!(
" {}.{} → {} ({}, on delete {}, on update {})",
r.other_table,
cols_disp(&r.other_columns),
cols_disp(&r.local_columns),
r.name,
r.on_delete,
r.on_update,
));
}
}
out
}
/// Indexes section (ADR-0025), only when the table carries a
/// user-created index. A UNIQUE index is marked `[unique]` (ADR-0035
/// §4d).
@@ -1591,11 +1551,23 @@ mod tests {
}
#[test]
fn render_structure_with_relationships() {
fn render_structure_omits_relationship_prose() {
// ADR-0050 (issue #28): the incidental-DDL structure echo never
// carries the `References:` / `Referenced by:` block, even when
// the description carries both inbound and outbound
// relationships. (Relationship-subject surfaces render diagrams
// via `render_structure_with_diagrams`, not this function.)
let desc = TableDescription {
name: "Customers".to_string(),
columns: vec![col("id", Type::Serial, true, false)],
outbound_relationships: Vec::new(),
outbound_relationships: vec![RelationshipEnd {
name: "cust_region".to_string(),
other_table: "Regions".to_string(),
other_columns: vec!["id".to_string()],
local_columns: vec!["region_id".to_string()],
on_delete: ReferentialAction::NoAction,
on_update: ReferentialAction::NoAction,
}],
inbound_relationships: vec![RelationshipEnd {
name: "cust_orders".to_string(),
other_table: "Orders".to_string(),
@@ -1609,15 +1581,14 @@ mod tests {
check_constraints: Vec::new(),
};
let out = render_structure(&desc).join("\n");
assert!(
out.contains("Referenced by:"),
"expected inbound relationship section:\n{out}",
);
assert!(
out.contains("Orders.cust_id → id"),
"expected inbound relationship line:\n{out}",
);
assert_snapshot!(out);
// The structure box still renders.
assert!(out.contains("Customers"), "structure header:\n{out}");
assert!(out.contains("│ id"), "column row:\n{out}");
// No relationship block in either direction.
assert!(!out.contains("References:"), "no outbound prose:\n{out}");
assert!(!out.contains("Referenced by:"), "no inbound prose:\n{out}");
assert!(!out.contains("Orders.cust_id"), "no prose line:\n{out}");
assert!(!out.contains("Regions"), "no prose line:\n{out}");
}
#[test]
+112 -4
View File
@@ -28,7 +28,35 @@ use super::PersistenceError;
pub(super) const STATUS_OK: &str = "ok";
pub(super) const STATUS_ERR: &str = "err";
/// Format a successful-command record. Pure; no I/O.
/// The optional status suffix marking an advanced-mode submission
/// (ADR-0052, issue #30): `ok:adv` / `err:adv`. Recorded so that
/// hydration can reconstruct the `:`-prefixed runnable form of an
/// advanced command, making advanced history reusable in simple mode.
pub(super) const ADV_SUFFIX: &str = "adv";
/// Build the status token for a `base` (`ok`/`err`) and submission mode.
pub(super) fn status_token(base: &str, advanced: bool) -> String {
if advanced {
format!("{base}:{ADV_SUFFIX}")
} else {
base.to_string()
}
}
/// Parse a status token into `(is_ok, advanced)` (ADR-0052). The base
/// (`ok` ⇒ replayable, anything else ⇒ skip) precedes an optional
/// `:adv` mode suffix. An unknown base degrades to `(false, _)`, so
/// replay skips it rather than mis-running it.
pub(super) fn parse_status(status: &str) -> (bool, bool) {
let (base, suffix) = status.split_once(':').unwrap_or((status, ""));
(base == STATUS_OK, suffix == ADV_SUFFIX)
}
/// Format a successful-command record. Pure; no I/O. (Simple-mode
/// convenience used by tests; production threads the mode through
/// [`format_record_with_status`] + [`status_token`], so this is
/// test-only since ADR-0052.)
#[cfg(test)]
pub(super) fn format_record(command_text: &str, timestamp_iso: String) -> String {
format_record_with_status(command_text, timestamp_iso, STATUS_OK)
}
@@ -100,9 +128,20 @@ fn parse_record_source(line: &str) -> Option<String> {
// characters) is preserved.
let mut parts = line.splitn(3, '|');
let _ts = parts.next()?;
let _status = parts.next()?;
let status = parts.next()?;
let source = parts.next()?;
Some(unescape_command(source))
let (_is_ok, advanced) = parse_status(status);
let command = unescape_command(source);
// ADR-0052: an advanced record is hydrated in its `:`-prefixed
// simple-mode runnable form, so cross-session recall matches the
// in-session ring (and recall strips the `:` again in advanced
// mode). A simple record hydrates bare. Old `ok`/`err` logs have no
// `:adv` suffix → read as simple, unchanged.
Some(if advanced {
format!(": {command}")
} else {
command
})
}
/// A parsed journal record (ADR-0034 §3). `source` is already
@@ -129,8 +168,11 @@ pub(super) fn parse_journal_record(line: &str) -> Option<JournalRecord> {
if !looks_like_iso8601(ts) {
return None;
}
// ADR-0052: the status may carry a `:adv` mode suffix; replayability
// keys off the base token only (`ok` / `ok:adv` are both ok).
let (status_is_ok, _advanced) = parse_status(status);
Some(JournalRecord {
status_is_ok: status == STATUS_OK,
status_is_ok,
source: unescape_command(source),
})
}
@@ -436,4 +478,70 @@ mod tests {
let body = fs::read_to_string(&path).unwrap();
assert_eq!(body, "first|ok|a\nsecond|ok|b\n");
}
// ---- ADR-0052 (issue #30): mode tag in the status field ----
#[test]
fn status_token_builds_and_parses_the_adv_suffix() {
assert_eq!(status_token(STATUS_OK, false), "ok");
assert_eq!(status_token(STATUS_OK, true), "ok:adv");
assert_eq!(status_token(STATUS_ERR, true), "err:adv");
assert_eq!(parse_status("ok"), (true, false));
assert_eq!(parse_status("ok:adv"), (true, true));
assert_eq!(parse_status("err"), (false, false));
assert_eq!(parse_status("err:adv"), (false, true));
// Unknown base → not ok (replay skips it), simple.
assert_eq!(parse_status("frobnicate"), (false, false));
}
#[test]
fn read_recent_sources_reconstructs_colon_prefix_for_advanced() {
// An advanced record (`ok:adv`) hydrates in its `:`-prefixed
// simple-mode runnable form; a simple record stays bare. This is
// the cross-session half of the issue #30 fix.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("history.log");
let adv = format_record_with_status(
"select * from T",
"2026-06-13T10:00:00Z".to_string(),
&status_token(STATUS_OK, true),
);
let simple = format_record_with_status(
"create table T with pk",
"2026-06-13T10:00:01Z".to_string(),
&status_token(STATUS_OK, false),
);
std::fs::write(&path, format!("{adv}{simple}")).unwrap();
let got = read_recent_sources(&path, 10).unwrap();
assert_eq!(
got,
vec![
": select * from T".to_string(),
"create table T with pk".to_string(),
],
);
}
#[test]
fn parse_journal_record_treats_ok_adv_as_ok() {
// Replay keys off the base token, so `ok:adv` replays like `ok`
// (source stays canonical).
let rec = parse_journal_record("2026-06-13T10:00:00Z|ok:adv|select * from T")
.expect("ok:adv journal record");
assert!(rec.status_is_ok);
assert_eq!(rec.source, "select * from T");
let err = parse_journal_record("2026-06-13T10:00:00Z|err:adv|select bad")
.expect("err:adv journal record");
assert!(!err.status_is_ok);
}
#[test]
fn old_three_field_log_reads_as_simple() {
// Back-compat: a pre-ADR-0052 log (no `:adv`) hydrates bare.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("history.log");
std::fs::write(&path, "2026-01-01T00:00:00Z|ok|select 1\n").unwrap();
let got = read_recent_sources(&path, 10).unwrap();
assert_eq!(got, vec!["select 1".to_string()]);
}
}
+32 -9
View File
@@ -395,11 +395,26 @@ impl Persistence {
}
}
/// Append one successful-command record to `history.log`.
pub fn append_history(&self, command_text: &str) -> Result<(), PersistenceError> {
/// Append one successful-command record to `history.log`. `advanced`
/// (ADR-0052) tags the record `ok:adv` when the command was submitted
/// in an advanced effective mode, so hydration can reconstruct its
/// `:`-prefixed form for reuse in simple mode.
pub fn append_history(
&self,
command_text: &str,
advanced: bool,
) -> Result<(), PersistenceError> {
let path = self.project_path.join(HISTORY_LOG);
let line = history::format_record(command_text, history::utc_iso8601_now());
debug!(len = command_text.len(), "persist: append ok record to history.log");
let status = history::status_token(history::STATUS_OK, advanced);
let line = history::format_record_with_status(
command_text,
history::utc_iso8601_now(),
&status,
);
debug!(
len = command_text.len(),
advanced, "persist: append ok record to history.log"
);
history::append(&path, &line)
}
@@ -410,14 +425,22 @@ impl Persistence {
/// transactional `ok` journal). Best-effort at the call site:
/// a failure to record a failure must never escalate a user
/// error into a fatal (ADR-0034 §4).
pub fn append_history_failure(&self, command_text: &str) -> Result<(), PersistenceError> {
pub fn append_history_failure(
&self,
command_text: &str,
advanced: bool,
) -> Result<(), PersistenceError> {
let path = self.project_path.join(HISTORY_LOG);
let status = history::status_token(history::STATUS_ERR, advanced);
let line = history::format_record_with_status(
command_text,
history::utc_iso8601_now(),
history::STATUS_ERR,
&status,
);
debug!(
len = command_text.len(),
advanced, "persist: append err record to history.log"
);
debug!(len = command_text.len(), "persist: append err record to history.log");
history::append(&path, &line)
}
@@ -577,8 +600,8 @@ mod tests {
fn append_history_creates_and_appends() {
let dir = tempdir();
let p = Persistence::new(dir.path().to_path_buf());
p.append_history("create table Foo with pk id(serial)").unwrap();
p.append_history("insert into Foo (1)").unwrap();
p.append_history("create table Foo with pk id(serial)", false).unwrap();
p.append_history("insert into Foo (1)", false).unwrap();
let body = fs::read_to_string(dir.path().join(HISTORY_LOG)).unwrap();
let lines: Vec<&str> = body.trim_end().lines().collect();
assert_eq!(lines.len(), 2);
+61 -22
View File
@@ -479,17 +479,19 @@ async fn run_loop(
command,
source,
submission_mode,
session.project().path().to_path_buf(),
);
}
Action::JournalFailure { source } => {
Action::JournalFailure { source, advanced } => {
// ADR-0034 §1/§4: record a failed command as an
// `err` record. Best-effort — a failure to record
// a failure must never escalate a user error into
// a fatal, so the result is logged and ignored.
// `err` record (ADR-0052: `err:adv` when advanced).
// Best-effort — a failure to record a failure must
// never escalate a user error into a fatal, so the
// result is logged and ignored.
if let Err(e) = crate::persistence::Persistence::new(
session.project().path().to_path_buf(),
)
.append_history_failure(&source)
.append_history_failure(&source, advanced)
{
tracing::warn!(error = %e, "failed to journal err record (ignored)");
}
@@ -971,7 +973,9 @@ async fn perform_switch(
// history.log. The worker's persistence is wired but not
// directly addressable from here, so we use a fresh
// Persistence handle for this single line.
let _ = Persistence::new(new_path.clone()).append_history(&source);
// App-lifecycle command (save-as/load/new): journalled simple
// (ADR-0052 — app commands run in any mode, so no `:` on recall).
let _ = Persistence::new(new_path.clone()).append_history(&source, false);
// Update the resume pointer so the next `--resume` launch
// reopens the project we just switched to — unless it is a
@@ -1040,7 +1044,9 @@ fn spawn_export(
source: String,
event_tx: mpsc::Sender<AppEvent>,
) {
let _ = crate::persistence::Persistence::new(project_path.clone()).append_history(&source);
// `export` app command: journalled simple (ADR-0052).
let _ = crate::persistence::Persistence::new(project_path.clone())
.append_history(&source, false);
tokio::spawn(async move {
let outcome = tokio::task::spawn_blocking(move || {
do_export(&project_path, &project_name, &data_root, target.as_deref())
@@ -1184,7 +1190,7 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac
// miss leaves that table's columns unpopulated and the
// walker falls back to the schemaless value-literal list.
for name in cache.tables.clone() {
if let Ok(desc) = database.describe_table(name.clone(), None).await {
if let Ok(desc) = database.describe_table(name.clone()).await {
// Per-table indexes for the items panel (S2, ADR-0025).
// Carry uniqueness so the panel can mark a UNIQUE index
// (ADR-0035 §4d). Captured before `desc.columns` is
@@ -1295,11 +1301,20 @@ fn spawn_rebuild(
source: String,
) {
tokio::spawn(async move {
let source_for_journal = source.clone();
match database
.rebuild_from_text(project_path.clone(), Some(source))
.await
{
Ok(()) => {
// ADR-0052: journal `rebuild` at the dispatch layer (the
// worker no longer journals); simple (app command),
// best-effort.
if let Err(e) = crate::persistence::Persistence::new(project_path.clone())
.append_history(&source_for_journal, false)
{
warn!(error = %e, "failed to journal rebuild (ignored)");
}
let summary = summarize_project(&project_path)
.unwrap_or_else(|_| "rebuild complete".to_string());
let _ = event_tx
@@ -1407,10 +1422,11 @@ fn spawn_dsl_dispatch(
command: Command,
source: String,
submission_mode: crate::app::EffectiveMode,
project_path: std::path::PathBuf,
) {
tokio::spawn(async move {
// Retain the source for `DslFailed` so the App can journal a
// rejected command as `err` (ADR-0034 §1/§2).
// Retain the source for journaling (ADR-0034 §1/§2; ADR-0052
// moved success journaling here, next to the failure path).
let source_for_journal = source.clone();
// ADR-0038: the DSL → SQL teaching echo fires for a DSL-form
// command submitted in an advanced effective mode (ADR-0037).
@@ -1431,6 +1447,19 @@ fn spawn_dsl_dispatch(
let lookups = collect_echo_lookups(&database, &command, submission_mode).await;
let echo = crate::echo::echo_for(&command, submission_mode);
let outcome = execute_command_typed(&database, command.clone(), source).await;
// ADR-0052 (issue #30): journal a SUCCESSFUL command here, at the
// top of the chain — the canonical source + submission mode are
// both in scope, so no mode-plumbing into the worker is needed.
// Best-effort (ADR-0040 amended): the command is already committed;
// a journal-write failure is logged, never fatal. Failures stay on
// the `JournalFailure` path (Ok/Err are exclusive — no double
// journal). `:adv` tags an advanced submission (ADR-0052).
if outcome.is_ok()
&& let Err(e) = crate::persistence::Persistence::new(project_path)
.append_history(&source_for_journal, submission_mode.is_advanced())
{
warn!(error = %e, "failed to journal ok record (ignored)");
}
let event = match outcome {
Ok(CommandOutcome::Schema(description)) => {
let schema_echo = build_schema_echo(
@@ -1569,6 +1598,7 @@ fn spawn_dsl_dispatch(
error,
facts,
source: source_for_journal,
advanced: submission_mode.is_advanced(),
}
}
};
@@ -1620,7 +1650,7 @@ async fn build_show_data_echo(
limit: Some(_),
..
} => database
.describe_table(name.clone(), None)
.describe_table(name.clone())
.await
.map(|desc| {
desc.columns
@@ -1702,7 +1732,7 @@ async fn collect_echo_lookups(
Command::DropIndex {
selector: IndexSelector::Columns { table, columns },
} => {
if let Ok(desc) = database.describe_table(table.clone(), None).await
if let Ok(desc) = database.describe_table(table.clone()).await
&& let Some(idx) = desc.indexes.iter().find(|i| i.columns == *columns)
{
out.drop_index_name = Some(idx.name.clone());
@@ -1717,7 +1747,7 @@ async fn collect_echo_lookups(
child_column,
},
} => {
if let Ok(desc) = database.describe_table(child_table.clone(), None).await
if let Ok(desc) = database.describe_table(child_table.clone()).await
&& let Some(rel) = desc.outbound_relationships.iter().find(|r| {
// The Endpoints drop selector is single-column
// (ADR-0043 keeps DROP by-endpoints single-column;
@@ -1741,7 +1771,7 @@ async fn collect_echo_lookups(
// resolver API would be the next step if schemas grow.
if let Ok(tables) = database.list_tables().await {
for table in tables {
if let Ok(desc) = database.describe_table(table.clone(), None).await
if let Ok(desc) = database.describe_table(table.clone()).await
&& desc.outbound_relationships.iter().any(|r| r.name == *name)
{
out.drop_relationship = Some((name.clone(), table.clone()));
@@ -1765,8 +1795,8 @@ async fn collect_echo_lookups(
// *before* execution to know which `ADD COLUMN` lines to
// emit. The parent columns here are the explicit DSL list,
// paired positionally with the child list.
let parent_desc = database.describe_table(parent_table.clone(), None).await;
let child_desc = database.describe_table(child_table.clone(), None).await;
let parent_desc = database.describe_table(parent_table.clone()).await;
let child_desc = database.describe_table(child_table.clone()).await;
if let (Ok(parent_desc), Ok(child_desc)) = (parent_desc, child_desc) {
let mut new_columns: Vec<(String, crate::dsl::types::Type)> = Vec::new();
for (child_col, parent_col) in child_columns.iter().zip(parent_columns) {
@@ -2034,7 +2064,7 @@ async fn enrich_check_violation(
.await
.map(|v| v.to_string());
// The rule itself — the column's compiled CHECK expression.
if let Ok(desc) = database.describe_table(table.to_string(), None).await
if let Ok(desc) = database.describe_table(table.to_string()).await
&& let Some(col) = desc.columns.iter().find(|c| c.name == column)
{
facts.check_rule.clone_from(&col.check);
@@ -2242,7 +2272,7 @@ async fn user_value_for_column_with_schema(
} = command
{
let desc = database
.describe_table(table.to_string(), None)
.describe_table(table.to_string())
.await
.ok()?;
// Build the natural-order column list the same way
@@ -2281,7 +2311,7 @@ async fn user_value_for_column_with_schema(
&& literal_rows.len() == 1
{
let desc = database
.describe_table(table.to_string(), None)
.describe_table(table.to_string())
.await
.ok()?;
let idx = desc.columns.iter().position(|c| c.name == column)?;
@@ -2540,6 +2570,15 @@ pub async fn run_replay(
execute_command_typed(database, command, command_text.clone()).await;
match outcome {
Ok(_) => {
// ADR-0052: journal the replayed line at the dispatch
// layer (the worker no longer journals). Replay is
// mode-agnostic, so the re-written record is tagged
// simple; best-effort, like the interactive path.
if let Err(e) = crate::persistence::Persistence::new(project_root.to_path_buf())
.append_history(&command_text, false)
{
warn!(error = %e, "failed to journal replayed line (ignored)");
}
count += 1;
}
Err(DbError::PersistenceFatal {
@@ -2891,7 +2930,7 @@ async fn execute_command_typed(
.await
.map(|d| CommandOutcome::Schema(Some(d))),
Command::ShowTable { name } => database
.describe_table(name, src)
.describe_table(name)
.await
.map(|d| CommandOutcome::Schema(Some(d))),
// ADR-0044: a named relationship renders as a diagram (App-side),
@@ -2944,14 +2983,14 @@ async fn execute_command_typed(
filter,
limit,
} => database
.query_data(name, filter, limit, src)
.query_data(name, filter, limit)
.await
.map(CommandOutcome::Query),
// A SQL `SELECT` (advanced mode; ADR-0030 §6, ADR-0031).
// The grammar walker has already validated `sql` is in
// the supported subset; the worker runs it as text.
Command::Select { sql } => database
.run_select(sql, src)
.run_select(sql)
.await
.map(CommandOutcome::Query),
// A SQL `INSERT` (advanced mode; ADR-0033 §1). Grammar-as-
+52
View File
@@ -31,6 +31,16 @@ const RECENT_WINDOW_DAYS: i64 = 3 * 365;
const ADULT_MIN_DAYS: i64 = 18 * 365;
const ADULT_MAX_DAYS: i64 = 80 * 365;
/// Year windows for the `int`-typed year heuristics (issue #33),
/// expressed relative to [`REF_YEAR`] so they advance with releases —
/// the year siblings of the `DateRecent` / `DateAdult` windows above.
/// `YearRecent` spans ~75 years (19502025 at REF_YEAR=2025), wide
/// enough for `published` / `founded` / `release_year`; `YearBirth`
/// mirrors the adult birth window (19452007).
const YEAR_RECENT_SPAN: i32 = 75;
const YEAR_BIRTH_MIN_AGE: i32 = 18;
const YEAR_BIRTH_MAX_AGE: i32 = 80;
/// Produce one value for `generator` against destination type `ty`.
#[must_use]
pub fn generate_value(generator: &Generator, ty: Type, rng: &mut SeedRng) -> Value {
@@ -71,6 +81,13 @@ pub fn generate_value(generator: &Generator, ty: Type, rng: &mut SeedRng) -> Val
Generator::CurrencyAmount => currency_amount(ty, rng),
Generator::Age => Value::Number(rng.random_range(18..=80).to_string()),
Generator::SmallInt => Value::Number(rng.random_range(1..=100).to_string()),
Generator::YearRecent => {
Value::Number(rng.random_range((REF_YEAR - YEAR_RECENT_SPAN)..=REF_YEAR).to_string())
}
Generator::YearBirth => Value::Number(
rng.random_range((REF_YEAR - YEAR_BIRTH_MAX_AGE)..=(REF_YEAR - YEAR_BIRTH_MIN_AGE))
.to_string(),
),
Generator::DateRecent => Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS))),
Generator::DateAdult => {
Value::Text(format_date(random_past_date(rng, ADULT_MIN_DAYS, ADULT_MAX_DAYS)))
@@ -489,6 +506,41 @@ mod tests {
assert!(matches!(v, Value::Number(_)), "numeric pick should be a Number: {v:?}");
}
#[test]
fn year_generators_stay_within_their_bounded_windows() {
// Issue #33: both year generators emit a plain `int` inside a
// bounded, plausible window — never the unbounded-int nonsense.
let mut rng = make_rng(Some(7));
for _ in 0..300 {
let Value::Number(s) = generate_value(&Generator::YearRecent, Type::Int, &mut rng)
else {
panic!("YearRecent must be a Number")
};
let n: i32 = s.parse().unwrap();
assert!((1950..=2025).contains(&n), "YearRecent {n} out of [1950,2025]");
}
for _ in 0..300 {
let Value::Number(s) = generate_value(&Generator::YearBirth, Type::Int, &mut rng)
else {
panic!("YearBirth must be a Number")
};
let n: i32 = s.parse().unwrap();
assert!((1945..=2007).contains(&n), "YearBirth {n} out of [1945,2007]");
}
}
#[test]
fn year_generators_are_deterministic_for_a_fixed_seed() {
assert_eq!(
gen_once(&Generator::YearRecent, Type::Int, 42),
gen_once(&Generator::YearRecent, Type::Int, 42),
);
assert_eq!(
gen_once(&Generator::YearBirth, Type::Int, 42),
gen_once(&Generator::YearBirth, Type::Int, 42),
);
}
#[test]
fn int_range_stays_within_inclusive_bounds() {
let g = Generator::Range { low: "10".into(), high: "20".into() };
+128 -2
View File
@@ -57,9 +57,14 @@ fn choose_generator_inner(table: &str, col: &ColumnSpec) -> Generator {
/// the post-seed advisory; such columns still receive generic text.
#[must_use]
pub fn is_enum_ish(name: &str) -> bool {
// `priority` is intentionally absent: issue #34 gave it a built-in
// value set (low/medium/high · 1/2/3), so it is no longer "filled
// generically" and must not trigger the D13 advisory. `severity` /
// `rating` / `stars` were never here. `status` stays — it is
// deliberately left to the advisory (no built-in set).
const ENUM_TOKENS: &[&str] = &[
"role", "status", "state", "type", "kind", "category", "level",
"tier", "stage", "priority", "gender",
"tier", "stage", "gender",
];
let toks = tokens(name);
toks.iter().any(|t| ENUM_TOKENS.contains(&t.as_str()))
@@ -150,6 +155,49 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
if numeric && has_any(toks, &["quantity", "qty", "stock", "count"]) {
return Some(Generator::SmallInt);
}
// — Year-as-int (issue #33) — bounded plausible years so the `int`
// type fallback (D8) can't emit nonsense like `9419`. `int`-gated
// (years are whole numbers) and placed *after* the quantity rule so
// `year_count` (a count of years) stays a `SmallInt`. `birth`/`born`/
// `dob` + year picks the birth window — the int sibling of the
// `dob → DateAdult` rule above — otherwise a recent window covers
// `year` / `*_year` / `published` / `founded`.
if matches!(ty, Type::Int)
&& (has_token(toks, "year") || has_any(toks, &["published", "founded"]))
{
return Some(if has_any(toks, &["birth", "born", "dob"]) {
Generator::YearBirth
} else {
Generator::YearRecent
});
}
// — Conventional choice sets (issue #34) — a few enum-ish names have
// a near-canonical small value set that reads far better than lorem
// text. Type-gated; reuses `PickFrom`. Names *without* a canonical
// set (`status`, `role`, `type`, …) stay unmatched → generic text +
// the D12/D13 advisory. `status` is deliberately excluded: its real
// values are too domain-specific (user-confirmed, issue #34). A
// user-declared `IN`-CHECK still wins — it is resolved before this.
if has_any(toks, &["priority", "prio"]) {
if text {
return Some(pick_from(&["low", "medium", "high"]));
}
if matches!(ty, Type::Int) {
return Some(pick_from(&["1", "2", "3"]));
}
}
if has_token(toks, "severity") {
if text {
return Some(pick_from(&["low", "medium", "high", "critical"]));
}
if matches!(ty, Type::Int) {
return Some(pick_from(&["1", "2", "3", "4"]));
}
}
if matches!(ty, Type::Int) && has_any(toks, &["rating", "stars"]) {
return Some(pick_from(&["1", "2", "3", "4", "5"]));
}
// — Temporal (bounded, D8) —
if matches!(ty, Type::Date) && has_any(toks, &["dob", "birthday", "birthdate"]) {
@@ -267,6 +315,14 @@ fn tokens(name: &str) -> Vec<String> {
out
}
/// A `PickFrom` generator from string-literal values (issue #34's
/// conventional choice sets). `literal_to_value` interprets each entry
/// by the destination type at generation time (an `int` column turns
/// `"1"` into a number).
fn pick_from(values: &[&str]) -> Generator {
Generator::PickFrom(values.iter().map(|s| (*s).to_string()).collect())
}
fn has_token(toks: &[String], t: &str) -> bool {
toks.iter().any(|x| x == t)
}
@@ -412,11 +468,81 @@ mod tests {
assert!(is_enum_ish("status"));
assert!(is_enum_ish("role"));
assert!(is_enum_ish("order_state"));
assert!(is_enum_ish("priority"));
// Issue #34: `priority` gained a built-in value set, so it is no
// longer advised (it is no longer "filled generically").
assert!(!is_enum_ish("priority"));
assert!(!is_enum_ish("severity"));
assert!(!is_enum_ish("rating"));
assert!(!is_enum_ish("email"));
assert!(!is_enum_ish("first_name"));
}
#[test]
fn year_like_int_columns_map_to_bounded_years() {
// Issue #33: `int`-gated year heuristics. `birth`/`born`/`dob`
// years pick the birth window; the rest a recent window.
assert_eq!(choose("authors", "birth_year", Type::Int), Generator::YearBirth);
assert_eq!(choose("authors", "birthYear", Type::Int), Generator::YearBirth);
assert_eq!(choose("u", "year_born", Type::Int), Generator::YearBirth);
assert_eq!(choose("books", "year", Type::Int), Generator::YearRecent);
assert_eq!(choose("films", "release_year", Type::Int), Generator::YearRecent);
assert_eq!(choose("books", "published", Type::Int), Generator::YearRecent);
assert_eq!(choose("companies", "founded", Type::Int), Generator::YearRecent);
// Type-gated: a text `year` is not a bounded-year int.
assert_eq!(choose("books", "year", Type::Text), Generator::Generic);
// `year_count` is a count, not a year — the quantity rule wins.
assert_eq!(choose("t", "year_count", Type::Int), Generator::SmallInt);
}
#[test]
fn conventional_choice_sets_map_to_pick_from() {
// Issue #34: type-gated built-in value sets.
assert_eq!(
choose("tickets", "priority", Type::Text),
Generator::PickFrom(vec!["low".into(), "medium".into(), "high".into()]),
);
assert_eq!(
choose("tickets", "prio", Type::Int),
Generator::PickFrom(vec!["1".into(), "2".into(), "3".into()]),
);
assert_eq!(
choose("bugs", "severity", Type::Text),
Generator::PickFrom(vec!["low".into(), "medium".into(), "high".into(), "critical".into()]),
);
assert_eq!(
choose("bugs", "severity", Type::Int),
Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into()]),
);
assert_eq!(
choose("reviews", "rating", Type::Int),
Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into(), "5".into()]),
);
assert_eq!(
choose("reviews", "stars", Type::Int),
Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into(), "5".into()]),
);
}
#[test]
fn status_is_left_to_the_advisory_not_given_a_set() {
// User-confirmed (issue #34): `status` keeps the D12 "don't
// guess" stance — generic text + the advisory, no built-in set.
assert_eq!(choose("orders", "status", Type::Text), Generator::Generic);
assert!(is_enum_ish("status"));
}
#[test]
fn a_declared_in_check_still_wins_over_a_built_in_set() {
// The CHECK is the user's explicit intent; it precedes the
// issue-#34 default set for the same name.
let mut spec = ColumnSpec::plain("priority", Type::Text);
spec.check_in_values = Some(vec!["p1".into(), "p2".into()]);
assert_eq!(
choose_generator("tickets", &spec),
Generator::PickFrom(vec!["p1".into(), "p2".into()]),
);
}
#[test]
fn enum_ish_columns_fall_through_to_generic() {
// No special generator — generic text + the advisory flags them.
+7
View File
@@ -149,6 +149,13 @@ pub enum Generator {
Age,
/// A small positive integer (quantities, counts).
SmallInt,
/// A plausible recent year as a plain `int` — `year` / `*_year` /
/// `published` / `founded` columns (issue #33). Bounded window so the
/// type-based `int` fallback can't emit nonsense like `9419`.
YearRecent,
/// A plausible birth year as a plain `int` — `birth_year` and kin
/// (issue #33), the year-typed sibling of [`Self::DateAdult`].
YearBirth,
// — Temporal (bounded windows, D8) —
/// A date within the last few years.
DateRecent,
@@ -0,0 +1,9 @@
---
source: src/app.rs
assertion_line: 5844
expression: block
---
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.
@@ -1,12 +0,0 @@
---
source: src/output_render.rs
expression: out
---
Customers
┌──────┬────────┬─────────────┐
│ Name │ Type │ Constraints │
├──────┼────────┼─────────────┤
│ id │ serial │ PK │
└──────┴────────┴─────────────┘
Referenced by:
Orders.cust_id → id (cust_orders, on delete cascade, on update no action)
@@ -1,6 +1,6 @@
---
source: src/ui.rs
assertion_line: 2326
assertion_line: 2839
expression: snapshot
---
╭ Output ──────────────────────────────────────────────────────────────────────╮
@@ -26,4 +26,4 @@ expression: snapshot
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · mode simple switch · Ctrl-C quit
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
@@ -1,6 +1,6 @@
---
source: src/ui.rs
assertion_line: 2309
assertion_line: 2822
expression: snapshot
---
╭ Output ──────────────────────────────────────────────────────────────────────╮
@@ -22,8 +22,8 @@ expression: snapshot
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
╭ Hint ────────────────────────────────────────────────────────────────────────╮
│Type a command — press Tab for options, `help` for a list
│Type a command — press Tab for options, `help` for a list · `mode advanced`
for SQL
╰──────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
@@ -1,6 +1,6 @@
---
source: src/ui.rs
assertion_line: 2317
assertion_line: 2830
expression: snapshot
---
╭ Output ──────────────────────────────────────────────────────────────────────╮
@@ -22,8 +22,8 @@ expression: snapshot
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
╭ Hint ────────────────────────────────────────────────────────────────────────╮
│Type a command — press Tab for options, `help` for a list
│Type a command — press Tab for options, `help` for a list · `mode advanced`
for SQL
╰──────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
@@ -1,5 +1,6 @@
---
source: src/ui.rs
assertion_line: 3445
expression: snapshot
---
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
@@ -23,8 +24,8 @@ expression: snapshot
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
│Type a command — press Tab for options, `help` for a list
│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
@@ -1,5 +1,6 @@
---
source: src/ui.rs
assertion_line: 3391
expression: snapshot
---
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
@@ -23,8 +24,8 @@ expression: snapshot
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
│Type a command — press Tab for options, `help` for a list
│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
@@ -1,5 +1,6 @@
---
source: src/ui.rs
assertion_line: 3381
expression: snapshot
---
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
@@ -23,8 +24,8 @@ expression: snapshot
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
│Type a command — press Tab for options, `help` for a list
│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
@@ -1,5 +1,6 @@
---
source: src/ui.rs
assertion_line: 3434
expression: snapshot
---
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
@@ -23,8 +24,8 @@ expression: snapshot
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
│Type a command — press Tab for options, `help` for a list
│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
@@ -1,5 +1,6 @@
---
source: src/ui.rs
assertion_line: 3460
expression: snapshot
---
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
@@ -23,8 +24,8 @@ expression: snapshot
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
│Type a command — press Tab for options, `help` for a list
│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL
│ │
╰────────────────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
@@ -1,6 +1,6 @@
---
source: src/ui.rs
assertion_line: 2369
assertion_line: 2882
expression: snapshot
---
╭ Output ──────────────────────────────────────────────────────────────────────╮
@@ -26,4 +26,4 @@ expression: snapshot
│insert into <Table> [(<col>[, ...])] [values] (<value>[, ...]) │
╰──────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
F1 hint · Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run
@@ -1,6 +1,6 @@
---
source: src/ui.rs
assertion_line: 2967
assertion_line: 3347
expression: snapshot
---
╭ Tables ───────────────────────────────────╮ ─────────────────────────────────╮
@@ -22,8 +22,8 @@ expression: snapshot
╰───────────────────────────────────────────╯ │
╭ Relationships ────────────────────────────╮ ─────────────────────────────────╯
│Customers_Orders │ ─────────────────────────────────╮
│ Customers.id -> │ ` for a list
│ Customers.id -> │ ` for a list · `mode advanced`
│ Orders.customer_id │ │
╰───────────────────────────────────────────╯ ─────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input
@@ -1,6 +1,6 @@
---
source: src/ui.rs
assertion_line: 2385
assertion_line: 2898
expression: snapshot
---
╭ Output ──────────────────────────────────────────────────────────────────────╮
@@ -26,4 +26,4 @@ expression: snapshot
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · Backspace cancel one-shot · Ctrl-C quit
F1 hint · Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run
@@ -1,6 +1,6 @@
---
source: src/ui.rs
assertion_line: 2679
assertion_line: 3102
expression: snapshot
---
╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮
@@ -22,8 +22,8 @@ expression: snapshot
╰──────────────────────────╯│ │
╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯
│(none) │╭ Hint ──────────────────────────────────────────────────────────────────────────╮
│ ││Type a command — press Tab for options, `help` for a list
│ ││
│ ││Type a command — press Tab for options, `help` for a list · `mode advanced`
│ ││for SQL
╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
@@ -1,6 +1,6 @@
---
source: src/ui.rs
assertion_line: 2399
assertion_line: 2912
expression: snapshot
---
╭ Output ──────────────────────────────────────────────────────────────────────╮
@@ -22,8 +22,8 @@ expression: snapshot
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
╭ Hint ────────────────────────────────────────────────────────────────────────╮
│Type a command — press Tab for options, `help` for a list
│Type a command — press Tab for options, `help` for a list · `mode advanced`
for SQL
╰──────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
@@ -1,6 +1,6 @@
---
source: src/ui.rs
assertion_line: 2789
assertion_line: 3212
expression: snapshot
---
╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮
@@ -22,8 +22,8 @@ expression: snapshot
╰──────────────────────────╯│ │
╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯
│Customers_Orders │╭ Hint ──────────────────────────────────────────────────────────────────────────╮
│ Customers.id -> ││Type a command — press Tab for options, `help` for a list
│ Orders.customer_id ││
│ Customers.id -> ││Type a command — press Tab for options, `help` for a list · `mode advanced`
│ Orders.customer_id ││for SQL
╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
@@ -1,6 +1,6 @@
---
source: src/ui.rs
assertion_line: 2265
assertion_line: 2616
expression: snapshot
---
╭ Output ──────────────────────────────────────────────────╮
@@ -46,4 +46,4 @@ expression: snapshot
│with `mode advanced`, or prefix the line with `:` to run… │
╰──────────────────────────────────────────────────────────╯
Project: Term Planner
Enter submit · : advanced once · mode advanced switch ·
F1 hint · Esc clear · Ctrl-A/E home/end · Ctrl-W del w
+307 -32
View File
@@ -275,13 +275,15 @@ fn render_nav_sidebar_overlay(app: &mut App, theme: &Theme, frame: &mut Frame<'_
render_relationships_panel(app, theme, frame, parts[1]);
}
/// Border style for a sidebar panel: an accented, bold border when it
/// holds navigation focus (ADR-0046 DC3), the muted border otherwise.
/// Border style for a sidebar panel: a non-bold **accent colour**
/// border when it holds navigation focus (ADR-0046 DC3, refined by
/// Amendment 1 / issue #25), the muted border otherwise. The focus
/// cue is the accent hue, NOT `Modifier::BOLD` — bold box-drawing
/// glyphs render as broken/gapped line-art in the asciinema player
/// and are fragile in some terminals.
fn panel_border_style(theme: &Theme, focused: bool) -> Style {
if focused {
Style::default()
.fg(theme.fg)
.add_modifier(Modifier::BOLD)
Style::default().fg(theme.mode_simple)
} else {
Style::default().fg(theme.border)
}
@@ -1692,7 +1694,19 @@ fn resolve_hint_lines(
(None, Some(crate::input_render::AmbientHint::Candidates { items, selected })) => {
vec![render_candidate_line(&items, selected, inner, theme)]
}
(None, None) => prose(&crate::t!("panel.hint_empty")),
// Empty input: the base prompt, plus — in simple mode only — a
// pointer to advanced mode (ADR-0051, issue #27), since the
// `mode advanced` switch left the keybinding strip. Advanced
// mode shows no pointer: users know how they reached it, and
// `help` covers the way back. (One-shot never reaches here — its
// `:` makes the input non-empty → ambient path.)
(None, None) => {
let mut text = crate::t!("panel.hint_empty");
if matches!(app.effective_mode(), EffectiveMode::Simple) {
text.push_str(&crate::t!("panel.hint_mode_advanced"));
}
prose(&text)
}
}
}
@@ -1843,6 +1857,66 @@ fn render_candidate_line(
Line::from(spans)
}
/// The keybinding strip is keystrokes-only and **state-selected**
/// (ADR-0051, issue #27): it advertises the keys for the user's *current*
/// interaction, chosen by priority — first matching state wins.
///
/// Returns `(key, label)` pairs (label localised via `t!`); the renderer
/// is a thin span builder over this list, so the binding sets are
/// unit-testable without a `Frame`. Mode-switch / `:` advertisements
/// deliberately leave the strip — they are typed commands, not
/// keystrokes — and move to the empty-input hint (`resolve_hint_lines`).
fn status_bar_bindings(app: &App) -> Vec<(&'static str, String)> {
// 1. Sidebar focus (Ctrl-O): the input is occluded by the overlay,
// so the panel-scroll keys win outright (ADR-0046).
if app.nav_focus.in_sidebar() {
return vec![
("Ctrl-O", crate::t!("shortcut.next_pane")),
("↑↓/PgUp/PgDn", crate::t!("shortcut.scroll")),
("Esc", crate::t!("shortcut.to_input")),
];
}
// 2. A live Tab-completion memo (ADR-0022): cycling keys. Pressing
// Up clears the memo, so this never co-occurs with state 3.
if app.last_completion.is_some() {
return vec![
("Tab/Shift-Tab", crate::t!("shortcut.cycle")),
("Esc", crate::t!("shortcut.cancel")),
("Enter", crate::t!("shortcut.run")),
];
}
// 3. Browsing recalled history (unedited): browse keys. Editing the
// recalled line ends navigation, dropping to state 4.
if app.is_browsing_history() {
return vec![
("↑↓", crate::t!("shortcut.browse")),
("Esc", crate::t!("shortcut.clear")),
("Enter", crate::t!("shortcut.run")),
];
}
// 4. Editing — the input has text: F1 (the contextual hint for what
// you're typing, ADR-0053) leads, then the readline edit keys
// (ADR-0049). Ctrl-K/U remain unadvertised muscle memory.
if !app.input.is_empty() {
return vec![
("F1", crate::t!("shortcut.hint")),
("Esc", crate::t!("shortcut.clear")),
("Ctrl-A/E", crate::t!("shortcut.home_end")),
("Ctrl-W", crate::t!("shortcut.del_word")),
("Enter", crate::t!("shortcut.run")),
];
}
// 5. Default — empty input, Input focus. F1 here expands on the most
// recent error, or points the user at getting started (ADR-0053).
vec![
("Ctrl-O", crate::t!("shortcut.nav")),
("Tab", crate::t!("shortcut.complete")),
("", crate::t!("shortcut.history")),
("F1", crate::t!("shortcut.hint")),
("Enter", crate::t!("shortcut.run")),
]
}
fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let key_style = Style::default()
.fg(theme.fg)
@@ -1853,35 +1927,14 @@ fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect
let separator = Span::styled(" · ", sep_style);
let mut spans: Vec<Span<'_>> = Vec::new();
let push_shortcut = |spans: &mut Vec<Span<'_>>, key: &'static str, label: &str| {
for (key, label) in status_bar_bindings(app) {
if !spans.is_empty() {
spans.push(separator.clone());
}
spans.push(Span::styled(key, key_style));
spans.push(Span::raw(" "));
spans.push(Span::styled(label.to_string(), label_style));
};
let submit = crate::t!("shortcut.submit");
push_shortcut(&mut spans, "Enter", &submit);
let switch = crate::t!("shortcut.switch");
let advanced_once = crate::t!("shortcut.advanced_once");
let cancel_one_shot = crate::t!("shortcut.cancel_one_shot");
let quit = crate::t!("shortcut.quit");
match app.effective_mode() {
EffectiveMode::Simple => {
push_shortcut(&mut spans, ":", &advanced_once);
push_shortcut(&mut spans, "mode advanced", &switch);
}
EffectiveMode::AdvancedPersistent => {
push_shortcut(&mut spans, "mode simple", &switch);
}
EffectiveMode::AdvancedOneShot => {
push_shortcut(&mut spans, "Backspace", &cancel_one_shot);
}
spans.push(Span::styled(label, label_style));
}
push_shortcut(&mut spans, "Ctrl-C", &quit);
let paragraph = Paragraph::new(Line::from(spans)).style(bar_style);
frame.render_widget(paragraph, area);
@@ -2580,6 +2633,168 @@ mod tests {
.expect("hint bottom border present")
}
// ---- ADR-0051 (issue #27): context- and state-aware strip ----
fn key_event(code: crossterm::event::KeyCode) -> crate::event::AppEvent {
crate::event::AppEvent::Key(crossterm::event::KeyEvent::new(
code,
crossterm::event::KeyModifiers::NONE,
))
}
/// The `key` column of the strip's bindings, in order.
fn strip_keys(app: &App) -> Vec<&'static str> {
status_bar_bindings(app).into_iter().map(|(k, _)| k).collect()
}
/// The full rendered strip text (keys + labels + separators).
fn strip_text(app: &App) -> String {
status_bar_bindings(app)
.iter()
.map(|(k, l)| format!("{k} {l}"))
.collect::<Vec<_>>()
.join(" · ")
}
fn hint_text(lines: &[Line<'_>]) -> String {
lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.clone()).collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn strip_default_state_is_nav_complete_history_run() {
let app = App::new();
assert_eq!(strip_keys(&app), vec!["Ctrl-O", "Tab", "", "F1", "Enter"]);
}
#[test]
fn strip_editing_state_surfaces_readline_keys() {
// Input has text (no completion/history transient) → the #29
// editing keys (ADR-0049).
let mut app = App::new();
app.input.push_str("create ta");
assert_eq!(
strip_keys(&app),
vec!["F1", "Esc", "Ctrl-A/E", "Ctrl-W", "Enter"],
);
}
#[test]
fn strip_sidebar_focus_state_is_pane_scroll_input() {
let mut app = App::new();
app.nav_focus = NavFocus::SidebarTables;
assert_eq!(
strip_keys(&app),
vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"],
);
// ...and the relationships sidebar is the same state.
app.nav_focus = NavFocus::SidebarRelationships;
assert_eq!(strip_keys(&app), vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"]);
}
#[test]
fn strip_completion_memo_state_is_cycle_cancel_run() {
// Drive the real flow: `show ` + Tab leaves a multi-candidate
// memo (ADR-0022). The strip must win over the editing state.
let mut app = App::new();
for c in "show ".chars() {
app.update(key_event(crossterm::event::KeyCode::Char(c)));
}
app.update(key_event(crossterm::event::KeyCode::Tab));
assert!(app.last_completion.is_some(), "memo set by Tab");
assert!(!app.input.is_empty(), "input non-empty — would be editing");
assert_eq!(
strip_keys(&app),
vec!["Tab/Shift-Tab", "Esc", "Enter"],
"completion state wins over editing",
);
}
#[test]
fn strip_history_navigation_state_is_browse_clear_run() {
// Submit a command, then Up to recall it — `history_cursor` is
// set, input is the (non-empty) recalled line, no memo.
let mut app = App::new();
for c in "drop table T".chars() {
app.update(key_event(crossterm::event::KeyCode::Char(c)));
}
app.update(key_event(crossterm::event::KeyCode::Enter)); // submit
app.update(key_event(crossterm::event::KeyCode::Up)); // recall
assert!(app.is_browsing_history(), "browsing recalled history");
assert!(app.last_completion.is_none(), "no completion memo");
assert_eq!(
strip_keys(&app),
vec!["↑↓", "Esc", "Enter"],
"history state wins over editing",
);
}
#[test]
fn every_strip_state_fits_the_eighty_column_budget() {
// ADR-0051 §3: the strips are kept lean by construction — the
// longest must fit an 80-col status line, so no graceful-drop
// machinery is needed. A future over-long strip fails here.
let sidebar = {
let mut a = App::new();
a.nav_focus = NavFocus::SidebarTables;
a
};
let editing = {
let mut a = App::new();
a.input.push('x');
a
};
for app in [&App::new(), &sidebar, &editing] {
let text = strip_text(app);
assert!(
text.chars().count() <= 80,
"strip {} cols > 80: {text:?}",
text.chars().count(),
);
}
}
#[test]
fn empty_hint_advertises_advanced_mode_in_simple() {
let app = App::new();
// Wide width so the pointer never wrap-splits.
let text = hint_text(&resolve_hint_lines(&app, &Theme::dark(), 200, 3));
assert!(
text.contains("`mode advanced` for SQL"),
"simple empty hint carries the advanced pointer:\n{text}",
);
}
#[test]
fn advanced_mode_empty_hint_has_no_mode_pointer() {
// ADR-0051: advanced mode shows no mode pointer (users know how
// they got there; `help` covers the way back).
let mut app = App::new();
app.mode = Mode::Advanced;
let text = hint_text(&resolve_hint_lines(&app, &Theme::dark(), 200, 3));
assert!(
!text.contains("mode simple") && !text.contains("mode advanced"),
"advanced empty hint carries no mode pointer:\n{text}",
);
}
#[test]
fn typing_replaces_the_empty_hint_mode_pointer() {
// Non-empty input → ambient hint path, not the empty-hint
// mode pointer.
let mut app = App::new();
app.input.push_str("create table");
app.input_cursor = app.input.len();
let text = hint_text(&resolve_hint_lines(&app, &Theme::dark(), 200, 3));
assert!(
!text.contains("for SQL"),
"no mode pointer once typing:\n{text}",
);
}
#[test]
fn clamp_wrapped_truncates_with_ellipsis_past_max() {
// ≤ max rows: untouched.
@@ -3027,16 +3242,76 @@ mod tests {
#[test]
fn focused_panel_gets_an_accent_border() {
// ADR-0046 DC3: the focused sidebar panel is accent-bordered.
// ADR-0046 DC3 (Amendment 1, issue #25): the focused sidebar
// panel is marked by a non-bold accent COLOUR, not bold. Bold
// box-drawing glyphs render as broken/gapped line-art in the
// asciinema player (and are fragile in some terminals), so the
// focus cue is the accent hue against the muted unfocused
// border — never a `Modifier::BOLD` on the border.
let theme = Theme::dark();
let focused = panel_border_style(&theme, true);
let normal = panel_border_style(&theme, false);
assert_eq!(focused.fg, Some(theme.fg));
assert!(focused.add_modifier.contains(Modifier::BOLD));
assert_eq!(focused.fg, Some(theme.mode_simple));
assert!(
!focused.add_modifier.contains(Modifier::BOLD),
"the focused border must NOT be bold (issue #25)",
);
assert_eq!(normal.fg, Some(theme.border));
assert!(!normal.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn focused_panel_border_cells_are_accent_colour_not_bold() {
// Full-stack guard for issue #25: the accent colour (and the
// absence of bold) must reach the actual rendered border cells,
// not just `panel_border_style` in isolation. With the Tables
// panel focused, its box-drawing border cells carry
// `theme.mode_simple` and never `Modifier::BOLD`; with no panel
// focused, no border cell wears the accent colour.
const BOX_DRAWING: &[char] = &['╭', '╮', '╰', '╯', '─', '│'];
let is_border = |sym: &str| sym.chars().all(|c| BOX_DRAWING.contains(&c));
let theme = Theme::dark();
let mut app = App::new();
app.tables = vec!["Customers".to_string(), "Orders".to_string()];
app.nav_focus = NavFocus::SidebarTables;
let buf = render_to_buffer(&mut app, &theme, 110, 24);
let mut accent_border_cells = 0;
for y in 0..buf.area.height {
for x in 0..buf.area.width {
let cell = &buf[(x, y)];
if is_border(cell.symbol()) && cell.fg == theme.mode_simple {
accent_border_cells += 1;
assert!(
!cell.modifier.contains(Modifier::BOLD),
"focused border cell at ({x},{y}) must not be bold (issue #25)",
);
}
}
}
assert!(
accent_border_cells > 0,
"the focused Tables panel must render accent-coloured border cells",
);
// With nothing focused (Input), no border cell wears the accent.
let mut app2 = App::new();
app2.tables = vec!["Customers".to_string()];
app2.nav_focus = NavFocus::Input;
let buf2 = render_to_buffer(&mut app2, &theme, 110, 24);
for y in 0..buf2.area.height {
for x in 0..buf2.area.width {
let cell = &buf2[(x, y)];
if is_border(cell.symbol()) {
assert_ne!(
cell.fg, theme.mode_simple,
"no border cell may wear the focus accent when nothing is focused (at {x},{y})",
);
}
}
}
}
#[test]
fn focused_tables_panel_scrolls_and_clamps() {
// ADR-0046 DC3: more tables than fit → a large offset reveals the
+5 -5
View File
@@ -93,7 +93,7 @@ fn rename_column_with_case_variant_table_keeps_metadata_in_step() {
.expect("rename column via a case-variant table name");
let desc = r
.block_on(db.describe_table("Items".to_string(), None))
.block_on(db.describe_table("Items".to_string()))
.expect("describe Items");
let amount = desc
.columns
@@ -126,7 +126,7 @@ fn insert_with_case_variant_table_persists_and_survives_rebuild() {
let db = fresh_rebuild(db, &project, &r);
let rows = r
.block_on(db.query_data("Items".to_string(), None, None, None))
.block_on(db.query_data("Items".to_string(), None, None))
.expect("query")
.rows;
assert_eq!(rows.len(), 1, "the wrong-case insert survived the rebuild (no data loss)");
@@ -146,7 +146,7 @@ fn add_column_with_case_variant_table_survives_rebuild() {
);
let db = fresh_rebuild(db, &project, &r);
let desc = r.block_on(db.describe_table("Items".to_string(), None)).expect("describe");
let desc = r.block_on(db.describe_table("Items".to_string())).expect("describe");
let qty = desc.columns.iter().find(|c| c.name == "qty").expect("qty added");
assert_eq!(qty.user_type, Some(Type::Int), "qty's user-type survived the rebuild");
// The CHECK is intact too (a negative qty is refused under the real table).
@@ -224,12 +224,12 @@ fn add_relationship_with_case_variant_tables_survives_rebuild() {
add 1:n relationship from parent.id to child.parent_id\n",
);
// The parent's inbound relationship is visible under the stored case.
let p = r.block_on(db.describe_table("Parent".to_string(), None)).expect("describe Parent");
let p = r.block_on(db.describe_table("Parent".to_string())).expect("describe Parent");
assert_eq!(p.inbound_relationships.len(), 1, "relationship recorded under the stored case");
assert_eq!(p.inbound_relationships[0].other_table, "Child");
let db = fresh_rebuild(db, &project, &r);
let p = r.block_on(db.describe_table("Parent".to_string(), None)).expect("describe Parent");
let p = r.block_on(db.describe_table("Parent".to_string())).expect("describe Parent");
assert_eq!(p.inbound_relationships.len(), 1, "relationship survived the rebuild");
assert_eq!(p.inbound_relationships[0].other_table, "Child");
}
+5 -5
View File
@@ -276,7 +276,7 @@ fn compound_fk_declares_enforces_and_round_trips() {
);
// describe shows the compound endpoints symmetrically.
let city = db.describe_table("City".to_string(), None).await.unwrap();
let city = db.describe_table("City".to_string()).await.unwrap();
let outbound = &city.outbound_relationships[0];
assert_eq!(
outbound.local_columns,
@@ -329,7 +329,7 @@ fn compound_fk_create_fk_makes_both_child_columns() {
)
.await
.expect("add compound relationship with --create-fk");
let city = db.describe_table("City".to_string(), None).await.unwrap();
let city = db.describe_table("City".to_string()).await.unwrap();
for col in ["c_country", "c_code"] {
assert!(
city.columns.iter().any(|c| c.name == col),
@@ -527,7 +527,7 @@ fn compound_fk_survives_rebuild_from_text() {
.await;
assert!(bad.is_err(), "compound FK still enforced after rebuild from text");
// Endpoints survived the round-trip intact.
let city = db.describe_table("City".to_string(), None).await.unwrap();
let city = db.describe_table("City".to_string()).await.unwrap();
assert_eq!(
city.outbound_relationships[0].other_columns,
vec!["country".to_string(), "code".to_string()],
@@ -563,7 +563,7 @@ fn compound_fk_undo_removes_the_relationship() {
.await
.expect("add compound relationship");
assert_eq!(
db.describe_table("City".to_string(), None)
db.describe_table("City".to_string())
.await
.unwrap()
.outbound_relationships
@@ -573,7 +573,7 @@ fn compound_fk_undo_removes_the_relationship() {
// One undo step removes the whole relationship (ADR-0013/0006).
db.undo().await.unwrap().expect("undo applied");
assert!(
db.describe_table("City".to_string(), None)
db.describe_table("City".to_string())
.await
.unwrap()
.outbound_relationships
+8 -54
View File
@@ -15,7 +15,7 @@ use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, RowFilter, Type, Value};
use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{
self, DATA_DIR, HISTORY_LOG, PROJECT_YAML,
self, DATA_DIR, PROJECT_YAML,
};
fn tempdir() -> tempfile::TempDir {
@@ -44,11 +44,6 @@ fn open_project(
(project, db, path)
}
fn read_history(project_path: &Path) -> Vec<String> {
let body = fs::read_to_string(project_path.join(HISTORY_LOG)).unwrap_or_default();
body.lines().map(str::to_string).collect()
}
fn read_yaml(project_path: &Path) -> String {
fs::read_to_string(project_path.join(PROJECT_YAML)).expect("project.yaml")
}
@@ -82,9 +77,9 @@ fn create_table_writes_yaml_and_history() {
assert!(yaml.contains("type: serial"), "yaml: {yaml}");
assert!(yaml.contains("type: text"), "yaml: {yaml}");
let history = read_history(&path);
assert_eq!(history.len(), 1, "expected one history line; got {history:?}");
assert!(history[0].ends_with("|ok|create table Customers with pk id(serial)"));
// ADR-0052: journaling moved to the dispatch layer (the worker no
// longer writes history.log); this test verifies only the yaml state.
// Journaling is covered by the history.rs/app.rs/replay tests.
}
#[test]
@@ -119,11 +114,8 @@ fn insert_writes_csv_and_history() {
assert_eq!(lines[0], "id,Name");
assert_eq!(lines[1], "1,Alice");
let history = read_history(&path);
assert!(
history.iter().any(|l| l.ends_with("|ok|insert into Customers ('Alice')")),
"history missing insert: {history:?}",
);
// ADR-0052: journaling moved off the worker; this test verifies the
// csv state only (journaling covered elsewhere).
}
#[test]
@@ -322,39 +314,6 @@ fn delete_all_rows_removes_csv() {
);
}
#[test]
fn show_table_appends_history_only() {
let data = tempdir();
let (_p, db, path) = open_project(&data);
rt().block_on(async {
db.create_table(
"Customers".to_string(),
vec![ColumnSpec::new("id".to_string(), Type::Serial)],
vec!["id".to_string()],
Some("create table Customers with pk id(serial)".to_string()),
)
.await
.unwrap();
let yaml_before = read_yaml(&path);
db.describe_table(
"Customers".to_string(),
Some("show table Customers".to_string()),
)
.await
.unwrap();
let yaml_after = read_yaml(&path);
// YAML body did not change for a read-only command.
assert_eq!(yaml_before, yaml_after);
});
let history = read_history(&path);
assert!(
history.iter().any(|l| l.ends_with("|ok|show table Customers")),
"history missing show entry: {history:?}",
);
}
#[test]
fn failed_command_does_not_append_history_or_change_yaml() {
let data = tempdir();
@@ -387,13 +346,8 @@ fn failed_command_does_not_append_history_or_change_yaml() {
assert_eq!(yaml_before, yaml_after, "failed cmd must not change yaml");
});
let history = read_history(&path);
// Only the first (successful) create_table should have logged.
let create_count = history
.iter()
.filter(|l| l.contains("|ok|create table Customers"))
.count();
assert_eq!(create_count, 1, "expected exactly one logged create; got: {history:?}");
// ADR-0052: journaling moved off the worker; this test now verifies
// only that a failed command does not change the yaml state.
}
#[test]
+4 -4
View File
@@ -76,7 +76,7 @@ fn rebuild_restores_schema_only_project() {
// Phase 4: confirm Customers exists with the right shape.
let desc = rt()
.block_on(async { db.describe_table("Customers".to_string(), None).await })
.block_on(async { db.describe_table("Customers".to_string()).await })
.expect("describe_table");
assert_eq!(desc.name, "Customers");
let cols: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect();
@@ -143,7 +143,7 @@ fn rebuild_restores_rows_from_csv() {
});
let rows = rt()
.block_on(async { db.query_data("Customers".to_string(), None, None, None).await })
.block_on(async { db.query_data("Customers".to_string(), None, None).await })
.expect("query_data");
assert_eq!(rows.rows.len(), 2);
let names: Vec<Option<String>> = rows.rows.iter().map(|r| r[1].clone()).collect();
@@ -371,7 +371,7 @@ fn rebuild_preserves_created_at_from_yaml() {
// Trigger any successful command so project.yaml is
// rewritten from the now-rebuilt db state.
rt().block_on(async {
db.describe_table("T".to_string(), Some("show table T".to_string()))
db.describe_table("T".to_string())
.await
.unwrap();
// describe is read-only; force a rewrite by adding a column.
@@ -451,7 +451,7 @@ fn rebuild_restores_indexes() {
});
let desc = rt()
.block_on(async { db.describe_table("Customers".to_string(), None).await })
.block_on(async { db.describe_table("Customers".to_string()).await })
.expect("describe_table");
assert_eq!(desc.indexes.len(), 1, "index should survive rebuild");
assert_eq!(desc.indexes[0].name, "idx_email");
+4 -7
View File
@@ -173,15 +173,12 @@ fn rebuild_against_populated_db_wipes_and_reloads() {
.expect("rebuild");
});
let rows = rt()
.block_on(async { db.query_data("Customers".to_string(), None, None, None).await })
.block_on(async { db.query_data("Customers".to_string(), None, None).await })
.unwrap();
assert_eq!(rows.rows.len(), 1);
assert_eq!(rows.rows[0][1].as_deref(), Some("Edna"));
// history.log should contain the rebuild entry.
let history = fs::read_to_string(project_path.join("history.log")).unwrap();
assert!(
history.lines().any(|l| l.ends_with("|ok|rebuild")),
"history.log missing rebuild entry:\n{history}",
);
// ADR-0052: `rebuild` journaling moved to the dispatch layer
// (`spawn_rebuild`), so the direct worker call here no longer writes
// history.log; this test verifies the wipe/reload behaviour only.
}
+1 -1
View File
@@ -362,7 +362,7 @@ fn end_to_end_export_then_import_real_project() {
// Round-trip: the inserted row is back.
let data_view = rt()
.block_on(async { imported_db.query_data("Customers".to_string(), None, None, None).await })
.block_on(async { imported_db.query_data("Customers".to_string(), None, None).await })
.expect("query data");
assert_eq!(data_view.rows.len(), 1);
// Serial id auto-filled to 1; Name was the inserted value.
+32 -6
View File
@@ -141,9 +141,9 @@ fn read_recent_history_returns_appended_entries_in_order() {
let tmp = tempdir();
let project = Project::create_temp(tmp.path()).unwrap();
let p = Persistence::new(project.path().to_path_buf());
p.append_history("create table A with pk").unwrap();
p.append_history("create table B with pk").unwrap();
p.append_history("create table C with pk").unwrap();
p.append_history("create table A with pk", false).unwrap();
p.append_history("create table B with pk", false).unwrap();
p.append_history("create table C with pk", false).unwrap();
let entries = p.read_recent_history(10).unwrap();
assert_eq!(
entries,
@@ -165,9 +165,9 @@ fn hydration_reads_both_ok_and_err_records() {
let tmp = tempdir();
let project = Project::create_temp(tmp.path()).unwrap();
let p = Persistence::new(project.path().to_path_buf());
p.append_history("create table A with pk").unwrap();
p.append_history_failure("insert into A (1, 2, 3)").unwrap();
p.append_history("show data A").unwrap();
p.append_history("create table A with pk", false).unwrap();
p.append_history_failure("insert into A (1, 2, 3)", false).unwrap();
p.append_history("show data A", false).unwrap();
let entries = p.read_recent_history(10).unwrap();
assert_eq!(
entries,
@@ -194,6 +194,32 @@ fn seed_history_replaces_in_memory_history() {
assert_eq!(app.history, vec!["x".to_string(), "y".to_string()]);
}
#[test]
fn advanced_command_journalled_then_hydrated_recalls_with_colon_in_simple() {
// ADR-0052 (issue #30) — the headline cross-session regression: an
// advanced command journalled `ok:adv`, then hydrated on a fresh
// session, recalls WITH its `:` so it re-runs in simple mode. (Before
// the fix, the `:` was lost on disk and the command came back bare.)
let tmp = tempdir();
let project = Project::create_temp(tmp.path()).unwrap();
let p = Persistence::new(project.path().to_path_buf());
// The dispatch layer journals the canonical source + advanced flag.
p.append_history("select * from T", true).unwrap();
p.append_history("create table T with pk", false).unwrap();
// Fresh session: hydrate the ring from disk.
let entries = p.read_recent_history(10).unwrap();
let mut app = App::new();
app.seed_history(entries);
// In simple mode the simple command recalls bare, the advanced one
// recalls `:`-prefixed (runnable via the one-shot escape).
app.update(key(KeyCode::Up));
assert_eq!(app.input, "create table T with pk");
app.update(key(KeyCode::Up));
assert_eq!(app.input, ": select * from T");
}
#[test]
fn seed_history_preserves_chronological_order_for_navigation() {
let mut app = App::new();
+6 -6
View File
@@ -107,7 +107,7 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() {
assert!(tables.contains(&"Students_Courses".to_string()), "tables: {tables:?}");
// Two FK columns, both part of the compound PK.
let desc = db.describe_table("Students_Courses".to_string(), None).await.unwrap();
let desc = db.describe_table("Students_Courses".to_string()).await.unwrap();
let cols: Vec<(&str, bool)> =
desc.columns.iter().map(|c| (c.name.as_str(), c.primary_key)).collect();
assert_eq!(
@@ -191,7 +191,7 @@ fn compound_parent_pk_contributes_one_fk_column_each() {
.await
.expect("create m:n");
let desc = db.describe_table("Students_Sections".to_string(), None).await.unwrap();
let desc = db.describe_table("Students_Sections".to_string()).await.unwrap();
let names: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect();
assert_eq!(names, vec!["Students_id", "Sections_course_id", "Sections_term"]);
// All three form the compound PK.
@@ -221,7 +221,7 @@ fn deleting_a_parent_cascades_to_the_junction() {
// Deleting the student cascades to the junction (ON DELETE CASCADE).
db.delete("Students".to_string(), RowFilter::AllRows, None).await.unwrap();
let rows = db.query_data("Students_Courses".to_string(), None, None, None).await.unwrap();
let rows = db.query_data("Students_Courses".to_string(), None, None).await.unwrap();
assert!(rows.rows.is_empty(), "junction rows should cascade-delete, got {:?}", rows.rows);
});
}
@@ -249,7 +249,7 @@ fn create_m2n_is_one_undo_step() {
let tables = db.list_tables().await.unwrap();
assert!(!tables.contains(&"Students_Courses".to_string()), "undo should remove the junction: {tables:?}");
// The parents' relationships are gone too (the junction held them).
let students = db.describe_table("Students".to_string(), None).await.unwrap();
let students = db.describe_table("Students".to_string()).await.unwrap();
assert!(students.inbound_relationships.is_empty(), "no leftover relationship after undo");
});
}
@@ -321,7 +321,7 @@ fn the_junction_can_be_renamed() {
assert!(tables.contains(&"Enrollments".to_string()), "tables: {tables:?}");
assert!(!tables.contains(&"Students_Courses".to_string()));
// Both relationships survive the rename (rebuild-preserving).
let desc = db.describe_table("Enrollments".to_string(), None).await.unwrap();
let desc = db.describe_table("Enrollments".to_string()).await.unwrap();
assert_eq!(desc.outbound_relationships.len(), 2, "FKs preserved across rename");
});
}
@@ -362,7 +362,7 @@ fn junction_survives_save_and_rebuild() {
db.rebuild_from_text(project.path().to_path_buf(), None).await.expect("rebuild");
let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Students_Courses".to_string()), "junction survived: {tables:?}");
let desc = db.describe_table("Students_Courses".to_string(), None).await.unwrap();
let desc = db.describe_table("Students_Courses".to_string()).await.unwrap();
assert_eq!(desc.outbound_relationships.len(), 2, "both FKs reconstructed");
assert!(desc.columns.iter().all(|c| c.primary_key), "compound PK reconstructed");
});
+9 -9
View File
@@ -108,13 +108,13 @@ fn replay_runs_advanced_sql_create_table_as_a_write() {
// The SQL DDL line actually created the structural table…
let desc = rt()
.block_on(async { db.describe_table("Widget".to_string(), None).await })
.block_on(async { db.describe_table("Widget".to_string()).await })
.expect("describe");
let names: Vec<String> = desc.columns.iter().map(|c| c.name.clone()).collect();
assert_eq!(names, vec!["id".to_string(), "name".to_string()]);
// …and the following insert (serial id auto-filled) ran against it.
let rows = rt()
.block_on(async { db.query_data("Widget".to_string(), None, None, None).await })
.block_on(async { db.query_data("Widget".to_string(), None, None).await })
.expect("query")
.rows;
assert_eq!(rows.len(), 1);
@@ -139,7 +139,7 @@ fn replay_three_lines_dispatches_three_commands() {
// The dispatched commands actually mutated state.
let data_result = rt()
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
.block_on(async { db.query_data("T".to_string(), None, None).await })
.expect("query_data");
assert_eq!(data_result.rows.len(), 1, "row inserted");
assert_eq!(data_result.rows[0][1].as_deref(), Some("Alice"));
@@ -174,7 +174,7 @@ fn replay_of_actual_history_log_runs_ok_commands_and_skips_err() {
assert_completed(&events, 3);
let data_result = rt()
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
.block_on(async { db.query_data("T".to_string(), None, None).await })
.expect("query_data");
assert_eq!(data_result.rows.len(), 1, "only the ok INSERT applied");
assert_eq!(data_result.rows[0][1].as_deref(), Some("alpha"));
@@ -227,7 +227,7 @@ fn replay_skips_app_lifecycle_commands_silently() {
other => panic!("expected ReplayCompleted, got {other:?}"),
}
let data_result = rt()
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
.block_on(async { db.query_data("T".to_string(), None, None).await })
.expect("query_data");
assert!(
data_result.columns.iter().any(|c| c == "v"),
@@ -401,14 +401,14 @@ fn replay_aborts_on_first_parse_failure_and_reports_line() {
// but earlier commands stayed applied (table T exists with
// the `name` column).
let desc = rt()
.block_on(async { db.describe_table("T".to_string(), None).await })
.block_on(async { db.describe_table("T".to_string()).await })
.expect("describe_table");
assert!(
desc.columns.iter().any(|c| c.name == "name"),
"earlier add column should have stayed applied"
);
let data_result = rt()
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
.block_on(async { db.query_data("T".to_string(), None, None).await })
.expect("query_data");
assert!(
data_result.rows.is_empty(),
@@ -467,7 +467,7 @@ fn replay_rejects_wrong_type_value_in_a_hand_built_script() {
// The earlier two lines stayed applied; the failing insert
// did not run — state is intact.
let data_result = rt()
.block_on(async { db.query_data("T".to_string(), None, None, None).await })
.block_on(async { db.query_data("T".to_string(), None, None).await })
.expect("query_data");
assert!(
data_result.rows.is_empty(),
@@ -527,7 +527,7 @@ fn replay_skips_nested_replay_with_a_warning() {
other => panic!("expected ReplayCompleted (nested replay skipped), got {other:?}"),
}
// The nested file's table was NOT created (the replay was skipped).
let cols = rt().block_on(async { db.query_data("T".to_string(), None, None, None).await });
let cols = rt().block_on(async { db.query_data("T".to_string(), None, None).await });
assert!(cols.is_err(), "inner.commands' table T must not exist (nested replay skipped)");
}
+117 -18
View File
@@ -281,6 +281,123 @@ fn seed_populates_a_table_and_persists_rows() {
assert!(csv.contains('@'), "seeded emails should appear in the CSV:\n{csv}");
}
/// Parse a seeded table's CSV into per-column value lists (simple
/// comma-split — the values under test carry no commas/quotes).
fn csv_columns(csv: &str) -> (Vec<String>, Vec<Vec<String>>) {
let mut lines = csv.lines().filter(|l| !l.trim().is_empty());
let header: Vec<String> = lines.next().unwrap().split(',').map(str::to_string).collect();
let rows: Vec<Vec<String>> =
lines.map(|l| l.split(',').map(str::to_string).collect()).collect();
(header, rows)
}
fn column_values(csv: &str, col: &str) -> Vec<String> {
let (header, rows) = csv_columns(csv);
let idx = header.iter().position(|h| h == col).expect("column present");
rows.iter().map(|r| r[idx].clone()).collect()
}
#[test]
fn seed_year_and_choice_set_heuristics() {
// Issues #33 (year-like int columns) + #34 (conventional choice
// sets). A fixed `--seed` makes the values deterministic; we assert
// membership in the bounded windows / value sets rather than exact
// strings (robust to RNG-internals changes, still proves the
// heuristic fired — the type fallback would produce 9419 / lorem).
let (project, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(db.create_table(
"Records".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("birth_year", Type::Int),
ColumnSpec::new("published", Type::Int),
ColumnSpec::new("priority", Type::Text),
ColumnSpec::new("severity", Type::Text),
ColumnSpec::new("rating", Type::Int),
],
vec!["id".to_string()],
None,
))
.expect("create Records");
rt.block_on(db.seed("Records".into(), None, Some(30), Vec::new(), Some(99), Some("seed Records 30".into())))
.expect("seed succeeds");
let csv = read_csv(&project, "Records").expect("Records CSV exists");
for y in column_values(&csv, "birth_year") {
let n: i32 = y.parse().expect("birth_year is an int");
assert!((1945..=2007).contains(&n), "birth_year {n} must be a plausible birth year");
}
for y in column_values(&csv, "published") {
let n: i32 = y.parse().expect("published is an int");
assert!((1950..=2025).contains(&n), "published {n} must be a plausible recent year");
}
for p in column_values(&csv, "priority") {
assert!(["low", "medium", "high"].contains(&p.as_str()), "priority `{p}` must be low/medium/high");
}
for s in column_values(&csv, "severity") {
assert!(
["low", "medium", "high", "critical"].contains(&s.as_str()),
"severity `{s}` must be low/medium/high/critical",
);
}
for r in column_values(&csv, "rating") {
let n: i32 = r.parse().expect("rating is an int");
assert!((1..=5).contains(&n), "rating {n} must be 15");
}
}
#[test]
fn seed_column_fill_uses_choice_set_heuristic() {
// The `seed <table>.<column>` column-fill path (an UPDATE over
// existing rows) shares `choose_generator`, so issue #34's value
// sets apply there too. Insert rows with `priority` left NULL, then
// fill just that column and confirm it collapses to the set.
let (project, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(db.create_table(
"Tasks".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("title", Type::Text),
ColumnSpec::new("priority", Type::Text),
],
vec!["id".to_string()],
None,
))
.expect("create Tasks");
for t in ["a", "b", "c", "d"] {
rt.block_on(db.insert(
"Tasks".to_string(),
Some(vec!["title".to_string()]),
vec![Value::Text(t.to_string())],
None,
))
.expect("insert row");
}
rt.block_on(db.seed(
"Tasks".into(),
Some("priority".into()),
None,
Vec::new(),
Some(5),
Some("seed Tasks.priority".into()),
))
.expect("column-fill priority");
let csv = read_csv(&project, "Tasks").expect("Tasks CSV");
let priorities = column_values(&csv, "priority");
assert_eq!(priorities.len(), 4, "every existing row is filled:\n{csv}");
for p in priorities {
assert!(
["low", "medium", "high"].contains(&p.as_str()),
"column-fill priority `{p}` must be low/medium/high",
);
}
}
#[test]
fn seed_count_defaults_to_twenty() {
let (project, db, _dir) = open_project_db();
@@ -313,24 +430,6 @@ fn seed_is_reproducible_with_a_fixed_seed() {
assert_eq!(csv1, csv2, "the same --seed must reproduce identical data");
}
#[test]
fn seed_writes_exactly_one_history_line() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_people(&db, &rt);
rt.block_on(db.seed("People".into(), None, Some(5), Vec::new(), Some(1), Some("seed People 5".into())))
.expect("seed succeeds");
let history = std::fs::read_to_string(project.path().join("history.log"))
.expect("history.log exists");
let seed_lines = history.lines().filter(|l| l.contains("seed People 5")).count();
assert_eq!(
seed_lines, 1,
"a seed of 5 rows must write exactly one history line:\n{history}"
);
}
// — FK sampling, empty-parent error, block guard (ADR-0048 D14 / D1) —
/// `Users(id serial pk, name text)` + `Orders(id serial pk, user_id
+1 -1
View File
@@ -462,7 +462,7 @@ fn app_show_table_renders_relationships_as_compact_diagrams() {
rt.block_on(seed_schema(&db));
// Orders holds the FK to Customers — an outbound relationship.
let desc = rt
.block_on(db.describe_table("Orders".to_string(), None))
.block_on(db.describe_table("Orders".to_string()))
.expect("describe Orders");
let mut app = App::new();
+17 -17
View File
@@ -111,7 +111,7 @@ fn e2e_alter_drop_compound_primary_key_member_is_refused() {
/// The current user-facing type of column `name` in table `T`.
fn col_type(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option<Type> {
r.block_on(db.describe_table("T".to_string(), None))
r.block_on(db.describe_table("T".to_string()))
.expect("describe")
.columns
.into_iter()
@@ -120,7 +120,7 @@ fn col_type(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option<Ty
}
fn column_names(db: &Database, r: &tokio::runtime::Runtime) -> Vec<String> {
r.block_on(db.describe_table("T".to_string(), None))
r.block_on(db.describe_table("T".to_string()))
.expect("describe")
.columns
.into_iter()
@@ -163,7 +163,7 @@ fn e2e_alter_table_add_rename_drop_and_raw_default_check() {
// The DEFAULT backfilled the pre-existing row to qty = 0.
let rows = r
.block_on(db.query_data("T".to_string(), None, None, None))
.block_on(db.query_data("T".to_string(), None, None))
.expect("query")
.rows;
assert_eq!(rows.len(), 1);
@@ -252,7 +252,7 @@ fn e2e_alter_column_type_clean_and_lossy_convert() {
}
let rows = r
.block_on(db.query_data("T".to_string(), None, None, None))
.block_on(db.query_data("T".to_string(), None, None))
.expect("query")
.rows;
assert_eq!(rows.len(), 1);
@@ -292,7 +292,7 @@ fn e2e_alter_column_type_int_to_serial_is_allowed() {
}
assert_eq!(col_type(&db, &r, "n"), Some(Type::Serial), "int→serial converted the column");
let rows = r
.block_on(db.query_data("T".to_string(), None, None, None))
.block_on(db.query_data("T".to_string(), None, None))
.expect("query")
.rows;
assert_eq!(rows[0][1].as_deref(), Some("100"), "the existing value is preserved");
@@ -635,7 +635,7 @@ fn e2e_drop_composite_unique_is_one_undo_step() {
.expect("write");
r.block_on(run_replay(&db, project.path(), "u.commands"));
let has_unique = || {
!r.block_on(db.describe_table("T".to_string(), None))
!r.block_on(db.describe_table("T".to_string()))
.expect("describe")
.unique_constraints
.is_empty()
@@ -878,7 +878,7 @@ fn e2e_describe_shows_table_level_constraints() {
"events: {events:?}"
);
let desc = r.block_on(db.describe_table("T".to_string(), None)).expect("describe");
let desc = r.block_on(db.describe_table("T".to_string())).expect("describe");
assert_eq!(
desc.unique_constraints,
vec![vec!["a".to_string(), "b".to_string()]],
@@ -976,7 +976,7 @@ fn e2e_rename_table_with_rows_csv_follows_and_survives_rebuild() {
assert!(!csv_path(&project, "Orders").exists(), "data/Orders.csv removed");
let rows = r
.block_on(db.query_data("Purchases".to_string(), None, None, None))
.block_on(db.query_data("Purchases".to_string(), None, None))
.expect("query")
.rows;
assert_eq!(rows.len(), 2);
@@ -991,7 +991,7 @@ fn e2e_rename_table_with_rows_csv_follows_and_survives_rebuild() {
"Purchases round-tripped through a fresh rebuild: {tables:?}"
);
let rows = r
.block_on(db.query_data("Purchases".to_string(), None, None, None))
.block_on(db.query_data("Purchases".to_string(), None, None))
.expect("query")
.rows;
assert_eq!(rows.len(), 2);
@@ -1077,7 +1077,7 @@ fn e2e_rename_fk_parent_updates_metadata_and_still_enforces() {
);
// The child's outbound relationship now points at the new parent name.
let c = r.block_on(db.describe_table("C".to_string(), None)).expect("describe C");
let c = r.block_on(db.describe_table("C".to_string())).expect("describe C");
assert_eq!(c.outbound_relationships.len(), 1);
assert_eq!(c.outbound_relationships[0].other_table, "Parent");
@@ -1129,7 +1129,7 @@ fn e2e_rename_fk_child_updates_metadata_and_still_enforces() {
);
// The parent's inbound relationship now names the renamed child.
let p = r.block_on(db.describe_table("P".to_string(), None)).expect("describe P");
let p = r.block_on(db.describe_table("P".to_string())).expect("describe P");
assert_eq!(p.inbound_relationships.len(), 1);
assert_eq!(p.inbound_relationships[0].other_table, "Child");
@@ -1168,7 +1168,7 @@ fn e2e_rename_self_referential_table_updates_both_ends() {
);
// Both ends of the self-reference now name `Tree`.
let t = r.block_on(db.describe_table("Tree".to_string(), None)).expect("describe Tree");
let t = r.block_on(db.describe_table("Tree".to_string())).expect("describe Tree");
assert_eq!(t.outbound_relationships[0].other_table, "Tree");
assert_eq!(t.inbound_relationships[0].other_table, "Tree");
@@ -1216,7 +1216,7 @@ fn e2e_rename_table_keeps_its_index_with_a_stale_name() {
"events: {events:?}"
);
let u = r.block_on(db.describe_table("Users".to_string(), None)).expect("describe Users");
let u = r.block_on(db.describe_table("Users".to_string())).expect("describe Users");
assert_eq!(u.indexes.len(), 1, "the index followed the rename");
assert_eq!(
u.indexes[0].name, "T_email_idx",
@@ -1226,7 +1226,7 @@ fn e2e_rename_table_keeps_its_index_with_a_stale_name() {
// Survives a fresh rebuild (recreated from IndexSchema on table Users).
let db = fresh_rebuild(db, &project, &r);
let u = r.block_on(db.describe_table("Users".to_string(), None)).expect("describe Users");
let u = r.block_on(db.describe_table("Users".to_string())).expect("describe Users");
assert_eq!(u.indexes.len(), 1);
assert_eq!(u.indexes[0].name, "T_email_idx");
}
@@ -1255,7 +1255,7 @@ fn e2e_rename_table_is_one_undo_step() {
"undo restored the old table name: {tables:?}"
);
assert_eq!(
r.block_on(db.query_data("Orders".to_string(), None, None, None)).expect("query").rows.len(),
r.block_on(db.query_data("Orders".to_string(), None, None)).expect("query").rows.len(),
1,
"the row is back under the old name"
);
@@ -1427,7 +1427,7 @@ fn e2e_alter_column_set_default_applies() {
))
.expect("insert omitting qty");
let rows = r
.block_on(db.query_data("T".to_string(), None, None, None))
.block_on(db.query_data("T".to_string(), None, None))
.expect("query")
.rows;
assert_eq!(
@@ -1473,7 +1473,7 @@ fn e2e_alter_column_drop_default_removes_it() {
))
.expect("insert omitting qty");
let rows = r
.block_on(db.query_data("T".to_string(), None, None, None))
.block_on(db.query_data("T".to_string(), None, None))
.expect("query")
.rows;
assert_eq!(
+4 -4
View File
@@ -55,7 +55,7 @@ fn insert_row(db: &Database, r: &tokio::runtime::Runtime, id: i64, email: &str)
}
fn index(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option<(Vec<String>, bool)> {
r.block_on(db.describe_table("T".to_string(), None))
r.block_on(db.describe_table("T".to_string()))
.expect("describe")
.indexes
.into_iter()
@@ -141,7 +141,7 @@ fn create_unique_index_on_duplicate_data_is_refused() {
#[test]
fn if_not_exists_on_an_existing_name_is_a_noop_and_journalled() {
let (p, db, _d) = open(false);
let (_p, db, _d) = open(false);
let r = rt();
make_t(&db, &r);
r.block_on(db.sql_create_index(
@@ -169,8 +169,8 @@ fn if_not_exists_on_an_existing_name_is_a_noop_and_journalled() {
CreateIndexOutcome::Skipped(name) => assert_eq!(name, "ix"),
CreateIndexOutcome::Created(_) => panic!("expected Skipped, got Created"),
}
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log");
assert!(log.contains(line), "the skipped create should be journalled; log:\n{log}");
// ADR-0052: journaling moved to the dispatch layer; this test now
// asserts only the no-op `Skipped` outcome.
}
#[test]

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