Compare commits
31 Commits
ae73a4be85
...
47a08166a4
| Author | SHA1 | Date | |
|---|---|---|---|
| 47a08166a4 | |||
| 6429b56443 | |||
| 4bdfce6250 | |||
| 138e766817 | |||
| aeb92f56a7 | |||
| 4a5fd1b5c1 | |||
| 050b36391e | |||
| 9868442889 | |||
| 309d2e0b3f | |||
| e16ad50aa7 | |||
| 60dbb903cc | |||
| 9a126782f1 | |||
| 4d004f5847 | |||
| d5fb47bcc8 | |||
| 0878c6df19 | |||
| 52815f1a76 | |||
| 2721bd8d04 | |||
| e8fa859ab9 | |||
| 5869eec4f4 | |||
| 298475b326 | |||
| 04ebd83f08 | |||
| 18d08642d7 | |||
| da8bfebc36 | |||
| 89b9392c25 | |||
| bba24120f1 | |||
| 88145225cc | |||
| 8e3208528e | |||
| 9d8161218a | |||
| dc63ed66f1 | |||
| c7ac0c9877 | |||
| 9189740028 |
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
!<arch>
|
||||
@@ -189,9 +189,18 @@ over keeping journaling coupled in the worker (which would have needed the
|
||||
no-op-skip / read-only sites no longer journal; success is journalled at
|
||||
the dispatch layer (`spawn_dsl_dispatch` / `run_replay` / app-command
|
||||
sites). The ring stays `Vec<String>`; `seed_history` / `ProjectSwitched`
|
||||
are untouched. The vestigial worker `source` plumbing (the `_source`
|
||||
param on `finalize_persistence` / `do_rebuild_from_text` and the thin
|
||||
read-only `*_request` wrappers) is left in place — a clean follow-up.
|
||||
are untouched. The vestigial worker `source` plumbing has since been
|
||||
**fully unwound** (2026-06-14 follow-up): `_source` removed from
|
||||
`finalize_persistence` / `do_rebuild_from_text`; the three read-only
|
||||
`*_request` wrappers inlined and deleted; and — because the cascade ran
|
||||
deeper than first estimated — the now-dead `source` param dropped from
|
||||
the ~30 worker handlers (leaf + composite) that only forwarded it, plus
|
||||
the `source` field removed from the `DescribeTable` / `QueryData` /
|
||||
`RunSelect` requests and the matching `DatabaseHandle` method parameters
|
||||
(the ~164 call-site churn was mostly tests). The only `source` left in
|
||||
the worker is the snapshot/undo label (`snapshot_then` /
|
||||
`stage_pre_mutation` / `begin_batch`), passed at the match-arm level.
|
||||
Purely mechanical, compiler-guided, no behaviour change.
|
||||
- **App commands recall bare.** Because they are dispatched outside the
|
||||
`ExecuteDsl`/spawn path, app commands journal **simple** (`advanced =
|
||||
false`) at their own sites, and `submit` excludes them from the ring's
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
# ADR-0053: Contextual `hint` — F1 live-input keybinding + `hint` command, with a tier-3 teaching corpus (H2)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted — implementation in progress. Revised after a `/runda` review
|
||||
(2026-06-14): corrected the verbosity-default fact; re-keyed tier-3
|
||||
content off `help_id`; split the pre-submit-diagnostic and runtime-error
|
||||
paths; added a comprehensiveness coverage test. Revised again during
|
||||
Phase B implementation (2026-06-15): the first exemplar showed per-*node*
|
||||
keying is too coarse for multi-form commands (`add`/`drop`/`show`/
|
||||
`create`), so D3 now keys tier-3 content **per form** via a
|
||||
`hint_ids: &[&str]` array mirroring `usage_ids` — and **clause-concept
|
||||
hints** are recorded as a deferred extension (separate tracking issue).
|
||||
The parallel question of whether the in-app `help` command should
|
||||
likewise distinguish advanced-SQL forms is tracked **separately** as
|
||||
Gitea issue #36 (it touches shipped, ADR-backed `help` behaviour).
|
||||
|
||||
Decided in conversation 2026-06-14. Closes the last open piece of **A1**
|
||||
(the canonical app-command set, ADR-0003): every app command is
|
||||
implemented except `hint`, which ADR-0003's command table listed as
|
||||
*"Request a hint for the current input (ADR pending)."* This ADR is that
|
||||
pending decision. Tracked as **H2** in `docs/requirements.md`.
|
||||
|
||||
References ADR-0003 (app-command set + the `:` escape), ADR-0019 (the
|
||||
friendly error layer / H1), ADR-0021 (per-command usage templates / H1a),
|
||||
ADR-0022 (ambient typing assistance — colour + hint panel + completion),
|
||||
ADR-0027 (input validity indicator), ADR-0046 (sidebar navigation +
|
||||
responsive input hint), ADR-0049 (input-field readline keymap), and
|
||||
ADR-0051 (context/state-aware keybinding strip).
|
||||
|
||||
## Context
|
||||
|
||||
`hint` is the only unbuilt app command. The naive reading — "show a hint" —
|
||||
hides a real subtlety, and a real cost.
|
||||
|
||||
**The subtlety: a submitted `hint` command cannot see live input.** App
|
||||
commands are submitted with Enter, which empties the input buffer. By the
|
||||
time `hint` dispatches, the partial command it was meant to help with is
|
||||
gone. So "a hint for the current input" cannot be served by a submitted
|
||||
command alone — it needs a *keybinding* that acts on the live buffer
|
||||
without submitting. ADR-0003 said "current input"; `requirements.md`
|
||||
broadened it to "current input **or the most recent error**." Both are
|
||||
wanted; they map to two different trigger surfaces.
|
||||
|
||||
**The cost: the value of `hint` is content, not plumbing.** The app
|
||||
already carries two tiers of contextual text:
|
||||
|
||||
- **Tier 1** — terse, always-on: syntax colour (ADR-0022); the error
|
||||
*headline* alone (ADR-0019, when `messages_verbosity: Short`).
|
||||
- **Tier 2** — short contextual lines: the ambient typing prose /
|
||||
`expected` set, shown live while typing (ADR-0022, catalogue
|
||||
`hint.ambient_*` / `hint.value_slot_*`); and the error `hint:` field —
|
||||
which, because `Verbosity::Verbose` is the **default**
|
||||
(`src/friendly/translate.rs:46`), is shown **by default** beneath every
|
||||
error headline (`messages short` is the opt-*out*, not `messages
|
||||
verbose` the opt-in).
|
||||
|
||||
So the verbose error hint is **already on screen by default**. If `hint`
|
||||
merely re-showed it, it would duplicate what the user can already see (and
|
||||
the ambient panel). To justify itself, `hint` must add a **tier 3**: a
|
||||
genuinely deeper, *teaching*-grade explanation — what the command/error
|
||||
means, a worked example, and the underlying relational concept. That
|
||||
corpus does not exist yet, and
|
||||
authoring it (to the standard of a teaching tool, where "pedagogy wins
|
||||
ties") is the bulk of the work.
|
||||
|
||||
The mechanism is small and reuses everything already present: the command
|
||||
REGISTRY (`src/dsl/grammar/mod.rs`), the `AppCommand` enum
|
||||
(`src/dsl/command.rs`), key dispatch (`App::handle_key`,
|
||||
`src/app.rs:1155`), the `note_help`/`note_help_topic` renderers
|
||||
(`src/app.rs:2982`/`3021`), the parser/walker expected-set
|
||||
(`ParseError.expected`, `WalkResult.tail_expected`), the friendly
|
||||
catalogue + `t!` macro + `keys.rs` validation, and the output styling
|
||||
vocabulary (`OutputStyleClass::Hint`).
|
||||
|
||||
## Decision
|
||||
|
||||
### D1 — Two surfaces, no topic argument
|
||||
|
||||
`hint` is delivered through **two complementary surfaces**:
|
||||
|
||||
1. **F1 keybinding → live input.** Pressing **F1** while typing renders a
|
||||
tier-3 hint for the command currently in the buffer, into the output
|
||||
panel, **without submitting or altering the buffer**. This is the
|
||||
primary, most-valuable path (it serves the literal "current input").
|
||||
2. **`hint` command → most recent error.** Submitting `hint` renders the
|
||||
tier-3 expansion of the most recent error. This is why the command
|
||||
exists despite the empty-buffer problem: the thing it helps with is
|
||||
the *last thing you tried*, not the now-empty buffer.
|
||||
|
||||
`hint` takes **no topic argument**. Explicit per-command reference is
|
||||
already `help <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, plus the live "expected next" (from the walker's `tail_expected` / parser `expected`) |
|
||||
| **F1** | empty input, a recent error exists | tier-3 expansion of that error |
|
||||
| **F1** | empty input, no recent error | a short "getting started" pointer (press F1 while typing a command; `help` for the full list) |
|
||||
| **`hint`** (submitted) | a recent error exists | tier-3 expansion of that error (primary use) |
|
||||
| **`hint`** (submitted) | no recent error | the same "getting started" pointer |
|
||||
|
||||
F1 is inert behind a modal and while a sidebar panel holds navigation
|
||||
focus (consistent with the existing `handle_key` gates, ADR-0046); it is
|
||||
active in the input context in both Simple and Advanced mode.
|
||||
|
||||
**Two error sources, one namespace.** Errors come in two kinds and reach
|
||||
`hint` by different routes:
|
||||
|
||||
- **Pre-submit diagnostics** (the ~33 `diagnostic.*` classes — arity,
|
||||
type, unknown table/column) are computed *while typing* by the walker.
|
||||
The **F1 live-input path** reads the current under-cursor diagnostic
|
||||
directly from the walker (the same source the ambient panel uses) and
|
||||
renders its `hint.err.<class>` block — no stored state needed.
|
||||
- **Runtime errors** (the 9 `translate_error` classes) occur *after*
|
||||
submit. The **`hint` command / empty-input F1** path reads them via the
|
||||
stored `last_error_hint_key` (D5).
|
||||
|
||||
Both render from the same `hint.err.*` namespace. **`:`-prefix handling:**
|
||||
on the simple-mode one-shot escape (`: SELECT …`), command
|
||||
identification for the F1 path strips the leading `:` first, so the
|
||||
advanced form is matched.
|
||||
|
||||
### D3 — The tier-3 content model
|
||||
|
||||
Tier-3 blocks live in the friendly catalogue under the existing `hint:`
|
||||
top-level namespace (where tier-2 ambient strings already live), in two
|
||||
new sub-namespaces:
|
||||
|
||||
- **`hint.cmd.<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 one new renderer, `App::note_hint*` (sibling
|
||||
of `note_help`/`note_help_topic`, `src/app.rs`), emitting a small framed
|
||||
block into the `output` buffer as `OutputKind::System` with
|
||||
`OutputStyleClass::Hint` on the `what`/`concept` prose and `Neutral` on
|
||||
the `example` line. The block is **persistent** (scrolls in the journal),
|
||||
unlike the transient ambient panel — pressing F1 is an explicit request
|
||||
to *keep* the deeper guidance on screen. The bottom keybinding strip
|
||||
(ADR-0051) advertises F1 in the editing/typing state.
|
||||
|
||||
### D5 — "Most recent (runtime) error" state
|
||||
|
||||
The **runtime-error route** (submitted `hint`, and empty-input F1) needs
|
||||
to map the last runtime error back to its `hint.err.<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) needs no
|
||||
stored state: it reads the current diagnostic from the walker at F1 time
|
||||
(D2). This is the cleaner split the `/runda` pass surfaced — typing-time
|
||||
diagnostics and post-submit runtime errors are genuinely different
|
||||
sources and should not be funnelled through one stored key.
|
||||
|
||||
### D6 — Content scope: comprehensive for v1
|
||||
|
||||
v1 ships tier-3 content for the **whole inventory**, not a subset (the
|
||||
graceful tier-2 fallback below is a safety net, not the plan):
|
||||
|
||||
- **~37 command forms** — every distinct node in `REGISTRY` gets its own
|
||||
`hint.cmd.<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` (×4 sides),
|
||||
`not_null`, `check`, `type_mismatch`, `not_found`, `already_exists`,
|
||||
`generic`, `invalid_value` — each gets a `hint.err.*` block.
|
||||
- **~33 `diagnostic.*` pre-submit classes** — arity, type, unknown
|
||||
table/column, etc. — each gets a `hint.err.*` block.
|
||||
|
||||
The full enumerated checklist is the implementation plan's tracking
|
||||
artifact (see *Content inventory*, below).
|
||||
|
||||
**Fallback (safety net):** if a tier-3 key is ever missing at runtime,
|
||||
the surface degrades to tier 2 — the ambient prose for the command path,
|
||||
or the verbose error `hint:` for the error path — never to a blank or an
|
||||
error. The `keys.rs` build-time validation keeps the corpus honest, so a
|
||||
missing key is caught in tests, not in front of a student.
|
||||
|
||||
### D7 — Authoring process: exemplars-first
|
||||
|
||||
Because the corpus is large and its *voice* is a pedagogical decision the
|
||||
maintainer owns, content is produced in two stages:
|
||||
|
||||
1. This ADR carries **2–3 worked exemplars** (below) as the canonical
|
||||
style reference. The `/runda` review of this ADR is where the voice and
|
||||
depth are approved.
|
||||
2. Once approved, the remaining blocks are authored to that template in
|
||||
**reviewable batches** (grouped by area: DDL, DML, app commands,
|
||||
error classes), not one monolithic drop.
|
||||
|
||||
### Exemplars (the style reference to approve)
|
||||
|
||||
**Command (F1 live-input), `insert`:**
|
||||
|
||||
```
|
||||
Hint — insert
|
||||
What: Add one or more rows to a table.
|
||||
Example: insert into Customers values ('Ann', 'ann@x.io')
|
||||
Concept: A row is one record; each value lines up with a column, in
|
||||
order. Columns typed serial/shortid fill themselves — leave
|
||||
them out.
|
||||
Next: a value list `(...)`, or `(col, ...) values (...)` to name columns
|
||||
```
|
||||
(The "Next:" line is the live expected-set from the walker, shown only on
|
||||
the non-empty-input F1 path.)
|
||||
|
||||
**Error (`hint` command), foreign-key child-side violation:**
|
||||
|
||||
```
|
||||
Hint — no parent row to point at
|
||||
What: The value you inserted into Orders.customer_id doesn't match
|
||||
any Customers row, so the foreign key has nothing to point at.
|
||||
Example: First insert into Customers values ('Ann', ...)
|
||||
Then insert into Orders values (..., 'Ann')
|
||||
Concept: A foreign key is a promise that every child points at a real
|
||||
parent. The parent must exist first. To allow orphans on
|
||||
delete instead, set the relationship's `on delete` to
|
||||
`set null` or `cascade`.
|
||||
```
|
||||
|
||||
**Command (F1 live-input), `add 1:n relationship`:**
|
||||
|
||||
```
|
||||
Hint — add relationship
|
||||
What: Link two tables so a parent row can own many child rows.
|
||||
Example: add 1:n relationship from Customers.id to Orders.customer_id
|
||||
Concept: The "1:n" means one parent, many children. The child column
|
||||
holds the foreign key; `--create-fk` adds it for you if it
|
||||
doesn't exist yet.
|
||||
```
|
||||
|
||||
## Forks (all user-chosen, 2026-06-14)
|
||||
|
||||
- **Trigger model:** both a keybinding (live input) and a submitted
|
||||
command (last error), rather than command-only or keybinding-only — the
|
||||
live-input path is the most useful, but the command completes the A1
|
||||
slot and serves the error case.
|
||||
- **Keybinding = F1:** the universal help convention; the key is
|
||||
genuinely free (no `KeyCode::F(1)` binding exists today — the `"F1"`
|
||||
strings in `input_render.rs`/tests are scenario labels, not the key, and
|
||||
ADR-0022 uses no `F1` requirement label). No collision with the ADR-0049
|
||||
readline keys, `Ctrl-O` (ADR-0046), `Esc`-clear, or the reserved
|
||||
`Ctrl-C` cancel (I5). Rejected: `?` (a typeable character — fiddly
|
||||
position-dependent handling) and a Ctrl/Alt chord (less discoverable, no
|
||||
advantage).
|
||||
- **No topic argument:** contextual only; `help <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 large, durable content corpus** (~37 command blocks + ~42 error/
|
||||
diagnostic blocks ≈ 80) enters the catalogue under `hint.cmd.*` /
|
||||
`hint.err.*`, validated by `keys.rs`. This is ongoing surface area: new
|
||||
commands/error classes should ship with their tier-3 hint (a checklist
|
||||
item for future feature ADRs).
|
||||
- **Testing:** Tier-1 unit tests for the trigger matrix (F1 with
|
||||
empty/non-empty input; `hint` with/without a recent error;
|
||||
`last_error_hint_key` set on the `translate_error` sites and cleared on
|
||||
success; the pre-submit-diagnostic vs runtime-error routing; the `:`
|
||||
strip), the command-identification logic, and the tier-2 fallback;
|
||||
Tier-2 `insta` snapshots for a representative rendered hint block;
|
||||
Tier-3 integration tests for the end-to-end flows (type a partial
|
||||
command → F1 → block appears, **buffer and completion memo untouched**;
|
||||
run a failing command → `hint` → error expansion). **A
|
||||
comprehensiveness coverage test** (enforces D6): iterate the REGISTRY
|
||||
and assert every node has a `hint_id` resolving to a `hint.cmd.*` block,
|
||||
and every runtime-error/diagnostic class has a `hint.err.*` block —
|
||||
`keys.rs` only checks that *referenced* keys resolve, not that every
|
||||
command/error *has* one, so this test is what makes "comprehensive"
|
||||
enforceable rather than aspirational.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Per-topic `hint <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.
|
||||
|
||||
## 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`) and per `diagnostic.*` pre-submit class.
|
||||
+2
-1
@@ -57,4 +57,5 @@ This directory contains the project's ADRs, recorded per
|
||||
- [ADR-0049 — Input-field readline keymap: Esc-clear + Ctrl-A/E/W/K/U (I1b)](0049-input-field-readline-keymap.md) — **Accepted + implemented 2026-06-12 (issue #29)**, closes Gitea **#29** and the deferred **I1b** readline requirement. **Amends ADR-0046**, which listed "readline shortcuts (I1b)" as out-of-scope — that item is now in scope and decided here; orthogonal to ADR-0003's input-*mode* model and extends the I1a single-line cursor editing already shipped. Binds, in the input field (non-modal, non-nav, both modes): **`Esc`** clears a partly-typed command (empty buffer, cursor→0, scroll→0); **`Ctrl-A`/`Ctrl-E`** alias Home/End (line start/end); **`Ctrl-W`** deletes the previous word (readline-style — eats trailing whitespace then the preceding non-whitespace run, UTF-8-safe on char boundaries, only back to the cursor); **`Ctrl-K`** kills to end of line; **`Ctrl-U`** kills to start. **Esc precedence:** a live Tab-completion memo still wins (Esc undoes the completion first, ADR-0022; Esc clears only when no memo) — Esc-once backs out the completion, Esc-again clears. Forks all user-chosen: **single-Esc-clears** (not double-Esc — discoverable over accident-proof; an unsubmitted draft can be lost, a submitted line is always in history); the **full I1b set** (not just the issue's literal Ctrl-A/E + Esc); a **new ADR** (not an ADR-0046 amendment / no-ADR). Cursor-only keys (Ctrl-A/E) leave history navigation intact like Home/End; buffer-mutating keys (Esc-clear, Ctrl-W/K/U) end it like Backspace. Helpers `clear_input`/`delete_prev_word`/`kill_to_end`/`kill_to_start` in `src/app.rs`; **22 new Tier-1 tests, 2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. OOS: on-screen keybinding hints (issue #27 owns surfacing per-focus keybindings in the bottom status line — this ADR makes the keys *work*, #27 makes them *discoverable*); demo-mode badges for the new chords (ADR-0047 follow-up — Esc already badges `[ESC]`, the glyph-less Ctrl-chords are flagged but not added); multi-line input (I1); word-wise cursor motion (Alt-B/F) / transpose / yank
|
||||
- [ADR-0050 — Incidental-DDL confirmations omit relationship info (structure-only)](0050-incidental-ddl-confirmations-omit-relationships.md) — **Accepted + implemented 2026-06-12 (issue #28)**, closes Gitea **#28**. **Supersedes** the incidental-DDL clause of **ADR-0044 §1** and the relationship-block half of **ADR-0016 §5**. Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/`rename`/`change column`, `add`/`drop index`) now render **structure only** — header + column box + `Indexes:` + constraints — with **no `References:` / `Referenced by:` block** (neither prose nor diagram), even when the table carries relationships the user did not touch. Rationale (owner): a confirmation echo reports the change just made, not untouched relationships; ADR-0044's terse prose was the lesser of "prose vs diagram", but the right answer for these surfaces is **neither**. **Relationship-subject surfaces are unchanged** — `show table`, `add`/`drop relationship`, `show relationship` still render ADR-0044 diagrams; relationships appear only when the user asks for (`show table`) or acts on (`add`/`drop relationship`) one, and are one `show table <T>` away — **no information lost**. Forks both user-chosen: **scope = all incidental DDL** (not just `add column` — the rationale is uniform, the mental model clean, and it's the simpler edit) and **delete the prose renderer** (not retain it dormant — no dead code). **Mechanism:** the `handle_dsl_success` `matches!` routing is unchanged (relationship-subject → diagrams; else → `render_structure`); the change is one line inside `render_structure` (`output_render.rs` — drop the relationship-block call) since all its callers are incidental DDL, plus deletion of the orphaned `relationship_prose_lines` + `cols_disp` helpers. The prose format survives in ADR-0016 §5 + git history for a future OOS-7 always-prose setting. **Tests:** the prose-presence unit test + its snapshot removed; a new unit test asserts `render_structure` on a description carrying **both** inbound and outbound relationships emits the box but no prose; the misnamed `add_relationship_flow_shows_inbound_section_on_parent` integration test (which sent an `AddColumn`) inverted + renamed to assert the add-column echo omits the prose; the diagram tests (`show table`, `add relationship`) unaffected. **2458 pass / 0 fail / 0 skip (1 ignored), clippy clean**. `requirements.md` unaffected (ADR-tracked refinement of a decided area, like ADR-0044 itself)
|
||||
- [ADR-0051 — Bottom keybinding strip: context- and state-aware](0051-context-state-aware-keybinding-strip.md) — **Accepted + implemented 2026-06-13 (issue #27)**, closes Gitea **#27**. Repurposes the bottom status line into a **keystrokes-only, state-selected** strip (builds on ADR-0046 nav focus, ADR-0003 modes, ADR-0049 the #29 readline keys it now advertises, ADR-0022 the completion memo). A pure `status_bar_bindings(app) -> Vec<(key,label)>` chooses the strip by **priority, first match wins**: (1) **sidebar focus** → `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input`; (2) **completion memo live** (`last_completion`) → `Tab/Shift-Tab cycle · Esc cancel · Enter run`; (3) **history navigation** (new `App::is_browsing_history()` exposing the private `history_cursor`) → `↑↓ browse · Esc clear · Enter run`; (4) **editing** (input non-empty) → `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` (surfaces the #29 keys, closing ADR-0049's deferred advertisement); (5) **default** (empty) → `Ctrl-O sidebar · Tab complete · ↑ history · Enter run`. Priority is correct because Up clears the completion memo and Tab cancels history nav, so states 2/3 never co-occur, and the five are exhaustive for Input focus. **Typed-command words leave the strip** (`mode advanced`/`mode simple` switch, `:` one-shot) and **mode discovery moves to the empty-input hint** (`resolve_hint_lines`), **simple mode only**: `\`mode advanced\` for SQL` (the verb "type" omitted — the prompt implies it; advanced mode shows **no** pointer per a post-trial user decision — a switcher knows how they got there and `help` covers the way back). The one-shot's old `Backspace cancel one-shot` label is subsumed by the editing state (behaviour intact). Forks all user-chosen: **editing state shows the #29 keys** (vs unadvertised); **`Ctrl-C quit` omitted** from the strip (vs always shown); **no width-drop machinery** — the longest strip (~65 cols) fits all supported widths, so a **width-budget unit test** keeps it lean by construction instead (the user's own observation). Catalog: 12 new `shortcut.*` labels + the `panel.hint_mode_advanced` string added to `en-US.yaml`+`keys.rs` (validator-checked 1:1), 5 now-dead strip strings removed. **Modal-aware strip is OOS** (pre-existing: a modal owns the keyboard and carries its own hints; the strip under it is unchanged-in-kind, not worsened). Tests: 9 Tier-1 unit (per-state key sets — completion/history driven through real key events; width budget; mode-pointer presence/absence), 1 Tier-3 rewritten (`status_bar_is_keystroke_only_and_state_aware`), 15 full-panel snapshots re-accepted (reviewed — strip/hint only). **2467 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: modal-aware strip; a full-key cheatsheet overlay; Ctrl-K/U advertisement (editing strip shows the highest-value subset within the width budget)
|
||||
- [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (status field gains a `:adv` tag; **journaling moves from the worker to the dispatch layer**), **ADR-0015 §5/§6** (history.log leaves the worker transaction — `commit-db-last` now scopes yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure is best-effort, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec<String>`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** OOS: unwinding the now-vestigial worker `source` plumbing (`_source` params + thin `*_request` wrappers — a clean follow-up); replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression)
|
||||
- [ADR-0052 — Mode-tagged history for cross-mode recall](0052-mode-tagged-history-cross-mode-recall.md) — **Accepted + implemented 2026-06-13 (issue #30)**, closes Gitea **#30** — the feature (advanced history reusable in simple mode) **and** the bug in its comment (the `:` one-shot prefix lost across sessions). **Amends ADR-0034** (status field gains a `:adv` tag; **journaling moves from the worker to the dispatch layer**), **ADR-0015 §5/§6** (history.log leaves the worker transaction — `commit-db-last` now scopes yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure is best-effort, not fatal); references ADR-0003. **Root cause:** history carried no mode, and the in-memory ring stored the raw `:select 1` while the worker journalled the *stripped* `select 1`, so the `:` was lost on disk. **Fix:** record the submission mode per entry as a **`:adv` suffix on the status token** (`ok`/`ok:adv`/`err`/`err:adv`) — `source` stays last + canonical so replay is unaffected; the in-memory ring (still `Vec<String>`) stores advanced entries in their `: `-prefixed simple-mode runnable form (a leading `:` unambiguously marks advanced since simple DSL never starts with `:`); recall **strips the `:` in advanced mode** (runs as bare SQL) and keeps it in simple mode (runs via the one-shot escape); hydration reconstructs the `: `-prefix from the tag, so cross-session = in-session. **The architectural turn (user's call):** the first draft kept journaling in the worker + threaded the mode down (~30-site plumbing); on review the user asked why the journal is written deep in the worker when the *failure* path already journals at the top of the chain — it shouldn't (history.log is a journal, not state). So **success journaling moved up** to `spawn_dsl_dispatch` / `run_replay` / the app-command sites (next to the failure path), the worker's `finalize_persistence` now writes only yaml/csv, and the journal write became **best-effort** (the command is already committed — consistent with the failure path; a rare disk-full leaves a committed command unjournalled, state intact). **App commands** journal simple (dispatched outside the spawn) and `submit` excludes them from the ring's advanced flag, so `undo`/`mode advanced` recall bare. Forks user-chosen: status-tag format (vs 4th field / `:`-in-source); unified scope; **dispatch-layer best-effort journaling** (vs worker-coupled-fatal). Two `/runda` passes (the second drove the relocation + app-command exclusion). Tests: the 15 worker-level journaling tests retired (worker no longer journals — yaml/csv/operation checks kept), re-covered at the new layer (history.rs status-tag + `:`-reconstruct; app.rs recall matrix; the #30 cross-session regression in `iteration6`; replay tests cover `run_replay` journaling). **2471 pass / 0 fail / 0 skip (1 ignored), clippy clean.** replay re-journaling mode-fidelity (a replayed advanced line re-journals simple — not a regression). **Follow-up done 2026-06-14:** the vestigial worker `source` plumbing was fully unwound (compiler-guided, no behaviour change) — `_source` removed from `finalize_persistence`/`do_rebuild_from_text`, the three `*_request` wrappers inlined+deleted, the dead `source` param dropped from the ~30 forwarding worker handlers, and the `source` field removed from the `DescribeTable`/`QueryData`/`RunSelect` requests + their `DatabaseHandle` methods (~164 mostly-test call sites); the only worker `source` left is the snapshot/undo label (see ADR-0052 *Consequences*)
|
||||
- [ADR-0053 — Contextual `hint` command and keybinding](0053-contextual-hint-command-and-keybinding.md) — **Accepted, implementation in progress (2026-06-14; Phase A done, Phase B underway)**. Settles the `hint` slot ADR-0003 left "ADR pending"; closes the last open piece of **A1** and tracks requirements **H2**. **Two surfaces:** an **F1 keybinding** that renders a deep hint for the *live* partial input without submitting (the primary path — a submitted `hint` command can't see the buffer it would help with, since Enter empties it), and a submitted **`hint` command** that expands on the *most recent error*. **No topic argument** (contextual only — `help <topic>` already owns explicit reference). Introduces a **tier-3 teaching layer**, deeper than the existing tier-1 (colour / error headline) and tier-2 (ambient one-liner; and the error `hint:`, which is shown **by default** since `Verbosity::Verbose` is the default — `messages short` is the opt-*out*); without it `hint` would just duplicate what's already on screen. Tier-3 content lives in the catalogue under `hint.cmd.<hint_id>` (per command form) and `hint.err.<class>` (per error/diagnostic class), each a structured `what`/`example`/`concept` block rendered via a new `note_hint*` family with `OutputStyleClass::Hint`. **Keyed per-form via a new `hint_ids: &[&str]` field on `CommandNode` mirroring `usage_ids`** (revised in Phase B): a per-*node* key proved too coarse — `add`/`drop`/`show`/`create` are each one node spanning many forms, and a live-input hint for `add 1:n relationship` must be specific to relationships; `hint_key_for_input_in_mode` reuses `usage_key_for_input_in_mode`'s form-word disambiguation, and covers the advanced-SQL forms whose `usage_ids` are empty. Not keyed off `help_id` (it is `None` on the advanced-SQL nodes purely to dedup the `help` list; that parallel gap is issue **#36**). **Clause-concept hints** (`on delete` actions, constraint slots, `with pk`, cardinality) are a recorded **deferred extension** (`hint.concept.<topic>`, issue **#37**) — per-form is the right tier-3 granularity, with position-awareness owned by tier-2 + the live `Next:` line. Two error routes share `hint.err.*`: pre-submit `diagnostic.*` read live from the walker (F1 path), runtime `translate_error` classes via stored `last_error_hint_key` (`hint` command / empty-F1). Adds `AppCommand::Hint`, a `HINT` grammar node + REGISTRY entry, the `hint_ids` field, and `last_error_hint_key`; F1 is a read-only overlay (buffer + completion memo untouched). **Content is the bulk of the work** (the mechanism is ~a day): **comprehensive for v1** — ~37 command forms + 9 runtime error classes + ~33 `diagnostic.*` classes ≈ 80 teaching blocks — authored **exemplars-first** (voice approved in this ADR's `/runda` review, then mass-authored in batches), enforced by a **comprehensiveness coverage test** (every node/error class has a key), with graceful fall-back to tier-2 if a key is ever missing. Forks user-chosen: two-surface model; **F1** (vs `?` / a chord); no-arg; comprehensive scope; exemplars-first. OOS: per-topic `hint <topic>` (rejected — overlaps `help`); always-on tier-3 (rejected — keeps ambient terse); non-`en-US` locales + success-command teaching (deferred); the `help`-side advanced-SQL gap (issue #36)
|
||||
|
||||
@@ -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 1–3 only, so **TT5** ("CI runs all tiers on Linux/macOS/Windows") is
|
||||
partially met — Linux, tiers 1–3.
|
||||
- **CI speed:** dependency/`target` caching (cargo-chef into the image, or
|
||||
`actions/cache`), and image slimming / `dind-cached` base-pull caching.
|
||||
- **Website deploy:** the static site → Cloudflare via Gitea Actions (a
|
||||
separate, simpler workflow on the website branch).
|
||||
- **fmt gate:** revisit on `main` once a `rustfmt` style is chosen.
|
||||
|
||||
## Relationship to other decisions
|
||||
|
||||
- **Builds on ADR-ci-002** (nix flake dev + build env). This ADR adds the
|
||||
musl-target/cc to that flake and consumes it from CI.
|
||||
- **Advances `requirements.md`:** **TT5** (CI runs the tiers — Linux, 1–3),
|
||||
**D2** (static binary — Linux, done), **D1**/**D3** (partial/deferred).
|
||||
- **Mirrors the website subproject's** separate ADR namespace and its
|
||||
static→Cloudflare-via-Gitea-Actions deployment posture (ADR-website-001).
|
||||
@@ -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).
|
||||
@@ -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** (~2–4 min; ~10 min total on the
|
||||
single runner), and Zig's per-target libc cache is cold each run. Fine at
|
||||
release frequency; cacheable later if it matters.
|
||||
- **The empty stub depends on `kernel32` forwarding `WaitOnAddress`** (true on
|
||||
Windows 8+), which covers every supported target.
|
||||
- **Asset naming** `rdbms-playground-<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).
|
||||
@@ -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).
|
||||
@@ -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` (1–2 sentences), `example` (one runnable line,
|
||||
mode-correct), `concept` (the relational idea — the teaching part;
|
||||
optional only where genuinely none, e.g. `quit`).
|
||||
|
||||
## 10. Tests
|
||||
|
||||
Written test-first against the Phase-A skeleton where possible.
|
||||
|
||||
- **Tier 1 (unit, `app.rs`):**
|
||||
- trigger matrix: F1 non-empty → command block; F1 empty + recent error
|
||||
→ error block; F1 empty + none → getting-started; `hint` command +
|
||||
error → error block; `hint` + none → getting-started.
|
||||
- `last_error_hint_key` set on a failing command, cleared on the next
|
||||
success.
|
||||
- routing: a pre-submit diagnostic on the input drives the diagnostic
|
||||
`hint.err`; a runtime error drives the stored-key route.
|
||||
- `:`-strip: `: SELECT …` in Simple mode resolves to the advanced node.
|
||||
- **read-only overlay:** F1 leaves `input`, `input_cursor`, and
|
||||
`last_completion` unchanged.
|
||||
- tier-2 fallback when `hint_id`/key absent.
|
||||
- **Tier 2 (`insta`):** snapshot a representative rendered tier-3 block
|
||||
(the `insert` exemplar) so the framed layout + styling spans are locked.
|
||||
- **Tier 3 (integration, `tests/it/`):** type a partial command → F1 →
|
||||
block appears, buffer untouched; run a failing insert → `hint` → FK
|
||||
error expansion.
|
||||
- **Comprehensiveness coverage test** (enforces D6, the key one): iterate
|
||||
`REGISTRY` and assert every node has a `hint_id` resolving to a
|
||||
`hint.cmd.*` block; assert every runtime-error + `diagnostic.*` class
|
||||
has a `hint.err.*` block. **Red until Phase C completes** — enable
|
||||
(un-`ignore`) as the final gate.
|
||||
- `keys.rs` validation continues to guarantee every *referenced* key
|
||||
resolves.
|
||||
|
||||
## 11. Keybinding strip + discoverability (Phase D)
|
||||
|
||||
- The ADR-0051 bottom strip advertises **F1 = hint** in the editing/
|
||||
typing state (and on the empty-input state, since F1 still does
|
||||
something there). Re-accept the affected full-panel snapshots.
|
||||
|
||||
## 12. ADR / docs
|
||||
|
||||
- ADR-0053 is committed (`e16ad50`). On completion, flip its Status from
|
||||
"implementation pending" to implemented (with date), and update the
|
||||
README index entry + `requirements.md` **H2 → [x]** and **A1 → [x]**
|
||||
(A1 closes when `hint` lands).
|
||||
|
||||
## 13. Risks / watch-list
|
||||
|
||||
- **Command-identification reuse.** The lookup exists
|
||||
(`command_for_entry_word` + the mode-aware `usage_keys_for_input_in_mode`,
|
||||
`grammar/mod.rs:811`/`564`); the only new code is a thin node/`hint_id`
|
||||
variant that reuses their selection. Do **not** re-implement entry-word
|
||||
matching — mirror the existing functions.
|
||||
- **Structured-key ergonomics.** Three sub-keys per block × ~80 blocks is
|
||||
~240 catalogue keys; keep the `keys.rs` registration generation tidy
|
||||
(consider a helper that registers the `{what,example,concept}` triple
|
||||
for an id).
|
||||
- **Content voice drift across batches.** Re-check each batch against the
|
||||
approved exemplars; the `concept` line is where drift (too terse / too
|
||||
advanced) creeps in. Pedagogy wins ties.
|
||||
- **F1 terminal capture.** A few terminals intercept F1; acceptable
|
||||
(it's the convention) but note it if testing surfaces it.
|
||||
- **Snapshot churn.** The strip change re-accepts ADR-0051 snapshots;
|
||||
keep that diff isolated.
|
||||
- **Coverage-test timing.** It is red through Phases A–C; gate it so CI
|
||||
isn't broken mid-stream (e.g. `#[ignore]` until the final batch), then
|
||||
make passing it the completion criterion.
|
||||
```
|
||||
Generated
+82
@@ -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
|
||||
}
|
||||
@@ -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)"
|
||||
'';
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
+343
@@ -271,6 +271,13 @@ pub struct App {
|
||||
pub nav_focus: NavFocus,
|
||||
pub output: VecDeque<OutputLine>,
|
||||
pub hint: Option<String>,
|
||||
/// Catalog class key of the most recent runtime error (H2 /
|
||||
/// ADR-0053 D5), e.g. `foreign_key.child_side`. Set when a
|
||||
/// friendly error is rendered, cleared on the next successful
|
||||
/// command. The submitted `hint` command and empty-input F1 use
|
||||
/// it to render that error's tier-3 `hint.err.<class>` block.
|
||||
/// `None` → no recent error → the "getting started" pointer.
|
||||
pub last_error_hint_key: Option<String>,
|
||||
/// The validity indicator's currently-visible verdict
|
||||
/// (ADR-0027). `None` means the indicator shows nothing —
|
||||
/// the input is clean, or it is hidden mid-typing while the
|
||||
@@ -521,6 +528,7 @@ pub const fn demo_badge_label(key: &KeyEvent) -> Option<&'static str> {
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Tab, _) => Some("[TAB]"),
|
||||
(KeyCode::BackTab, _) => Some("[SHIFT-TAB]"),
|
||||
(KeyCode::F(1), _) => Some("[F1]"),
|
||||
(KeyCode::Enter, _) => Some("[ENTER]"),
|
||||
(KeyCode::Esc, _) => Some("[ESC]"),
|
||||
(KeyCode::Up, _) => Some("[UP]"),
|
||||
@@ -557,6 +565,7 @@ impl App {
|
||||
nav_focus: NavFocus::Input,
|
||||
output: VecDeque::with_capacity(OUTPUT_CAPACITY),
|
||||
hint: None,
|
||||
last_error_hint_key: None,
|
||||
input_indicator: None,
|
||||
tables: Vec::new(),
|
||||
relationships: Vec::new(),
|
||||
@@ -1208,6 +1217,21 @@ impl App {
|
||||
return self.handle_nav_key(key);
|
||||
}
|
||||
|
||||
// H2 / ADR-0053: F1 is a read-only contextual-hint overlay —
|
||||
// it emits into the output journal and must NOT touch the input
|
||||
// buffer, cursor, or the completion memo, so it sits ahead of
|
||||
// the memo-clearing completion match below. Non-empty input →
|
||||
// a hint for the command being typed; empty input → expand on
|
||||
// the most recent error (or a getting-started pointer).
|
||||
if key.code == KeyCode::F(1) {
|
||||
if self.input.trim().is_empty() {
|
||||
self.note_hint_for_recent_error();
|
||||
} else {
|
||||
self.note_hint_for_input();
|
||||
}
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// ADR-0022 stage 8 — non-modal completion. Tab /
|
||||
// Shift-Tab cycle; Esc / Backspace undo the whole
|
||||
// last-Tab insertion in one keystroke while the memo
|
||||
@@ -1774,6 +1798,13 @@ impl App {
|
||||
// recallable. The canonical (un-prefixed) text is what reaches
|
||||
// the journal via `ExecuteDsl.source`.
|
||||
let is_app = matches!(&parsed, Ok(Command::App(_)));
|
||||
// H2 / ADR-0053 D5: a new *DSL* command supersedes the previous
|
||||
// runtime error for `hint`. App commands (incl. `hint` itself)
|
||||
// and parse errors leave it intact, so `hint` still expands the
|
||||
// last real error after, say, a `help` in between.
|
||||
if matches!(&parsed, Ok(cmd) if !matches!(cmd, Command::App(_))) {
|
||||
self.last_error_hint_key = None;
|
||||
}
|
||||
let advanced = submission_mode.is_advanced() && !is_app;
|
||||
let ring_line = if advanced {
|
||||
format!(": {effective_input}")
|
||||
@@ -1814,6 +1845,13 @@ impl App {
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
// H2 / ADR-0053: a submitted `hint` acts on the most recent
|
||||
// runtime error (the buffer is empty post-submit). The
|
||||
// live-input surface is the F1 keybinding (handle_key).
|
||||
AppCommand::Hint => {
|
||||
self.note_hint_for_recent_error();
|
||||
Vec::new()
|
||||
}
|
||||
AppCommand::Rebuild => vec![Action::PrepareRebuild],
|
||||
AppCommand::Save => self.handle_save_command(false),
|
||||
AppCommand::SaveAs => self.handle_save_command(true),
|
||||
@@ -2422,6 +2460,10 @@ impl App {
|
||||
// runtime built before posting the event.
|
||||
let ctx = self.build_translate_context(command, facts);
|
||||
let rendered = crate::friendly::translate_error(&error, &ctx).render();
|
||||
// H2 / ADR-0053 D5: remember this error's tier-3 class so a
|
||||
// following `hint` (or empty-input F1) can expand on it.
|
||||
self.last_error_hint_key =
|
||||
crate::friendly::error_hint_class(&error, &ctx).map(String::from);
|
||||
warn!(
|
||||
verb = command.verb(),
|
||||
error = %rendered,
|
||||
@@ -3091,6 +3133,94 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
// ── H2 / ADR-0053: contextual `hint` ────────────────────────
|
||||
// Phase A wires the two surfaces (F1 → live input; the `hint`
|
||||
// command → most recent error) plus the tier-2 fallback. The
|
||||
// tier-3 corpus (`hint.cmd.*` / `hint.err.*`) is authored in later
|
||||
// phases; until a block exists, `emit_tier3_block` returns false
|
||||
// and the surface degrades to the ambient prose / getting-started
|
||||
// pointer — never blank.
|
||||
|
||||
/// F1 with a non-empty buffer: a tier-3 hint for the command form
|
||||
/// being typed, else the tier-2 ambient prose (ADR-0053 D2).
|
||||
/// Read-only — callers guarantee the buffer/cursor/memo are left
|
||||
/// untouched.
|
||||
fn note_hint_for_input(&mut self) {
|
||||
// `feedback_view` strips the `:` one-shot sigil and
|
||||
// `effective_mode` reflects the one-shot advanced surface, so
|
||||
// the hint matches the command the user is actually typing.
|
||||
let (view, cursor, _off) = self.feedback_view();
|
||||
let probe = view.to_string();
|
||||
let mode = self.effective_mode().as_mode();
|
||||
if let Some(id) = crate::dsl::grammar::hint_key_for_input_in_mode(&probe, mode)
|
||||
&& self.emit_tier3_block(&format!("hint.cmd.{id}"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Tier-2 fallback: surface the ambient prose as a persistent
|
||||
// line (computed exactly as the live panel does).
|
||||
let ambient = crate::input_render::ambient_hint_in_mode(
|
||||
&probe,
|
||||
cursor,
|
||||
self.last_completion.as_ref(),
|
||||
&self.schema_cache,
|
||||
mode,
|
||||
);
|
||||
match ambient {
|
||||
Some(crate::input_render::AmbientHint::Prose(text)) => {
|
||||
self.push_category_three_prose(text);
|
||||
}
|
||||
Some(crate::input_render::AmbientHint::Candidates { items, .. }) => {
|
||||
let names = items
|
||||
.iter()
|
||||
.map(|c| c.text.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
self.push_category_three_prose(crate::t!("hint.ambient_expected", expected = names));
|
||||
}
|
||||
None => self.note_getting_started(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `hint` command (and empty-input F1): expand on the most
|
||||
/// recent runtime error, else point the user at how to start
|
||||
/// (ADR-0053 D2/D5).
|
||||
fn note_hint_for_recent_error(&mut self) {
|
||||
if let Some(class) = self.last_error_hint_key.clone()
|
||||
&& self.emit_tier3_block(&format!("hint.err.{class}"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.note_getting_started();
|
||||
}
|
||||
|
||||
fn note_getting_started(&mut self) {
|
||||
self.note_system(crate::t!("hint.getting_started"));
|
||||
}
|
||||
|
||||
/// Render a tier-3 block (`<stem>.what` / `.example` / `.concept`)
|
||||
/// when it has been authored; returns `false` if the `what` part is
|
||||
/// absent so the caller can fall back to tier 2. `what` is
|
||||
/// mandatory, `example`/`concept` optional (ADR-0053 D3). Styling
|
||||
/// polish (the framed block) lands with the corpus.
|
||||
fn emit_tier3_block(&mut self, stem: &str) -> bool {
|
||||
let cat = crate::friendly::catalog();
|
||||
if cat.get(&format!("{stem}.what")).is_none() {
|
||||
return false;
|
||||
}
|
||||
self.note_system(crate::friendly::translate(&format!("{stem}.what"), &[]));
|
||||
if cat.get(&format!("{stem}.example")).is_some() {
|
||||
self.note_system(crate::friendly::translate(&format!("{stem}.example"), &[]));
|
||||
}
|
||||
if cat.get(&format!("{stem}.concept")).is_some() {
|
||||
self.push_category_three_prose(crate::friendly::translate(
|
||||
&format!("{stem}.concept"),
|
||||
&[],
|
||||
));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn note_system(&mut self, text: impl Into<String>) {
|
||||
self.push_multiline(text.into(), OutputKind::System);
|
||||
}
|
||||
@@ -5539,6 +5669,219 @@ mod tests {
|
||||
assert!(last.text.contains("Ghost"), "{}", last.text);
|
||||
}
|
||||
|
||||
// ── H2 / ADR-0053: contextual `hint` (Phase A skeleton) ──────
|
||||
|
||||
fn f1(app: &mut App) -> Vec<Action> {
|
||||
app.update(key(KeyCode::F(1)))
|
||||
}
|
||||
|
||||
fn no_such_table_failure() -> AppEvent {
|
||||
AppEvent::DslFailed {
|
||||
command: Command::DropTable {
|
||||
name: "Ghost".to_string(),
|
||||
},
|
||||
error: crate::db::DbError::Sqlite {
|
||||
message: "no such table: Ghost".to_string(),
|
||||
kind: crate::db::SqliteErrorKind::NoSuchTable,
|
||||
},
|
||||
facts: crate::friendly::FailureContext::default(),
|
||||
source: String::new(),
|
||||
advanced: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_command_parses_to_app_hint() {
|
||||
use crate::dsl::{parse_command, AppCommand, Command};
|
||||
assert!(matches!(
|
||||
parse_command("hint"),
|
||||
Ok(Command::App(AppCommand::Hint))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_command_with_no_recent_error_shows_getting_started() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "hint");
|
||||
submit(&mut app);
|
||||
assert!(output_contains(&app, "press F1"), "{}", error_lines(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f1_on_empty_input_with_no_error_shows_getting_started() {
|
||||
let mut app = App::new();
|
||||
let before = app.output.len();
|
||||
f1(&mut app);
|
||||
assert!(app.output.len() > before, "F1 must emit something");
|
||||
assert!(output_contains(&app, "press F1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f1_is_a_read_only_overlay() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "insert into T");
|
||||
let input = app.input.clone();
|
||||
let cursor = app.input_cursor;
|
||||
let before = app.output.len();
|
||||
f1(&mut app);
|
||||
assert_eq!(app.input, input, "F1 must not change the buffer");
|
||||
assert_eq!(app.input_cursor, cursor, "F1 must not move the cursor");
|
||||
assert!(app.output.len() > before, "F1 emits a hint line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f1_preserves_the_completion_memo() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "show ");
|
||||
app.update(key(KeyCode::Tab));
|
||||
assert!(app.last_completion.is_some(), "precondition: Tab sets the memo");
|
||||
let input = app.input.clone();
|
||||
f1(&mut app);
|
||||
assert!(app.last_completion.is_some(), "F1 must not clear the memo");
|
||||
assert_eq!(app.input, input, "F1 must not change the buffer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsl_failure_sets_hint_class_and_a_later_dsl_command_clears_it() {
|
||||
let mut app = App::new();
|
||||
app.update(no_such_table_failure());
|
||||
assert_eq!(app.last_error_hint_key.as_deref(), Some("not_found"));
|
||||
// A new DSL command supersedes the previous error.
|
||||
type_str(&mut app, "drop table Ghost");
|
||||
submit(&mut app);
|
||||
assert_eq!(app.last_error_hint_key, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_command_does_not_clear_the_hint_class() {
|
||||
let mut app = App::new();
|
||||
app.update(no_such_table_failure());
|
||||
assert_eq!(app.last_error_hint_key.as_deref(), Some("not_found"));
|
||||
// `help` (an app command) leaves the last error intact, so a
|
||||
// following `hint` still expands on it.
|
||||
type_str(&mut app, "help");
|
||||
submit(&mut app);
|
||||
assert_eq!(
|
||||
app.last_error_hint_key.as_deref(),
|
||||
Some("not_found"),
|
||||
"an app command must not clear the last error's hint class"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_after_error_emits_a_hint_without_panicking() {
|
||||
// Phase A: no tier-3 `hint.err.*` content exists yet, so the
|
||||
// error path falls back to the getting-started pointer. (Phase C
|
||||
// replaces this with the real error block.)
|
||||
let mut app = App::new();
|
||||
app.update(no_such_table_failure());
|
||||
let before = app.output.len();
|
||||
type_str(&mut app, "hint");
|
||||
submit(&mut app);
|
||||
assert!(app.output.len() > before, "hint must emit something");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_list_includes_hint() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "help");
|
||||
submit(&mut app);
|
||||
assert!(
|
||||
output_contains(&app, "explain the most recent error"),
|
||||
"help list must advertise the hint command"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_hint_describes_the_hint_command() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "help hint");
|
||||
submit(&mut app);
|
||||
assert!(output_contains(&app, "explain the most recent error"));
|
||||
}
|
||||
|
||||
// ── Phase B: tier-3 exemplar content renders ────────────────
|
||||
|
||||
#[test]
|
||||
fn f1_on_insert_input_renders_the_insert_hint_block() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "insert into Customers ");
|
||||
f1(&mut app);
|
||||
assert!(
|
||||
output_contains(&app, "Add one or more rows to a table"),
|
||||
"expected the insert tier-3 block"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f1_on_add_relationship_renders_the_relationship_block() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "add 1:n relationship from Customers.id to Orders.cust ");
|
||||
f1(&mut app);
|
||||
assert!(
|
||||
output_contains(&app, "one parent, many children"),
|
||||
"expected the add-relationship tier-3 block"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f1_on_add_column_does_not_render_the_relationship_block() {
|
||||
// Per-form disambiguation (ADR-0053 D3): `add column` resolves
|
||||
// to `add_column` (no tier-3 block yet → tier-2 fallback), NOT
|
||||
// the relationship block — proving the multi-form node keys
|
||||
// per form, not per node.
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "add column Note text to Customers");
|
||||
f1(&mut app);
|
||||
assert!(!output_contains(&app, "one parent, many children"));
|
||||
assert!(!output_contains(&app, "1:n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hint_renders_the_foreign_key_error_block() {
|
||||
let mut app = App::new();
|
||||
app.last_error_hint_key = Some("foreign_key.child_side".to_string());
|
||||
type_str(&mut app, "hint");
|
||||
submit(&mut app);
|
||||
assert!(
|
||||
output_contains(&app, "doesn't match any parent row"),
|
||||
"expected the FK child-side tier-3 block"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Phase C batch 1: app-command hints render ───────────────
|
||||
|
||||
#[test]
|
||||
fn f1_on_an_app_command_renders_its_hint_block() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "mode advanced");
|
||||
f1(&mut app);
|
||||
assert!(
|
||||
output_contains(&app, "Switch between simple mode"),
|
||||
"expected the `mode` tier-3 block"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Phase C batch 2: DDL hints render (incl. multi-form DROP) ──
|
||||
|
||||
#[test]
|
||||
fn f1_on_create_table_renders_its_hint_block() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "create table Customers with pk id(serial)");
|
||||
f1(&mut app);
|
||||
assert!(output_contains(&app, "Create a new table"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f1_disambiguates_drop_forms() {
|
||||
let mut app = App::new();
|
||||
type_str(&mut app, "drop index idx_email");
|
||||
f1(&mut app);
|
||||
// Resolves drop_index, not drop_table/column/etc.
|
||||
assert!(output_contains(&app, "Remove an index by name"));
|
||||
assert!(!output_contains(&app, "Remove a table"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_command_toggles_verbosity_and_reports() {
|
||||
let mut app = App::new();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],};
|
||||
|
||||
@@ -1790,6 +1790,7 @@ pub static SHOW: CommandNode = CommandNode {
|
||||
shape: SHOW_SHAPE,
|
||||
ast_builder: build_show,
|
||||
help_id: Some("data.show"),
|
||||
hint_ids: &[],
|
||||
usage_ids: &[
|
||||
"parse.usage.show_data",
|
||||
"parse.usage.show_table",
|
||||
@@ -1805,6 +1806,7 @@ pub static SEED: CommandNode = CommandNode {
|
||||
shape: SEED_SHAPE,
|
||||
ast_builder: build_seed,
|
||||
help_id: Some("data.seed"),
|
||||
hint_ids: &[],
|
||||
usage_ids: &["parse.usage.seed"],
|
||||
};
|
||||
|
||||
@@ -1813,6 +1815,8 @@ pub static INSERT: CommandNode = CommandNode {
|
||||
shape: INSERT_SHAPE,
|
||||
ast_builder: build_insert,
|
||||
help_id: Some("data.insert"),
|
||||
// ADR-0053 Phase-B exemplar.
|
||||
hint_ids: &["insert"],
|
||||
usage_ids: &["parse.usage.insert"],};
|
||||
|
||||
pub static UPDATE: CommandNode = CommandNode {
|
||||
@@ -1820,6 +1824,7 @@ pub static UPDATE: CommandNode = CommandNode {
|
||||
shape: UPDATE_SHAPE,
|
||||
ast_builder: build_update,
|
||||
help_id: Some("data.update"),
|
||||
hint_ids: &[],
|
||||
usage_ids: &["parse.usage.update"],};
|
||||
|
||||
pub static DELETE: CommandNode = CommandNode {
|
||||
@@ -1827,6 +1832,7 @@ pub static DELETE: CommandNode = CommandNode {
|
||||
shape: DELETE_SHAPE,
|
||||
ast_builder: build_delete,
|
||||
help_id: Some("data.delete"),
|
||||
hint_ids: &[],
|
||||
usage_ids: &["parse.usage.delete"],};
|
||||
|
||||
pub static REPLAY: CommandNode = CommandNode {
|
||||
@@ -1834,6 +1840,7 @@ pub static REPLAY: CommandNode = CommandNode {
|
||||
shape: REPLAY_PATH,
|
||||
ast_builder: build_replay,
|
||||
help_id: Some("data.replay"),
|
||||
hint_ids: &[],
|
||||
usage_ids: &["parse.usage.replay"],};
|
||||
|
||||
pub static EXPLAIN: CommandNode = CommandNode {
|
||||
@@ -1841,6 +1848,7 @@ pub static EXPLAIN: CommandNode = CommandNode {
|
||||
shape: EXPLAIN_SHAPE,
|
||||
ast_builder: build_explain,
|
||||
help_id: Some("data.explain"),
|
||||
hint_ids: &[],
|
||||
usage_ids: &["parse.usage.explain"],};
|
||||
|
||||
/// `explain` over advanced-mode SQL (ADR-0039).
|
||||
@@ -1860,6 +1868,7 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode {
|
||||
// too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE`
|
||||
// precedent; otherwise `note_help` would print `explain` twice.
|
||||
help_id: None,
|
||||
hint_ids: &[],
|
||||
usage_ids: &[],};
|
||||
|
||||
/// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032).
|
||||
@@ -1875,6 +1884,7 @@ pub static SELECT: CommandNode = CommandNode {
|
||||
shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL),
|
||||
ast_builder: build_select,
|
||||
help_id: None,
|
||||
hint_ids: &[],
|
||||
usage_ids: &["parse.usage.select"],};
|
||||
|
||||
/// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c).
|
||||
@@ -1889,6 +1899,7 @@ pub static WITH: CommandNode = CommandNode {
|
||||
shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL),
|
||||
ast_builder: build_select,
|
||||
help_id: None,
|
||||
hint_ids: &[],
|
||||
usage_ids: &["parse.usage.with"],};
|
||||
|
||||
/// SQL `INSERT` — the `Advanced`-category node of the shared
|
||||
@@ -1906,6 +1917,7 @@ pub static SQL_INSERT: CommandNode = CommandNode {
|
||||
shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE),
|
||||
ast_builder: build_sql_insert,
|
||||
help_id: None,
|
||||
hint_ids: &[],
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
@@ -1919,6 +1931,7 @@ pub static SQL_UPDATE: CommandNode = CommandNode {
|
||||
shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE),
|
||||
ast_builder: build_sql_update,
|
||||
help_id: None,
|
||||
hint_ids: &[],
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
@@ -1934,6 +1947,7 @@ pub static SQL_DELETE: CommandNode = CommandNode {
|
||||
shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE),
|
||||
ast_builder: build_sql_delete,
|
||||
help_id: None,
|
||||
hint_ids: &[],
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
|
||||
@@ -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: &[],
|
||||
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: &[],
|
||||
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: &[],
|
||||
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: &[],
|
||||
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: &[],
|
||||
usage_ids: &["parse.usage.sql_alter_table"],
|
||||
};
|
||||
|
||||
|
||||
+126
-35
@@ -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,79 @@ pub fn usage_keys_for_input_in_mode(
|
||||
source: &str,
|
||||
mode: crate::mode::Mode,
|
||||
) -> Option<(&'static str, Vec<&'static str>)> {
|
||||
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 word = &source[kw_start..kw_end];
|
||||
let candidates = commands_for_entry_word(word);
|
||||
if candidates.is_empty() {
|
||||
let pick = selected_nodes_for_input_in_mode(source, mode);
|
||||
if pick.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let union = |nodes: &[(usize, &'static CommandNode, CommandCategory)]| -> Vec<&'static str> {
|
||||
let mut keys: Vec<&'static str> = Vec::new();
|
||||
for (_, node, _) in nodes {
|
||||
for (_, node, _) in &pick {
|
||||
for k in node.usage_ids {
|
||||
if !keys.contains(k) {
|
||||
keys.push(*k);
|
||||
}
|
||||
}
|
||||
}
|
||||
keys
|
||||
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> {
|
||||
let nodes = selected_nodes_for_input_in_mode(source, mode);
|
||||
if nodes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut keys: Vec<&'static str> = Vec::new();
|
||||
for (_, node, _) in &nodes {
|
||||
for k in node.hint_ids {
|
||||
if !keys.contains(k) {
|
||||
keys.push(*k);
|
||||
}
|
||||
}
|
||||
}
|
||||
pick_form_key(source, &keys)
|
||||
}
|
||||
|
||||
/// Shared mode-aware command-form selection for the entry word at the
|
||||
/// start of `source`.
|
||||
///
|
||||
/// Extracted so the usage-key and hint-id lookups agree on which form
|
||||
/// the user is typing.
|
||||
///
|
||||
/// Advanced mode: every candidate form is reachable — the SQL nodes
|
||||
/// are primary, and the DSL nodes remain valid via fallback (verified:
|
||||
/// `create table … with pk` and `drop column …` both run in advanced
|
||||
/// mode). Mode-primary (Advanced) first, so a hint never hides input
|
||||
/// that works. Simple mode: only the DSL forms — the SQL-only forms
|
||||
/// hit the "this is SQL" rail and are not reachable. (ADR-0042 G3.)
|
||||
/// Degenerate guard: an advanced-only word in simple mode leaves the
|
||||
/// selection empty; fall back to all candidates.
|
||||
fn selected_nodes_for_input_in_mode(
|
||||
source: &str,
|
||||
mode: crate::mode::Mode,
|
||||
) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
|
||||
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
||||
let start = skip_whitespace(source, 0);
|
||||
let Some((kw_start, kw_end)) = consume_ident(source, start) else {
|
||||
return Vec::new();
|
||||
};
|
||||
// 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 word = &source[kw_start..kw_end];
|
||||
let candidates = commands_for_entry_word(word);
|
||||
if candidates.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let selected: Vec<(usize, &'static CommandNode, CommandCategory)> =
|
||||
if mode == crate::mode::Mode::Advanced {
|
||||
let mut v: Vec<_> = candidates
|
||||
@@ -621,17 +680,7 @@ pub fn usage_keys_for_input_in_mode(
|
||||
.filter(|(_, _, c)| *c == CommandCategory::Simple)
|
||||
.collect()
|
||||
};
|
||||
// Degenerate guard: an advanced-only word in simple mode (not
|
||||
// normally reachable — it hits the SQL rail first) leaves
|
||||
// `selected` empty; fall back to all candidates so a usage block
|
||||
// still renders rather than the available-commands fallback.
|
||||
let pick = if selected.is_empty() { candidates } else { selected };
|
||||
let keys = union(&pick);
|
||||
if keys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let entry = pick[0].1.entry.primary;
|
||||
Some((entry, keys))
|
||||
if selected.is_empty() { candidates } else { selected }
|
||||
}
|
||||
|
||||
/// The single usage template most relevant to `source`, when
|
||||
@@ -658,14 +707,24 @@ pub fn usage_key_for_input_in_mode(
|
||||
source: &str,
|
||||
mode: crate::mode::Mode,
|
||||
) -> Option<&'static str> {
|
||||
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
||||
let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?;
|
||||
pick_form_key(source, &keys)
|
||||
}
|
||||
|
||||
/// From the form word after the entry keyword, pick the single `keys`
|
||||
/// entry for the form `source` names.
|
||||
///
|
||||
/// A single-entry list resolves to its one key; a multi-form list
|
||||
/// disambiguates by the form word (`add 1:n relationship` → the
|
||||
/// `…relationship` key, `create m:n …` → the `…m2n` key, else the
|
||||
/// identifier form word matched against each key's suffix). Shared by
|
||||
/// the usage-template and tier-3-hint single-key lookups so they agree.
|
||||
fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> {
|
||||
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
||||
let first = *keys.first()?;
|
||||
if keys.len() == 1 {
|
||||
return Some(first);
|
||||
}
|
||||
// Multi-form: the form is named by the token right after
|
||||
// the entry keyword.
|
||||
let start = skip_whitespace(source, 0);
|
||||
let (_, entry_end) = consume_ident(source, start)?;
|
||||
let after = skip_whitespace(source, entry_end);
|
||||
@@ -674,14 +733,12 @@ pub fn usage_key_for_input_in_mode(
|
||||
return keys.iter().copied().find(|k| k.ends_with("relationship"));
|
||||
}
|
||||
// The `create m:n relationship` form (ADR-0045) opens with `m:n`
|
||||
// — a letter, so the digit branch misses it, and its usage key ends
|
||||
// `…create_m2n` (not `relationship`).
|
||||
// — a letter, so the digit branch misses it; its key ends `…m2n`.
|
||||
if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) {
|
||||
return keys.iter().copied().find(|k| k.ends_with("m2n"));
|
||||
}
|
||||
// Otherwise the form word is an identifier — `column`,
|
||||
// `index`, `table`, `relationship` — matched against the
|
||||
// usage key's suffix.
|
||||
// Otherwise the form word is an identifier — `column`, `index`,
|
||||
// `table`, `relationship` — matched against each key's suffix.
|
||||
let (s, e) = consume_ident(source, after)?;
|
||||
let form = source[s..e].to_ascii_lowercase();
|
||||
keys.iter().copied().find(|k| k.ends_with(form.as_str()))
|
||||
@@ -712,6 +769,7 @@ pub fn entry_words_alphabetised() -> Vec<&'static str> {
|
||||
pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
|
||||
(&app::QUIT, CommandCategory::Simple),
|
||||
(&app::HELP, CommandCategory::Simple),
|
||||
(&app::HINT, CommandCategory::Simple),
|
||||
(&app::REBUILD, CommandCategory::Simple),
|
||||
(&app::SAVE, CommandCategory::Simple),
|
||||
(&app::NEW, CommandCategory::Simple),
|
||||
@@ -836,6 +894,39 @@ 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")
|
||||
);
|
||||
// Unknown entry word → None (tier-2 fallback).
|
||||
assert_eq!(hint_key_for_input_in_mode("zzz", Mode::Simple), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod usage_key_tests {
|
||||
use super::usage_key_for_input;
|
||||
|
||||
@@ -6910,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 {
|
||||
@@ -6917,6 +6918,7 @@ mod dispatch_3a_tests {
|
||||
shape: Node::Word(Word::keyword("sqltail")),
|
||||
ast_builder: sql_builder,
|
||||
help_id: None,
|
||||
hint_ids: &[],
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
|
||||
@@ -180,6 +180,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("help.unknown_topic", &["topic"]),
|
||||
("help.app.quit", &[]),
|
||||
("help.app.help", &[]),
|
||||
("help.app.hint", &[]),
|
||||
("help.app.rebuild", &[]),
|
||||
("help.app.save", &[]),
|
||||
("help.app.new", &[]),
|
||||
@@ -222,6 +223,89 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
&["message", "usage"],
|
||||
),
|
||||
("hint.ambient_expected", &["expected"]),
|
||||
("hint.getting_started", &[]),
|
||||
// Tier-3 teaching blocks (ADR-0053 D3) — Phase-B exemplars.
|
||||
("hint.cmd.insert.what", &[]),
|
||||
("hint.cmd.insert.example", &[]),
|
||||
("hint.cmd.insert.concept", &[]),
|
||||
("hint.cmd.add_relationship.what", &[]),
|
||||
("hint.cmd.add_relationship.example", &[]),
|
||||
("hint.cmd.add_relationship.concept", &[]),
|
||||
("hint.err.foreign_key.child_side.what", &[]),
|
||||
("hint.err.foreign_key.child_side.example", &[]),
|
||||
("hint.err.foreign_key.child_side.concept", &[]),
|
||||
// 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", &[]),
|
||||
(
|
||||
"hint.ambient_invalid_ident",
|
||||
&["kind", "found"],
|
||||
@@ -299,6 +383,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", &[]),
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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,129 @@ hint:
|
||||
ambient_complete: "Submit with Enter"
|
||||
ambient_expected: "Next: {expected}"
|
||||
ambient_error_with_usage: "{message} — usage: {usage}"
|
||||
# H2 / ADR-0053: shown by `hint` / F1 when there is nothing specific
|
||||
# to expand on (no recent error, empty input).
|
||||
getting_started: "Start typing a command and press F1 for a hint, or type `help` for the full command list."
|
||||
# ── Tier-3 teaching blocks (ADR-0053 D3) ──────────────────────────
|
||||
# Per-form command hints (`hint.cmd.<form>`) and per-class error
|
||||
# hints (`hint.err.<class>`), each a `what` (1–2 sentences) / `example`
|
||||
# (one runnable, mode-correct line) / `concept` (the relational idea —
|
||||
# the teaching part). Phase B seeds the three approved exemplars; the
|
||||
# rest are authored in Phase C.
|
||||
cmd:
|
||||
insert:
|
||||
what: "Add one or more rows to a table."
|
||||
example: "insert into Customers values ('Ann', 'ann@example.io')"
|
||||
concept: "A row is one record; each value lines up with a column, in order. Columns typed `serial`/`shortid` fill themselves — leave them out."
|
||||
add_relationship:
|
||||
what: "Link two tables so a parent row can own many child rows."
|
||||
example: "add 1:n relationship from Customers.id to Orders.customer_id"
|
||||
concept: "The \"1:n\" means one parent, many children. The child column holds the foreign key; add `--create-fk` to create that column if it doesn't exist yet."
|
||||
# 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."
|
||||
err:
|
||||
foreign_key:
|
||||
child_side:
|
||||
what: "The value you gave for the child column doesn't match any parent row, so the foreign key has nothing to point at."
|
||||
example: "First insert the parent (insert into Customers …), then the child that references it."
|
||||
concept: "A foreign key is a promise that every child points at a real parent, so the parent must exist first. To allow orphans on delete instead, set the relationship's `on delete` to `set null` or `cascade`."
|
||||
# Invalid identifier in a schema slot (ADR-0022 stage 8e
|
||||
# + the user's #5). Voice mirrors ADR-0019's "no such
|
||||
# {kind}" wording for consistency with engine errors.
|
||||
@@ -617,6 +742,7 @@ parse:
|
||||
# description.
|
||||
quit: "quit"
|
||||
help: "help [<command>]"
|
||||
hint: "hint"
|
||||
rebuild: "rebuild"
|
||||
save: "save | save as"
|
||||
new: "new"
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+13
-13
@@ -1190,7 +1190,7 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac
|
||||
// miss leaves that table's columns unpopulated and the
|
||||
// walker falls back to the schemaless value-literal list.
|
||||
for name in cache.tables.clone() {
|
||||
if let Ok(desc) = database.describe_table(name.clone(), None).await {
|
||||
if let Ok(desc) = database.describe_table(name.clone()).await {
|
||||
// Per-table indexes for the items panel (S2, ADR-0025).
|
||||
// Carry uniqueness so the panel can mark a UNIQUE index
|
||||
// (ADR-0035 §4d). Captured before `desc.columns` is
|
||||
@@ -1650,7 +1650,7 @@ async fn build_show_data_echo(
|
||||
limit: Some(_),
|
||||
..
|
||||
} => database
|
||||
.describe_table(name.clone(), None)
|
||||
.describe_table(name.clone())
|
||||
.await
|
||||
.map(|desc| {
|
||||
desc.columns
|
||||
@@ -1732,7 +1732,7 @@ async fn collect_echo_lookups(
|
||||
Command::DropIndex {
|
||||
selector: IndexSelector::Columns { table, columns },
|
||||
} => {
|
||||
if let Ok(desc) = database.describe_table(table.clone(), None).await
|
||||
if let Ok(desc) = database.describe_table(table.clone()).await
|
||||
&& let Some(idx) = desc.indexes.iter().find(|i| i.columns == *columns)
|
||||
{
|
||||
out.drop_index_name = Some(idx.name.clone());
|
||||
@@ -1747,7 +1747,7 @@ async fn collect_echo_lookups(
|
||||
child_column,
|
||||
},
|
||||
} => {
|
||||
if let Ok(desc) = database.describe_table(child_table.clone(), None).await
|
||||
if let Ok(desc) = database.describe_table(child_table.clone()).await
|
||||
&& let Some(rel) = desc.outbound_relationships.iter().find(|r| {
|
||||
// The Endpoints drop selector is single-column
|
||||
// (ADR-0043 keeps DROP by-endpoints single-column;
|
||||
@@ -1771,7 +1771,7 @@ async fn collect_echo_lookups(
|
||||
// resolver API would be the next step if schemas grow.
|
||||
if let Ok(tables) = database.list_tables().await {
|
||||
for table in tables {
|
||||
if let Ok(desc) = database.describe_table(table.clone(), None).await
|
||||
if let Ok(desc) = database.describe_table(table.clone()).await
|
||||
&& desc.outbound_relationships.iter().any(|r| r.name == *name)
|
||||
{
|
||||
out.drop_relationship = Some((name.clone(), table.clone()));
|
||||
@@ -1795,8 +1795,8 @@ async fn collect_echo_lookups(
|
||||
// *before* execution to know which `ADD COLUMN` lines to
|
||||
// emit. The parent columns here are the explicit DSL list,
|
||||
// paired positionally with the child list.
|
||||
let parent_desc = database.describe_table(parent_table.clone(), None).await;
|
||||
let child_desc = database.describe_table(child_table.clone(), None).await;
|
||||
let parent_desc = database.describe_table(parent_table.clone()).await;
|
||||
let child_desc = database.describe_table(child_table.clone()).await;
|
||||
if let (Ok(parent_desc), Ok(child_desc)) = (parent_desc, child_desc) {
|
||||
let mut new_columns: Vec<(String, crate::dsl::types::Type)> = Vec::new();
|
||||
for (child_col, parent_col) in child_columns.iter().zip(parent_columns) {
|
||||
@@ -2064,7 +2064,7 @@ async fn enrich_check_violation(
|
||||
.await
|
||||
.map(|v| v.to_string());
|
||||
// The rule itself — the column's compiled CHECK expression.
|
||||
if let Ok(desc) = database.describe_table(table.to_string(), None).await
|
||||
if let Ok(desc) = database.describe_table(table.to_string()).await
|
||||
&& let Some(col) = desc.columns.iter().find(|c| c.name == column)
|
||||
{
|
||||
facts.check_rule.clone_from(&col.check);
|
||||
@@ -2272,7 +2272,7 @@ async fn user_value_for_column_with_schema(
|
||||
} = command
|
||||
{
|
||||
let desc = database
|
||||
.describe_table(table.to_string(), None)
|
||||
.describe_table(table.to_string())
|
||||
.await
|
||||
.ok()?;
|
||||
// Build the natural-order column list the same way
|
||||
@@ -2311,7 +2311,7 @@ async fn user_value_for_column_with_schema(
|
||||
&& literal_rows.len() == 1
|
||||
{
|
||||
let desc = database
|
||||
.describe_table(table.to_string(), None)
|
||||
.describe_table(table.to_string())
|
||||
.await
|
||||
.ok()?;
|
||||
let idx = desc.columns.iter().position(|c| c.name == column)?;
|
||||
@@ -2930,7 +2930,7 @@ async fn execute_command_typed(
|
||||
.await
|
||||
.map(|d| CommandOutcome::Schema(Some(d))),
|
||||
Command::ShowTable { name } => database
|
||||
.describe_table(name, src)
|
||||
.describe_table(name)
|
||||
.await
|
||||
.map(|d| CommandOutcome::Schema(Some(d))),
|
||||
// ADR-0044: a named relationship renders as a diagram (App-side),
|
||||
@@ -2983,14 +2983,14 @@ async fn execute_command_typed(
|
||||
filter,
|
||||
limit,
|
||||
} => database
|
||||
.query_data(name, filter, limit, src)
|
||||
.query_data(name, filter, limit)
|
||||
.await
|
||||
.map(CommandOutcome::Query),
|
||||
// A SQL `SELECT` (advanced mode; ADR-0030 §6, ADR-0031).
|
||||
// The grammar walker has already validated `sql` is in
|
||||
// the supported subset; the worker runs it as text.
|
||||
Command::Select { sql } => database
|
||||
.run_select(sql, src)
|
||||
.run_select(sql)
|
||||
.await
|
||||
.map(CommandOutcome::Query),
|
||||
// A SQL `INSERT` (advanced mode; ADR-0033 §1). Grammar-as-
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -173,7 +173,7 @@ fn rebuild_against_populated_db_wipes_and_reloads() {
|
||||
.expect("rebuild");
|
||||
});
|
||||
let rows = rt()
|
||||
.block_on(async { db.query_data("Customers".to_string(), None, None, None).await })
|
||||
.block_on(async { db.query_data("Customers".to_string(), None, None).await })
|
||||
.unwrap();
|
||||
assert_eq!(rows.rows.len(), 1);
|
||||
assert_eq!(rows.rows[0][1].as_deref(), Some("Edna"));
|
||||
|
||||
@@ -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.
|
||||
|
||||
+6
-6
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -64,7 +64,7 @@ fn created_table_appears_with_playground_types() {
|
||||
assert!(tables.contains(&"Widget".to_string()));
|
||||
|
||||
let desc = r
|
||||
.block_on(db.describe_table("Widget".to_string(), None))
|
||||
.block_on(db.describe_table("Widget".to_string()))
|
||||
.expect("describe");
|
||||
let types: Vec<(String, Option<Type>)> = desc
|
||||
.columns
|
||||
@@ -98,7 +98,7 @@ fn integer_primary_key_is_plain_int() {
|
||||
))
|
||||
.expect("create");
|
||||
let desc = r
|
||||
.block_on(db.describe_table("T".to_string(), None))
|
||||
.block_on(db.describe_table("T".to_string()))
|
||||
.expect("describe");
|
||||
assert_eq!(desc.columns[0].user_type, Some(Type::Int));
|
||||
}
|
||||
@@ -137,7 +137,7 @@ fn serial_pk_autoincrements_in_multi_column_table() {
|
||||
}
|
||||
|
||||
let data = r
|
||||
.block_on(db.query_data("T".to_string(), None, None, None))
|
||||
.block_on(db.query_data("T".to_string(), None, None))
|
||||
.expect("query");
|
||||
let id_idx = data
|
||||
.columns
|
||||
@@ -220,7 +220,7 @@ fn table_without_primary_key_is_allowed() {
|
||||
))
|
||||
.expect("insert into PK-less table");
|
||||
let data = r
|
||||
.block_on(db.query_data("Notes".to_string(), None, None, None))
|
||||
.block_on(db.query_data("Notes".to_string(), None, None))
|
||||
.expect("query");
|
||||
assert_eq!(data.rows.len(), 1);
|
||||
}
|
||||
@@ -299,7 +299,7 @@ fn default_is_applied_when_column_omitted() {
|
||||
))
|
||||
.expect("insert");
|
||||
let data = r
|
||||
.block_on(db.query_data("T".to_string(), None, None, None))
|
||||
.block_on(db.query_data("T".to_string(), None, None))
|
||||
.expect("query");
|
||||
let n_idx = data.columns.iter().position(|c| c == "n").expect("n column");
|
||||
assert_eq!(data.rows[0][n_idx].as_deref(), Some("7"), "DEFAULT 7 applied");
|
||||
@@ -381,7 +381,7 @@ fn check_default_and_composite_unique_survive_rebuild() {
|
||||
// A valid row inserts; DEFAULT n=7 survived.
|
||||
r.block_on(ins("1", "1", "5")).expect("valid row");
|
||||
let data = r
|
||||
.block_on(db.query_data("T".to_string(), None, None, None))
|
||||
.block_on(db.query_data("T".to_string(), None, None))
|
||||
.expect("query");
|
||||
let n_idx = data.columns.iter().position(|c| c == "n").expect("n column");
|
||||
assert_eq!(data.rows[0][n_idx].as_deref(), Some("7"), "DEFAULT survived rebuild");
|
||||
@@ -679,7 +679,7 @@ fn sql_create_table_is_one_undo_step() {
|
||||
/// Sorted `id` column values of table `T`.
|
||||
fn ids(db: &Database, r: &tokio::runtime::Runtime) -> Vec<Option<String>> {
|
||||
let d = r
|
||||
.block_on(db.query_data("T".to_string(), None, None, None))
|
||||
.block_on(db.query_data("T".to_string(), None, None))
|
||||
.expect("query");
|
||||
let idx = d.columns.iter().position(|c| c == "id").expect("id column");
|
||||
let mut v: Vec<Option<String>> = d.rows.iter().map(|row| row[idx].clone()).collect();
|
||||
@@ -801,7 +801,7 @@ fn dropping_a_column_a_table_check_references_fails_cleanly() {
|
||||
|
||||
// The table is intact: both columns survive (rollback) ...
|
||||
let desc = r
|
||||
.block_on(db.describe_table("T".to_string(), None))
|
||||
.block_on(db.describe_table("T".to_string()))
|
||||
.expect("describe still works");
|
||||
assert_eq!(
|
||||
desc.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>(),
|
||||
@@ -925,14 +925,14 @@ fn foreign_key_creates_named_relationship_visible_in_describe() {
|
||||
.expect("create child with FK");
|
||||
|
||||
// The child has an outbound relationship; the parent an inbound one.
|
||||
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe child");
|
||||
let child = r.block_on(db.describe_table("child".to_string())).expect("describe child");
|
||||
assert_eq!(child.outbound_relationships.len(), 1, "child references parent");
|
||||
let rel = &child.outbound_relationships[0];
|
||||
assert_eq!(rel.name, "parent_id_to_child_pid", "auto-named per ADR-0013");
|
||||
assert_eq!(rel.other_table, "parent");
|
||||
assert_eq!(rel.local_columns, vec!["pid".to_string()]);
|
||||
|
||||
let parent = r.block_on(db.describe_table("parent".to_string(), None)).expect("describe parent");
|
||||
let parent = r.block_on(db.describe_table("parent".to_string())).expect("describe parent");
|
||||
assert_eq!(parent.inbound_relationships.len(), 1, "parent is referenced by child");
|
||||
}
|
||||
|
||||
@@ -954,7 +954,7 @@ fn explicit_constraint_name_is_used() {
|
||||
Some("create table child (id serial primary key, pid int, constraint child_to_parent foreign key (pid) references parent(id))".to_string()),
|
||||
))
|
||||
.expect("create child with named FK");
|
||||
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe");
|
||||
let child = r.block_on(db.describe_table("child".to_string())).expect("describe");
|
||||
assert_eq!(child.outbound_relationships[0].name, "child_to_parent");
|
||||
}
|
||||
|
||||
@@ -974,7 +974,7 @@ fn bare_references_resolves_to_parent_single_column_pk() {
|
||||
Some("create table child (id serial primary key, pid int references parent)".to_string()),
|
||||
))
|
||||
.expect("create child with bare REFERENCES");
|
||||
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe");
|
||||
let child = r.block_on(db.describe_table("child".to_string())).expect("describe");
|
||||
assert_eq!(child.outbound_relationships[0].other_columns, vec!["id".to_string()], "resolved to parent PK");
|
||||
}
|
||||
|
||||
@@ -1108,7 +1108,7 @@ fn create_table_with_fk_is_one_undo_step() {
|
||||
// parent (now un-referenced) can be described without a dangling rel.
|
||||
r.block_on(db.undo()).expect("undo").expect("a step was undone");
|
||||
assert!(!r.block_on(db.list_tables()).unwrap().contains(&"child".to_string()));
|
||||
let parent = r.block_on(db.describe_table("parent".to_string(), None)).expect("describe parent");
|
||||
let parent = r.block_on(db.describe_table("parent".to_string())).expect("describe parent");
|
||||
assert!(parent.inbound_relationships.is_empty(), "the relationship was undone with the table");
|
||||
}
|
||||
|
||||
@@ -1152,7 +1152,7 @@ fn foreign_key_on_delete_cascade_takes_effect() {
|
||||
))
|
||||
.expect("delete parent");
|
||||
let child_rows = r
|
||||
.block_on(db.query_data("child".to_string(), None, None, None))
|
||||
.block_on(db.query_data("child".to_string(), None, None))
|
||||
.expect("query child");
|
||||
assert!(child_rows.rows.is_empty(), "ON DELETE CASCADE removed the child row");
|
||||
}
|
||||
@@ -1232,7 +1232,7 @@ fn fk_survives_a_rebuild_triggering_column_add() {
|
||||
.expect("add column via rebuild");
|
||||
|
||||
// The relationship still exists after the rebuild.
|
||||
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe");
|
||||
let child = r.block_on(db.describe_table("child".to_string())).expect("describe");
|
||||
assert_eq!(child.outbound_relationships.len(), 1, "FK survived the column-add rebuild");
|
||||
// And the engine still enforces it (now and after a fresh rebuild).
|
||||
insert_parent_row(&db, &r);
|
||||
@@ -1275,7 +1275,7 @@ fn fk_referential_actions_survive_rebuild() {
|
||||
))
|
||||
.expect("create");
|
||||
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), None)).expect("rebuild");
|
||||
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe");
|
||||
let child = r.block_on(db.describe_table("child".to_string())).expect("describe");
|
||||
let rel = &child.outbound_relationships[0];
|
||||
assert_eq!(rel.on_delete, ReferentialAction::Cascade, "ON DELETE survived rebuild");
|
||||
assert_eq!(rel.on_update, ReferentialAction::SetNull, "ON UPDATE survived rebuild");
|
||||
@@ -1299,7 +1299,7 @@ fn dropping_the_child_clears_the_fk_relationship() {
|
||||
.expect("create");
|
||||
r.block_on(db.drop_table("child".to_string(), Some("drop table child".to_string())))
|
||||
.expect("drop child");
|
||||
let parent = r.block_on(db.describe_table("parent".to_string(), None)).expect("describe parent");
|
||||
let parent = r.block_on(db.describe_table("parent".to_string())).expect("describe parent");
|
||||
assert!(parent.inbound_relationships.is_empty(), "dropping the child cleared the relationship");
|
||||
}
|
||||
|
||||
@@ -1341,7 +1341,7 @@ fn bare_self_reference_resolves_to_own_pk() {
|
||||
Some("create table emp (id int primary key, mgr int references emp)".to_string()),
|
||||
))
|
||||
.expect("create self-referential emp with a bare reference");
|
||||
let emp = r.block_on(db.describe_table("emp".to_string(), None)).expect("describe");
|
||||
let emp = r.block_on(db.describe_table("emp".to_string())).expect("describe");
|
||||
assert_eq!(emp.outbound_relationships[0].other_columns, vec!["id".to_string()], "bare self-ref resolved to own PK");
|
||||
// Enforced: a non-existent manager is rejected.
|
||||
r.block_on(db.insert(
|
||||
|
||||
@@ -154,7 +154,7 @@ fn delete_without_where_runs_across_all_rows() {
|
||||
let csv = read_csv(&project, "t").unwrap_or_default();
|
||||
assert!(!csv.contains('a') && !csv.contains('b') && !csv.contains('c'), "no rows left: {csv:?}");
|
||||
let remaining = rt
|
||||
.block_on(db.query_data("t".to_string(), None, None, None))
|
||||
.block_on(db.query_data("t".to_string(), None, None))
|
||||
.expect("query t");
|
||||
assert!(remaining.rows.is_empty(), "table empty after unfiltered delete");
|
||||
}
|
||||
@@ -302,8 +302,8 @@ fn cascade_to_two_children_reports_both() {
|
||||
assert_eq!(by_child.get("Orders"), Some(&2), "two orders cascaded");
|
||||
assert_eq!(by_child.get("Reviews"), Some(&1), "one review cascaded");
|
||||
// Both child CSVs re-persisted to the post-cascade (empty) state.
|
||||
let orders = rt.block_on(db.query_data("Orders".to_string(), None, None, None)).unwrap();
|
||||
let reviews = rt.block_on(db.query_data("Reviews".to_string(), None, None, None)).unwrap();
|
||||
let orders = rt.block_on(db.query_data("Orders".to_string(), None, None)).unwrap();
|
||||
let reviews = rt.block_on(db.query_data("Reviews".to_string(), None, None)).unwrap();
|
||||
assert!(orders.rows.is_empty() && reviews.rows.is_empty(), "both children emptied");
|
||||
let _ = &project;
|
||||
}
|
||||
@@ -361,7 +361,7 @@ fn delete_violating_fk_fails_and_persists_nothing() {
|
||||
let result = run_delete(&db, &rt, input);
|
||||
assert!(result.is_err(), "delete of a referenced parent must be rejected");
|
||||
// Rolled back: Alice survives.
|
||||
let customers = rt.block_on(db.query_data("Customers".to_string(), None, None, None)).unwrap();
|
||||
let customers = rt.block_on(db.query_data("Customers".to_string(), None, None)).unwrap();
|
||||
assert_eq!(customers.rows.len(), 1, "parent row preserved after rejected delete");
|
||||
// No history line for the failed statement (written only on success).
|
||||
let history = std::fs::read_to_string(project.path().join("history.log")).unwrap_or_default();
|
||||
|
||||
@@ -149,7 +149,7 @@ fn seed(db: &Database, rt: &tokio::runtime::Runtime, sql: &str) {
|
||||
}
|
||||
|
||||
fn query(db: &Database, rt: &tokio::runtime::Runtime, table: &str) -> Vec<Vec<Option<String>>> {
|
||||
rt.block_on(db.query_data(table.to_string(), None, None, None))
|
||||
rt.block_on(db.query_data(table.to_string(), None, None))
|
||||
.unwrap_or_else(|e| panic!("query_data {table}: {e:?}"))
|
||||
.rows
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ fn make_t_with_index(db: &Database, r: &tokio::runtime::Runtime) -> String {
|
||||
}
|
||||
|
||||
fn index_names(db: &Database, r: &tokio::runtime::Runtime) -> Vec<String> {
|
||||
r.block_on(db.describe_table("T".to_string(), None))
|
||||
r.block_on(db.describe_table("T".to_string()))
|
||||
.expect("describe")
|
||||
.indexes
|
||||
.into_iter()
|
||||
|
||||
@@ -150,7 +150,7 @@ fn drop_table_is_one_undo_step_and_restores_data() {
|
||||
assert!(r.block_on(db.undo()).expect("undo").is_some(), "the drop was one undo step");
|
||||
assert!(r.block_on(db.list_tables()).unwrap().contains(&"T".to_string()));
|
||||
let data = r
|
||||
.block_on(db.query_data("T".to_string(), None, None, None))
|
||||
.block_on(db.query_data("T".to_string(), None, None))
|
||||
.expect("query");
|
||||
assert_eq!(data.rows.len(), 1, "the dropped row was restored by undo");
|
||||
}
|
||||
|
||||
+10
-16
@@ -215,7 +215,7 @@ fn decimal_aggregation_display_trims_ieee754_noise() {
|
||||
|
||||
// The reported case: the aggregate no longer leaks float noise.
|
||||
let agg = rt
|
||||
.block_on(db.run_select("select sum(price * qty) from Products".to_string(), None))
|
||||
.block_on(db.run_select("select sum(price * qty) from Products".to_string()))
|
||||
.expect("aggregate select");
|
||||
assert_eq!(
|
||||
agg.rows[0][0].as_deref(),
|
||||
@@ -226,7 +226,7 @@ fn decimal_aggregation_display_trims_ieee754_noise() {
|
||||
// Raw decimal column is still exact — TEXT storage preserves
|
||||
// the input string verbatim, including the trailing zero.
|
||||
let raw = rt
|
||||
.block_on(db.run_select("select price from Products".to_string(), None))
|
||||
.block_on(db.run_select("select price from Products".to_string()))
|
||||
.expect("raw decimal select");
|
||||
let prices: Vec<&str> = raw.rows.iter().map(|r| r[0].as_deref().unwrap()).collect();
|
||||
assert_eq!(
|
||||
@@ -240,10 +240,7 @@ fn decimal_aggregation_display_trims_ieee754_noise() {
|
||||
fn database_run_select_constant_returns_a_single_row() {
|
||||
let (_p, db, _dir) = open_project_db();
|
||||
let data = rt()
|
||||
.block_on(db.run_select(
|
||||
"select 1".to_string(),
|
||||
Some("select 1".to_string()),
|
||||
))
|
||||
.block_on(db.run_select("select 1".to_string()))
|
||||
.expect("`select 1` runs clean");
|
||||
assert_eq!(data.rows.len(), 1, "one result row");
|
||||
assert_eq!(data.rows[0].len(), 1, "one column");
|
||||
@@ -288,7 +285,7 @@ fn database_run_select_from_user_table_returns_inserted_rows() {
|
||||
.expect("insert row");
|
||||
});
|
||||
let data = rt
|
||||
.block_on(db.run_select("select Name from T".to_string(), None))
|
||||
.block_on(db.run_select("select Name from T".to_string()))
|
||||
.expect("SELECT runs");
|
||||
assert_eq!(data.rows.len(), 1);
|
||||
assert_eq!(data.rows[0][0].as_deref(), Some("Ada"));
|
||||
@@ -336,7 +333,7 @@ fn database_run_select_recovers_bool_column_type() {
|
||||
.expect("insert row");
|
||||
});
|
||||
let data = rt
|
||||
.block_on(db.run_select("select Active from Products".to_string(), None))
|
||||
.block_on(db.run_select("select Active from Products".to_string()))
|
||||
.expect("SELECT runs");
|
||||
assert_eq!(data.rows.len(), 2);
|
||||
assert_eq!(data.column_types, vec![Some(Type::Bool)]);
|
||||
@@ -374,7 +371,7 @@ fn database_run_select_recovers_text_type_through_alias() {
|
||||
// playground type is recovered.
|
||||
let data = rt
|
||||
.block_on(
|
||||
db.run_select("select Name as n from Users".to_string(), None),
|
||||
db.run_select("select Name as n from Users".to_string()),
|
||||
)
|
||||
.expect("SELECT runs");
|
||||
assert_eq!(data.columns, vec!["n".to_string()]);
|
||||
@@ -402,7 +399,7 @@ fn database_run_select_computed_expression_stays_typeless() {
|
||||
.expect("insert");
|
||||
});
|
||||
let data = rt
|
||||
.block_on(db.run_select("select Score + 1 from T".to_string(), None))
|
||||
.block_on(db.run_select("select Score + 1 from T".to_string()))
|
||||
.expect("SELECT runs");
|
||||
assert_eq!(data.column_types, vec![None]);
|
||||
}
|
||||
@@ -439,7 +436,6 @@ fn engine_aggregate_in_where_routes_through_catalog() {
|
||||
let err = rt
|
||||
.block_on(db.run_select(
|
||||
"select id from T where count(score) > 0".to_string(),
|
||||
None,
|
||||
))
|
||||
.expect_err("engine should reject aggregate in WHERE");
|
||||
let DbError::Sqlite { .. } = &err else {
|
||||
@@ -512,7 +508,6 @@ fn engine_group_by_missing_routes_through_catalog() {
|
||||
let _ = rt
|
||||
.block_on(db.run_select(
|
||||
"select category, count(*) from T group by category".to_string(),
|
||||
None,
|
||||
))
|
||||
.expect("benign GROUP BY query runs");
|
||||
// Direct unit test on the matcher: ensure a message that
|
||||
@@ -574,7 +569,6 @@ fn engine_scalar_subquery_too_many_rows_routes_through_catalog() {
|
||||
let _ = rt
|
||||
.block_on(db.run_select(
|
||||
"select (select v from T) from T".to_string(),
|
||||
None,
|
||||
))
|
||||
.expect("benign scalar subquery query runs");
|
||||
let synthetic = DbError::Sqlite {
|
||||
@@ -624,13 +618,13 @@ fn database_run_select_type_recovery_works_on_empty_table() {
|
||||
});
|
||||
// No INSERT — the table is empty.
|
||||
let data_text = rt
|
||||
.block_on(db.run_select("select col_text from Empty".to_string(), None))
|
||||
.block_on(db.run_select("select col_text from Empty".to_string()))
|
||||
.expect("SELECT runs even on empty table");
|
||||
assert!(data_text.rows.is_empty());
|
||||
assert_eq!(data_text.column_types, vec![Some(Type::Text)]);
|
||||
|
||||
let data_blob = rt
|
||||
.block_on(db.run_select("select col_blob from Empty".to_string(), None))
|
||||
.block_on(db.run_select("select col_blob from Empty".to_string()))
|
||||
.expect("SELECT runs even on empty table");
|
||||
assert!(data_blob.rows.is_empty());
|
||||
assert_eq!(
|
||||
@@ -723,7 +717,7 @@ fn database_run_select_recovers_all_ten_playground_types() {
|
||||
for (col, expected_type) in cases {
|
||||
let sql = format!("select {col} from AllTypes");
|
||||
let data = rt
|
||||
.block_on(db.run_select(sql.clone(), None))
|
||||
.block_on(db.run_select(sql.clone()))
|
||||
.expect("SELECT runs");
|
||||
assert_eq!(
|
||||
data.column_types,
|
||||
|
||||
@@ -501,7 +501,7 @@ fn update_all_rows_flag_in_advanced_updates_every_row() {
|
||||
"the --all-rows update replays through the DSL fall-back; events: {events:?}"
|
||||
);
|
||||
let rows = rt
|
||||
.block_on(db.query_data("t".to_string(), None, None, None))
|
||||
.block_on(db.query_data("t".to_string(), None, None))
|
||||
.expect("query")
|
||||
.rows;
|
||||
assert_eq!(rows.len(), 2, "both rows present");
|
||||
|
||||
@@ -63,7 +63,7 @@ async fn insert_named(db: &Database, name: &str) {
|
||||
}
|
||||
|
||||
async fn row_count(db: &Database) -> usize {
|
||||
db.query_data("Customers".to_string(), None, None, None)
|
||||
db.query_data("Customers".to_string(), None, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.rows
|
||||
@@ -306,7 +306,7 @@ async fn sql_delete(db: &Database, input: &str) {
|
||||
}
|
||||
|
||||
async fn count_t(db: &Database) -> usize {
|
||||
db.query_data("T".to_string(), None, None, None)
|
||||
db.query_data("T".to_string(), None, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.rows
|
||||
@@ -378,7 +378,7 @@ fn undo_restores_db_and_csv_consistently() {
|
||||
// Both the database read model and the on-disk CSV are
|
||||
// restored — the (db, csv) pair stays consistent.
|
||||
assert_eq!(
|
||||
db.query_data("T".to_string(), None, None, None)
|
||||
db.query_data("T".to_string(), None, None)
|
||||
.await
|
||||
.unwrap()
|
||||
.rows
|
||||
|
||||
@@ -250,6 +250,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
|
||||
App(app) => match app {
|
||||
AppCommand::Quit => "App(Quit)".into(),
|
||||
AppCommand::Help { .. } => "App(Help)".into(),
|
||||
AppCommand::Hint => "App(Hint)".into(),
|
||||
AppCommand::Rebuild => "App(Rebuild)".into(),
|
||||
AppCommand::Save => "App(Save)".into(),
|
||||
AppCommand::SaveAs => "App(SaveAs)".into(),
|
||||
|
||||
Reference in New Issue
Block a user