Merge branch 'main' into website
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -108,6 +108,23 @@ Current decisions at a glance (each backed by an ADR):
|
||||
SQL `select` / `with` / `insert` / `update` / `delete`
|
||||
(ADR-0039). `EXPLAIN QUERY PLAN` never executes, so
|
||||
explaining a destructive command is safe.
|
||||
- **Continuous integration & release** (built on the `ci` branch,
|
||||
2026-06-15; decisions in `docs/ci/adr/` — **ADR-ci-001/002/003**,
|
||||
a namespace kept separate from the main ADR sequence to avoid
|
||||
cross-branch number collisions, like the website's): a self-hosted
|
||||
**Gitea Actions** pipeline built on a **nix flake** (pinned Rust
|
||||
`1.95.0` — one source of toolchain for dev *and* CI) plus a
|
||||
prebuilt CI image. **Gate** (`ci.yaml`): `clippy -D warnings` +
|
||||
`cargo test` on every branch push / PR. **Release** on a `v*` tag
|
||||
(`release.yaml`): the four non-macOS **D1** targets cross-built
|
||||
with `cargo-zigbuild` (Linux musl static + standalone Windows
|
||||
`.exe`); the two macOS targets via the **dispatched**
|
||||
`release-macos.yaml` on a Tart Apple-Silicon runner (de-nix the
|
||||
`libiconv` load path + ad-hoc re-sign). All published to a Gitea
|
||||
release with `.sha256`s. **`fmt` is intentionally not gated yet**
|
||||
(the tree isn't stock-`rustfmt`-clean). `workflow_dispatch` is
|
||||
Gitea-default-branch-only, so `release-macos` is dispatchable once
|
||||
this lands on `main`.
|
||||
|
||||
## Repository layout
|
||||
|
||||
@@ -347,8 +364,14 @@ not yet implemented:
|
||||
Ctrl-Enter submits.
|
||||
- **Tab completion** (I3), **syntax highlighting** (I4).
|
||||
- **ER diagram export** (V3).
|
||||
- **CI** (TT5): test infrastructure exists; CI workflow not
|
||||
yet configured.
|
||||
- **Full TT5** (CI): the pipeline is live (see the CI decision
|
||||
above / `docs/ci/adr/`), but "all tiers on all OSes" isn't
|
||||
complete — **Windows is build-only** (cross-compiled, not
|
||||
executed: no Windows runner) and **Tier 4** (PTY, TT4) isn't
|
||||
wired in CI.
|
||||
- **D3 packaging**: prebuilt binaries + checksums ship to Gitea
|
||||
releases, but the Homebrew / Scoop / winget / `cargo binstall`
|
||||
manifests are not done.
|
||||
|
||||
## Handoff notes
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -70,3 +70,65 @@ True UUIDs are intentionally **not** in the type set.
|
||||
- Learners who later need a true UUID column will find that the
|
||||
app does not provide one; this is a deliberate trade-off in
|
||||
favour of TUI legibility.
|
||||
|
||||
## Amendment 1 — display rounding of coerced doubles (2026-06-12)
|
||||
|
||||
Issue #32. The Decision keeps `decimal` exact by storing it as
|
||||
TEXT, noting that "numeric ops require casts" — the engine has no
|
||||
native decimal/BCD type (SQLite's storage classes are only NULL /
|
||||
INTEGER / REAL / TEXT / BLOB; `NUMERIC` is an affinity, not a
|
||||
type). What the original wording did not anticipate is that the
|
||||
engine performs that cast **implicitly**: `sum(price * qty)` over
|
||||
TEXT decimals coerces to an IEEE-754 double with no explicit cast,
|
||||
and the computed result carries no playground type (ADR-0030 §6),
|
||||
so it rendered with the double's full noise —
|
||||
`298.59999999999997` for `298.60`. For a teaching tool that is a
|
||||
confusing, off-topic lesson about float representation.
|
||||
|
||||
### Decision
|
||||
|
||||
**Round floating-point values to 15 significant figures for
|
||||
display only.** A double carries ~15–17 significant decimal digits
|
||||
and the noise lives in the last one or two; rounding to 15 then
|
||||
taking the shortest round-tripping form of the rounded value
|
||||
collapses `298.59999999999997` → `298.6` and
|
||||
`0.30000000000000004` → `0.3`. A clean value rounds to itself, so
|
||||
the result is never longer than before; non-finite values pass
|
||||
through. Implemented as `format_real_display` in `db.rs`.
|
||||
|
||||
The rounding is wired into **exactly one place — `format_cell`,
|
||||
the result-set / `show data` cell formatter** — because that is
|
||||
the only surface where the IEEE-754 noise actually appears: noise
|
||||
arises from *arithmetic/aggregation*, whose results flow through
|
||||
`format_cell`. Every other `f64`-to-string path deliberately keeps
|
||||
full precision, and the distinction is **semantic, not cosmetic**:
|
||||
|
||||
- **Persistence stays exact.** The CSV encoder
|
||||
(`persistence::csv_io::format_real`) keeps the shortest
|
||||
round-tripping form so a stored `real` survives save/load
|
||||
byte-for-byte — rounding there would corrupt data.
|
||||
- **Uniqueness dry-runs key on exact values.** `render_value`
|
||||
(the diagnostic/echo formatter) is reused as a *canonical
|
||||
identity key* by `dry_run_unique` (ADR-0029 §5) and
|
||||
`check_uniqueness_collisions` (ADR-0017 §4.3): they group rows
|
||||
by this string to predict the duplicates the engine would
|
||||
reject. Rounding there would merge two distinct doubles into one
|
||||
key and report a collision the engine — which compares exact
|
||||
values — would not. So `render_value` keeps `format!("{r}")`.
|
||||
(It also never displays a *computed* value, so it has no noise
|
||||
to trim.)
|
||||
- **FK-key matching and EXPLAIN-SQL literals keep full
|
||||
precision** — neither is a data-cell display.
|
||||
|
||||
Within `format_cell` the rounding applies to **all** REAL cells
|
||||
(stored `real` columns and computed results alike), for one
|
||||
consistent rule; the lost digits are at the double's precision
|
||||
limit, not real information, and a stored `real` typed by the user
|
||||
is itself noise-free so its display is unchanged in practice. Raw
|
||||
`decimal` columns are unaffected — they are TEXT and render
|
||||
verbatim, trailing zeros and all (`100.10`). Exact decimal
|
||||
*arithmetic* (a SQLite extension exposing
|
||||
`decimal_mul`/`decimal_sum`) was considered and rejected: it would
|
||||
require rewriting the user's standard-SQL operators into function
|
||||
calls, defeating both the "validated SQL runs verbatim" model and
|
||||
the goal of teaching ordinary SQL.
|
||||
|
||||
@@ -213,6 +213,14 @@ working copy.
|
||||
|
||||
### 6. Persistence ordering
|
||||
|
||||
> **Amended by ADR-0052 (2026-06-13, issue #30):** `history.log` is no
|
||||
> longer written inside the worker transaction. It is a *journal* of typed
|
||||
> commands, not state, so success journaling moved to the dispatch layer
|
||||
> (next to the already-top-level failure journaling); `commit-db-last` now
|
||||
> governs the three **state** targets only (db + `project.yaml` +
|
||||
> `data/*.csv`), which still commit atomically in the worker. The journal
|
||||
> write is best-effort (amends ADR-0040).
|
||||
|
||||
A successful user command produces effects in four targets:
|
||||
the SQLite database, `project.yaml`, the relevant
|
||||
`data/<table>.csv` file(s), and `history.log`. INV-2 from the
|
||||
|
||||
@@ -197,6 +197,16 @@ Referenced by:
|
||||
The relationship sections retain today's plain-text format
|
||||
to leave room for the future relationship-rendering ADR.
|
||||
|
||||
> **Superseded.** ADR-0044 replaced this prose block with compact
|
||||
> diagrams on relationship-subject surfaces (`show table`,
|
||||
> `add`/`drop relationship`). **ADR-0050 (2026-06-12, issue #28)** then
|
||||
> removed the relationship block entirely from incidental-DDL structure
|
||||
> echoes (`create table`, `add`/`drop`/`rename`/`change column`,
|
||||
> `add`/`drop index`) — those render structure only — and **deleted the
|
||||
> prose renderer**. The `References:` / `Referenced by:` format above is
|
||||
> retained here as documentation/provenance should the OOS-7
|
||||
> always-prose display setting ever be built.
|
||||
|
||||
### 6. Theme integration
|
||||
|
||||
Theme colors apply to the box-drawing characters via the
|
||||
|
||||
@@ -772,6 +772,58 @@ invalid_ident_does_not_fire_for_column_prefix_at_sql_expr_slot}`;
|
||||
`theme::function_colour_is_distinct_from_keyword_identifier_and_type`.
|
||||
See ADR-0031's status note for the grammar-side anchor.
|
||||
|
||||
## Amendment 7 — optional positional args reach the hint panel (2026-06-12)
|
||||
|
||||
Issue #26. At `seed <table> ▮` the hint panel showed only the
|
||||
`set` / `--seed` continuation chips and never mentioned the
|
||||
**optional row count** — even though a count (`seed users 50`) is
|
||||
the most common next move. The count is a bare positional
|
||||
`NumberLit` with no keyword/candidate text, so the candidate ladder
|
||||
can't surface it; and `seed <table>` is already a *complete*
|
||||
command, so the hint resolver short-circuits (empty expected set).
|
||||
|
||||
The existing `IntroProse` `HintMode` (ADR-0024 §HintMode-per-node;
|
||||
issue #4's CREATE-TABLE element hint) is the right tool — it shows
|
||||
prose that *introduces* a position whose first-class move has no
|
||||
candidate, with the keyword alternatives folded into the prose and
|
||||
Tab still cycling them. But it did not reach this position: a
|
||||
`Node::Hinted`'s mode lives in `pending_hint_mode`, which the very
|
||||
next match clears — including the **empty** match of a skipped
|
||||
`Optional`. The CREATE-TABLE element survives only because it sits
|
||||
in a *required* `Repeated(min:1)`; an optional positional followed
|
||||
by more optionals (the seed count) is cleared before the resolver
|
||||
reads it.
|
||||
|
||||
### Mechanism
|
||||
|
||||
A small, general carry: when `walk_optional` skips its inner (the
|
||||
inner didn't engage), it stashes any `IntroProse` key the inner
|
||||
left in `pending_hint_mode` into a new `WalkContext` field,
|
||||
`surviving_intro_hint: Option<(key, position)>`, **before** the
|
||||
empty match clears `pending_hint_mode`. The trailing optionals,
|
||||
which are not `IntroProse`, don't overwrite it. The hint snapshot
|
||||
keeps the key **only when `position == cursor`** (the slice end),
|
||||
so it shows while the cursor sits at the count slot but not once a
|
||||
later clause (`set …`) consumes input past it, nor once the count
|
||||
itself is supplied. The resolver returns that `IntroProse` even for
|
||||
an otherwise-complete command (ahead of the empty-expected
|
||||
short-circuit).
|
||||
|
||||
The seed grammar wraps the count in
|
||||
`Hinted { IntroProse("hint.seed_count"), NumberLit }`; the prose
|
||||
names the count (with its default 20) plus the `.column`
|
||||
column-fill form and the `set` / `--seed` keywords (user-chosen
|
||||
scope: mention every option). Only `IntroProse` is carried —
|
||||
`ProseOnly` / `ForceProse` mark *active* slots and reach the
|
||||
resolver through the normal path, unchanged. The CREATE-TABLE
|
||||
element (in a `Repeated`, not an `Optional`) is untouched.
|
||||
|
||||
This is a refinement of ADR-0024 §HintMode-per-node and a sibling
|
||||
of issue #4; no `AmbientHint` / renderer change. Covered by
|
||||
`input_render::{seed_count_is_advertised_at_the_optional_position,
|
||||
seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given,
|
||||
seed_count_hint_also_fires_after_a_column_fill_target}`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Deliberately deferred to keep this ADR shippable as a single
|
||||
|
||||
@@ -1480,6 +1480,76 @@ accumulators), the per-keystroke re-walk (ADR-0027's
|
||||
debounced cadence), and the ORDER BY no-fixup-needed
|
||||
clarification.
|
||||
|
||||
## Amendment 3 — bare table aliases in expression slots (2026-06-12)
|
||||
|
||||
Issue #31. A bare in-scope table alias typed where the grammar
|
||||
expects a column — `… GROUP BY o`, with `o` aliasing
|
||||
`FROM Orders o` — was a blind spot in two surfaces:
|
||||
|
||||
- **Completion (§10).** §10.5 narrows columns *past* a
|
||||
`qualifier .`, but the bare-ident slot before the dot offered
|
||||
only columns and function names, never the aliases themselves.
|
||||
A learner mid-typing `o` toward `o.<column>` got no Tab help.
|
||||
- **Diagnostics (§11.2).** §11.2 added `projection_alias_misplaced`
|
||||
for a *projection* alias used in a forbidden clause, but a bare
|
||||
*table* alias fell through to the generic `unknown_column`
|
||||
bare-reference check (§11.2's `matched.len() == 0` arm), which
|
||||
reported `no such column \`o\` on table \`Orders, …\`` — calling
|
||||
an in-scope alias an unknown column.
|
||||
|
||||
### What changes
|
||||
|
||||
1. **Completion offers in-scope FROM qualifiers at a bare
|
||||
`sql_expr_ident` slot** (one not already past a `qualifier .`).
|
||||
Each binding contributes its *qualifier* — the alias if it has
|
||||
one, else the table name (an aliased source must be referenced
|
||||
by its alias). Folded into the existing `IdentSource::Columns`
|
||||
candidate list so it sorts / dedups / colours uniformly. When
|
||||
the partial *exactly* matches an in-scope qualifier the alias
|
||||
source steps aside: discoverability is already served, and
|
||||
suppressing sibling aliases lets the diagnostic below surface
|
||||
(rather than being hidden by the `typing_over_diag` path).
|
||||
|
||||
2. **A bare ident matching an in-scope qualifier now emits a
|
||||
targeted diagnostic** instead of `unknown_column`, checked in
|
||||
the `matched.len() == 0` arm *after* the projection-alias check
|
||||
(so an ORDER-BY projection-alias reference still wins). It is a
|
||||
drop-in replacement at the same span and `Error` severity — only
|
||||
the message text changes — so the validity verdict, token
|
||||
overlay, and hint-panel paths behave exactly as they did for
|
||||
`unknown_column`:
|
||||
- `diagnostic.alias_used_as_column` — `` `o` is a table alias —
|
||||
write `o.<column>` to reference one of its columns `` (the
|
||||
binding has an alias), or
|
||||
- `diagnostic.table_used_as_column` — same shape, "is a table"
|
||||
(an un-aliased table source).
|
||||
|
||||
Two guards keep the qualified-form advice correct (both covered
|
||||
by regression tests):
|
||||
- **SQL only.** The branch fires only for `role ==
|
||||
"sql_expr_ident"`. The DSL `Expr` (role `expr_column`) reaches
|
||||
the same arm but has no `table.column` syntax, so a DSL bare
|
||||
table-name ref keeps the generic `unknown_column` — advising
|
||||
the qualified form there would be wrong.
|
||||
- **Effective-qualifier match.** It matches the binding's
|
||||
*effective qualifier* — the alias if present, else the table
|
||||
name — not the table name independently. An aliased source
|
||||
must be referenced by its alias (`FROM a x … GROUP BY a` is
|
||||
invalid SQL), so the shadowed real name `a` falls through to
|
||||
`unknown_column` rather than being advised as `a.<column>`.
|
||||
This mirrors the completion side's qualifier rule exactly.
|
||||
|
||||
A genuine unknown column (matching no alias, table, or column)
|
||||
still reports `unknown_column` verbatim.
|
||||
|
||||
The message tail is deliberately clause-neutral ("to reference
|
||||
one of its columns") rather than GROUP-BY-specific, because the
|
||||
bare-reference arm fires across the projection, `WHERE`,
|
||||
`GROUP BY`, and `HAVING`.
|
||||
|
||||
This is an additive refinement of §10 and §11.2; no grammar node
|
||||
changes.
|
||||
|
||||
## See also
|
||||
|
||||
- ADR-0005 — the ten-type vocabulary §10 resolves back to.
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
Accepted. **Amended by ADR-0052 (2026-06-13, issue #30):** the status
|
||||
field gains an optional `:adv` mode suffix (`ok:adv` / `err:adv`) — the
|
||||
"non-breaking future extension" this ADR reserved — and **success
|
||||
journaling moves out of the worker to the dispatch layer**
|
||||
(`spawn_dsl_dispatch` / `run_replay` / app-command sites), next to the
|
||||
failure path, where the submission mode is in scope. `status_is_ok` keys
|
||||
off the base token, so `ok:adv` replays like `ok`.
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
**Accepted** — 2026-05-30 (issue #9). Amends the output conventions of
|
||||
ADR-0014 (data operations), ADR-0028 (query plans / `explain`), and
|
||||
ADR-0019 (failure rendering); builds on ADR-0037's mode-tagged echo
|
||||
line.
|
||||
line. **Amended by ADR-0052 (2026-06-13, issue #30):** a `history.log`
|
||||
*journal*-write failure on a **successful** command is no longer fatal —
|
||||
journaling moved to the dispatch layer (after the db commit), so it is
|
||||
best-effort (logged + ignored), consistent with the failure-journal path.
|
||||
State-write failures (yaml/csv/db) remain fatal.
|
||||
|
||||
## Context
|
||||
|
||||
|
||||
@@ -103,6 +103,10 @@ Prose-retained surfaces (**unchanged** from ADR-0016 §5):
|
||||
`add`/`drop index` — keep the terse `References:` /
|
||||
`Referenced by:` prose. A simple `add column` on a heavily-related
|
||||
table should not print a wall of diagrams.
|
||||
*(**Superseded 2026-06-12 by ADR-0050** (issue #28): these incidental
|
||||
DDL echoes now render **structure only** — no relationship block at
|
||||
all, neither prose nor diagram. The prose renderer was deleted. The
|
||||
diagram surfaces below are unchanged.)*
|
||||
|
||||
So this **partially supersedes ADR-0016 §5**: the prose block is
|
||||
replaced by diagrams on the relationship-subject surfaces and
|
||||
|
||||
@@ -525,7 +525,9 @@ All tiers green, zero skips; clippy clean (nursery).
|
||||
submits over a multi-logical-line buffer. DA3/DA4 keep a single
|
||||
logical line; this remains a separate, deferred feature.
|
||||
- **Readline shortcuts (I1b)** — Ctrl-A/E/W/K/U stay reserved-deferred;
|
||||
not touched here.
|
||||
not touched here. *(Superseded 2026-06-12: I1b is now in scope and
|
||||
decided by **ADR-0049** — Esc-clear + Ctrl-A/E/W/K/U in the input
|
||||
field, issue #29.)*
|
||||
- **Cross-session sidebar persistence** — visibility is session-only
|
||||
(DB1); persisting it would amend ADR-0015.
|
||||
- **The output panel as a third navigation focus target** — navigation
|
||||
@@ -554,3 +556,27 @@ All tiers green, zero skips; clippy clean (nursery).
|
||||
and is accepted: 90 is the screencast width, real terminals sit well
|
||||
to one side of it, and `Ctrl-O` peek covers the in-between case. The
|
||||
`90` threshold is a tunable constant.
|
||||
|
||||
## Amendment 1 — focus accent is a colour, not bold (2026-06-12)
|
||||
|
||||
Issue #25. DC3's "accent border" on the focused sidebar panel was
|
||||
first implemented as bright `theme.fg` **plus `Modifier::BOLD`** on
|
||||
the box-drawing border. Bold box-drawing glyphs render as broken /
|
||||
gapped line-art in the asciinema player used for the website casts
|
||||
(vertical strokes don't connect to the corner glyphs) and are
|
||||
fragile in some terminals.
|
||||
|
||||
**`panel_border_style` now marks focus with a non-bold accent
|
||||
colour — `theme.mode_simple` (blue) — and never `Modifier::BOLD` on
|
||||
a border.** The unfocused border stays muted `theme.border`. This
|
||||
makes the ADR's "accent border (lazygit convention)" wording
|
||||
literal — it is now a true accent hue rather than bold bright-fg —
|
||||
and is what renders cleanly in casts. Bold remains fine on *text*
|
||||
spans (titles, key hints); the constraint is specifically that
|
||||
box-drawing borders carry no bold attribute.
|
||||
|
||||
Note: this is a pure style change. The Tier-2 snapshots are
|
||||
text-only (`render_to_string` captures cell symbols, not styles),
|
||||
so none needed re-accepting; the Tier-1 `panel_border_style`
|
||||
assertion was updated and a render-level test now checks the actual
|
||||
border cells carry the accent colour and no bold.
|
||||
|
||||
@@ -317,6 +317,8 @@ with the implementation):
|
||||
| `url`/`website`/`homepage` · `color`/`colour` | URL / hex colour | text |
|
||||
| `price`/`amount`/`cost`/`salary`/`balance`/`total` | currency-range number | numeric |
|
||||
| `age` · `quantity`/`qty`/`stock`/`count` | 18–80 · small int | numeric |
|
||||
| `year`/`*_year`/`published`/`founded` (Amendment 1) | bounded year (birth window for `birth`/`born`/`dob`, else 1950–2025) | int |
|
||||
| `priority`/`prio` · `severity` · `rating`/`stars` (Amendment 1) | built-in `PickFrom` value set | text/int |
|
||||
| `date`/`*_date` | date, recent ~3 yr window | date |
|
||||
| `dob`/`birthday` | date, adult window (18–80 yr ago) | date |
|
||||
| `timestamp`/`datetime` · `created_at`/`updated_at`/`*_at` | datetime, recent window (`updated_at` ≥ `created_at`) | datetime |
|
||||
@@ -675,3 +677,66 @@ the regression floor.
|
||||
derive-`IN`-else-friendly-fail tier.
|
||||
- **`set`-driven NULL / per-column report / recursive parent seed:**
|
||||
deferred — see Out of scope.
|
||||
|
||||
## Amendment 1 — year-as-int + conventional choice sets (2026-06-12)
|
||||
|
||||
Two SD2-style refinements to the D7 catalogue, surfaced while writing
|
||||
the website `seed` docs. Both are additive name rules; no change to D8
|
||||
(type fallback), the executor, or the grammar.
|
||||
|
||||
### Issue #33 — year-like `int` columns
|
||||
|
||||
A column such as `published` or `birth_year` was just an `int`, so it
|
||||
fell through to the unbounded type-based `int` path (D8) and produced
|
||||
nonsense like `9419` or `1426` — implausible as years, undercutting the
|
||||
"realistic data" pedagogy. Added an **`int`-gated** year rule, placed
|
||||
*after* the quantity rule (so `year_count` stays a count):
|
||||
|
||||
- `year` / `*_year` / `published` / `founded` → **`YearRecent`**, a
|
||||
bounded window of **1950–2025** (75 years relative to the fixed
|
||||
`REF_YEAR`, wide enough for published books / founding years /
|
||||
release years; matches the issue's own `between 1950 and 2020`
|
||||
workaround).
|
||||
- the same with a `birth` / `born` / `dob` token (e.g. `birth_year`) →
|
||||
**`YearBirth`**, mirroring the existing `dob → DateAdult` adult birth
|
||||
window as years (**1945–2007**).
|
||||
|
||||
Both emit a plain `int`. `published` / `founded` are included
|
||||
(user-confirmed): an `int` so named is almost always a year (a flag
|
||||
would be `is_published`). The generators are **not** added to the D9
|
||||
named-generator vocabulary — explicit control stays with `set <col>
|
||||
between <lo> and <hi>`.
|
||||
|
||||
### Issue #34 — built-in value sets for conventional choice names
|
||||
|
||||
D12 deliberately does not guess values for enum-ish names. For a few,
|
||||
though, there is a near-canonical small set that reads far better than
|
||||
lorem text. Added a **type-gated `PickFrom`** lookup (reusing the
|
||||
existing generator — no new machinery), placed ahead of the enum-ish
|
||||
fallthrough:
|
||||
|
||||
| Name (tokens) | text | int |
|
||||
|---|---|---|
|
||||
| `priority` / `prio` | `low`/`medium`/`high` | `1`/`2`/`3` |
|
||||
| `severity` | `low`/`medium`/`high`/`critical` | `1`/`2`/`3`/`4` |
|
||||
| `rating` / `stars` | — | `1`–`5` |
|
||||
|
||||
A user-declared `IN`-CHECK (D17) still wins — it is resolved before the
|
||||
heuristics. Any name that gains a set is **removed from the enum-ish
|
||||
advisory trigger** (`priority` left `ENUM_TOKENS`); since the advisory
|
||||
(D13) only fires on `Generator::Generic`, a `PickFrom` name is excluded
|
||||
either way, but the removal keeps `is_enum_ish` semantically "names seed
|
||||
still can't guess".
|
||||
|
||||
**`status` is deliberately excluded** (user-confirmed on the issue): its
|
||||
real values are too domain-specific (`active/inactive`,
|
||||
`open/closed/pending`, `draft/published`, …), so it keeps the D12
|
||||
"don't guess" stance — generic text + the advisory pointing at `set
|
||||
status in (…)`. `state` stays its US-state-name generator (D7);
|
||||
`type`/`kind`/`category`/`stage`/`gender` and `size`/`tier`/`plan` were
|
||||
considered and left to the advisory.
|
||||
|
||||
**Website follow-up** (tracked on the `website` branch, not here): the
|
||||
`seed` cast exercises a `tickets` table with `priority`; it should be
|
||||
re-recorded so the table tightens once `priority` collapses to a short
|
||||
value — likely subsumed by the pre-publication cast sweep.
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# ADR-0049: Input-field readline keymap — Esc-clear + Ctrl-A/E/W/K/U (I1b)
|
||||
|
||||
## Status
|
||||
|
||||
**Accepted + implemented 2026-06-12 (issue #29).** Closes Gitea **#29**
|
||||
("Command input keystroke support") and the deferred **I1b** readline
|
||||
requirement in `requirements.md`. Every fork below was escalated to the
|
||||
user and user-chosen before any code was written; implemented test-first
|
||||
(22 new Tier-1 tests in `src/app.rs`, all green; clippy nursery clean).
|
||||
|
||||
This ADR **amends ADR-0046**, which explicitly listed "readline
|
||||
shortcuts (I1b)" in its out-of-scope set: that item is now in scope and
|
||||
decided here. It is orthogonal to ADR-0003's input-*mode* model (simple
|
||||
vs advanced, the `:` sigil) — these are editing keys within the input
|
||||
field, not mode or sigil changes — and it extends the single-line cursor
|
||||
editing already shipped under requirement **I1a** (Left/Right/Home/End/
|
||||
Backspace/Delete, `app.rs`).
|
||||
|
||||
## Context
|
||||
|
||||
The input field already supported in-line cursor editing (I1a): Left/
|
||||
Right by char (UTF-8 aware), Home/End to the extremes, Backspace/Delete.
|
||||
Two gaps remained, raised in issue #29:
|
||||
|
||||
1. No way to **clear a partly-typed command** in one keystroke — a user
|
||||
who started typing the wrong thing had to hold Backspace.
|
||||
2. No **readline cursor/kill shortcuts** (Ctrl-A/Ctrl-E and friends) for
|
||||
keyboards without Home/End and for muscle-memory in a command-driven
|
||||
workflow. This is requirement I1b, deferred by ADR-0046.
|
||||
|
||||
`Esc` was free in the input field except that a *live Tab-completion
|
||||
memo* consumes it first (to undo the completion in one keystroke,
|
||||
ADR-0022). Ctrl-A/E/W/K/U were unbound. The existing chords are Ctrl-C
|
||||
(quit), Ctrl-O (nav focus cycle, ADR-0046), and Ctrl-`]` (demo caption
|
||||
toggle, ADR-0047) — none collide with a/e/w/k/u.
|
||||
|
||||
## Decision
|
||||
|
||||
Bind the following in the input field (non-modal, non-navigation,
|
||||
both input modes), in `App::handle_key`:
|
||||
|
||||
| Key | Action |
|
||||
|-----------|---------------------------------------------------|
|
||||
| `Esc` | Clear the input (empty buffer, cursor→0, scroll→0)|
|
||||
| `Ctrl-A` | Cursor to line start (alias of Home) |
|
||||
| `Ctrl-E` | Cursor to line end (alias of End) |
|
||||
| `Ctrl-W` | Delete the word before the cursor |
|
||||
| `Ctrl-K` | Kill from the cursor to end of line |
|
||||
| `Ctrl-U` | Kill from start of line to the cursor |
|
||||
|
||||
Behavioural rules:
|
||||
|
||||
- **Esc precedence.** A live completion memo still wins: the first Esc
|
||||
undoes the completion (ADR-0022), and Esc only *clears* when no memo
|
||||
is alive. This is a natural progression — Esc once to back out the
|
||||
completion, Esc again to clear.
|
||||
- **Esc does not clear while navigating the sidebar.** When a sidebar
|
||||
panel is focused (Ctrl-O, ADR-0046 DC3), `handle_key` routes every
|
||||
key to the navigation handler *before* the input-field keymap, where
|
||||
Esc exits navigation mode (`nav_exit`). Entering nav mode never
|
||||
touched the input buffer, so Esc-to-close-the-panel returns focus to
|
||||
the input with the partly-typed command intact — it cannot reach the
|
||||
clear binding. Locked by a regression test.
|
||||
- **Single Esc clears** (user-chosen over double-Esc). Discoverable and
|
||||
fast; the trade-off (an accidental Esc wipes an unsubmitted line) was
|
||||
accepted. A submitted line is always recoverable from history; only
|
||||
*unsubmitted* draft text is lost.
|
||||
- **Cursor-only keys don't touch history navigation.** Ctrl-A/Ctrl-E,
|
||||
like Home/End, move the cursor without ending history recall.
|
||||
- **Buffer-mutating keys end history navigation.** Esc-clear and
|
||||
Ctrl-W/K/U call `cancel_history_navigation` (the cleared/edited line
|
||||
*is* the new draft), matching Backspace/Delete.
|
||||
- **Ctrl-W is readline-style and UTF-8 safe.** It eats any run of
|
||||
trailing whitespace, then the preceding run of non-whitespace; word
|
||||
boundaries are found on char boundaries so multi-byte words delete
|
||||
cleanly. It only ever deletes back to the cursor (a mid-line Ctrl-W
|
||||
leaves the suffix intact).
|
||||
|
||||
Helpers added: `clear_input`, `delete_prev_word`, `kill_to_end`,
|
||||
`kill_to_start` (`src/app.rs`), mirroring the existing `cursor_left` /
|
||||
`delete_before_cursor` style.
|
||||
|
||||
## Forks (all user-chosen)
|
||||
|
||||
- **Esc semantics:** single-Esc-clears, *not* double-Esc — discoverable
|
||||
over accident-proof.
|
||||
- **Scope:** the *full* I1b set (Esc-clear + Ctrl-A/E/W/K/U), not just
|
||||
the issue's literal Ctrl-A/E + Esc — closes the whole I1b requirement
|
||||
in one pass rather than leaving Ctrl-W/K/U for a follow-up.
|
||||
- **Documentation:** a new ADR (this one), recording the input-field
|
||||
keymap convention and amending ADR-0046's OOS list — over folding it
|
||||
into ADR-0046 or shipping it I1a-style with no ADR.
|
||||
|
||||
## Consequences
|
||||
|
||||
- I1b is complete; `requirements.md` I1b moves to `[x]`.
|
||||
- The new keys are **not yet advertised on screen.** Surfacing per-focus
|
||||
keybindings in the bottom status line is issue #27's domain (a
|
||||
separate, in-design UX change); this ADR makes the keys *work*, #27
|
||||
will make them *discoverable*.
|
||||
- **Demo-mode badges** (ADR-0047) are *not* extended to the new Ctrl-
|
||||
chords here. Esc already badges as `[ESC]`; Ctrl-A/E/W/K/U are
|
||||
glyph-less and would be invisible in an asciinema cast. Whether to add
|
||||
`[CTRL-A]`…`[CTRL-U]` badges is left to ADR-0047's scope and flagged
|
||||
as a follow-up — it is a cast-polish concern, not a #29 requirement.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- On-screen keybinding hints for the input field (issue #27).
|
||||
- Demo badges for the new chords (ADR-0047 follow-up; flagged above).
|
||||
- Multi-line input (I1) and its Ctrl-Enter submit — unrelated, still
|
||||
deferred.
|
||||
- Word-wise *cursor motion* (Alt-B/Alt-F) and transpose/yank — not
|
||||
requested; not part of I1b.
|
||||
@@ -0,0 +1,119 @@
|
||||
# ADR-0050: Incidental-DDL confirmations omit relationship info (structure-only)
|
||||
|
||||
## Status
|
||||
|
||||
**Accepted + implemented 2026-06-12 (issue #28).** Closes Gitea **#28**.
|
||||
Both forks below were escalated to the user and user-chosen before any
|
||||
code was written; implemented test-first. **Supersedes** the
|
||||
incidental-DDL clause of **ADR-0044 §1** and the part of **ADR-0016 §5**
|
||||
that placed a relationship block under every structure echo. The
|
||||
diagram behaviour ADR-0044 introduced for relationship-subject surfaces
|
||||
is unchanged.
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0016 §5 rendered a structure box followed by a plain-text
|
||||
`References:` / `Referenced by:` relationship block under **every**
|
||||
structure echo. ADR-0044 §1 split that by surface:
|
||||
|
||||
- **Relationship-subject surfaces** — `show table <T>`,
|
||||
`add 1:n relationship`, `drop relationship`, `show relationship <name>`
|
||||
— render relationships as compact **diagrams** (the user asked for, or
|
||||
acted on, a relationship).
|
||||
- **Incidental DDL auto-shows** — `create table`, `add`/`drop`/`rename`/
|
||||
`change column`, `add`/`drop index` — kept the terse **prose** block,
|
||||
with the rationale *"a simple `add column` on a heavily-related table
|
||||
should not print a wall of diagrams."*
|
||||
|
||||
Issue #28 reconsiders the deeper question ADR-0044 did not ask: should
|
||||
an incidental-DDL confirmation show relationship information **at all**?
|
||||
Owner preference: **no.** A confirmation echo should focus on the change
|
||||
just made — the new / updated structure — not re-print the table's
|
||||
relationships, which the user did not touch. The terse prose was the
|
||||
lesser of "prose vs diagram", but the right answer for these surfaces is
|
||||
**neither**.
|
||||
|
||||
## Decision
|
||||
|
||||
**Incidental-DDL confirmation echoes render the structure only** — the
|
||||
table-name header, the column / type / constraints box, the `Indexes:`
|
||||
section, and the constraint section — with **no relationship section**
|
||||
(neither prose nor diagram).
|
||||
|
||||
- **Scope: all incidental DDL** (user-chosen, over "just `add column`"):
|
||||
`create table`, `add column`, `drop column`, `rename column`,
|
||||
`change column`, `add index`, `drop index`. The rule is uniform — a
|
||||
structural edit confirms structure, never relationships. (For a
|
||||
freshly `create`d table the relationship section was empty anyway; the
|
||||
rule still applies for consistency of the mental model.)
|
||||
- **Relationship-subject surfaces are unchanged.** `show table`,
|
||||
`add`/`drop relationship`, and `show relationship <name>` still render
|
||||
diagrams. Relationships appear **only** when the user asks for them
|
||||
(`show table` / `show relationship`) or acts on one
|
||||
(`add`/`drop relationship`).
|
||||
- **No information is lost.** Anything dropped from an incidental echo is
|
||||
one `show table <T>` away.
|
||||
|
||||
### Mechanism
|
||||
|
||||
The `handle_dsl_success` routing (`app.rs`) is **unchanged**: it still
|
||||
sends relationship-subject commands to the diagram renderer and
|
||||
everything else to `render_structure`. The change is entirely inside
|
||||
`render_structure` (`output_render.rs`): it no longer appends the
|
||||
relationship block — `render_structure` = structure box + indexes +
|
||||
constraints. All of `render_structure`'s callers are incidental DDL
|
||||
(verified), so this single edit covers the whole scope with no
|
||||
per-command branching.
|
||||
|
||||
### Prose renderer disposition
|
||||
|
||||
The orphaned prose renderer (`relationship_prose_lines`, and its
|
||||
sole helper `cols_disp`) is **deleted** (user-chosen, over retaining it
|
||||
dormant). After this change no shipped surface renders the prose form,
|
||||
so keeping it would be dead code. The prose format remains documented in
|
||||
**ADR-0016 §5** and in git history; if ADR-0044's OOS-7 user-configurable
|
||||
"always-prose" display setting is ever built, it re-introduces the ~30
|
||||
lines from that provenance.
|
||||
|
||||
## Forks (all user-chosen)
|
||||
|
||||
- **Scope:** *all incidental DDL*, not just `add column` — the owner's
|
||||
rationale ("confirm the change, not untouched relationships") applies
|
||||
uniformly, gives a clean mental model, and is the simpler edit (remove
|
||||
one call vs a per-command flag).
|
||||
- **Prose renderer:** *delete* it — no dead code — over retaining a
|
||||
public, tested-but-uncalled renderer for the speculative OOS-7 setting.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Incidental confirmations are shorter and on-topic; a heavily-related
|
||||
table no longer prints a relationship wall after `add column`.
|
||||
- One relationship renderer (prose) leaves the codebase; the diagram
|
||||
renderer (ADR-0044) is the only relationship render path that ships.
|
||||
- `requirements.md` is unaffected (this is an ADR-tracked refinement of a
|
||||
decided area, like ADR-0044 itself); the change is cross-referenced
|
||||
from the commit + this ADR.
|
||||
|
||||
## Tests
|
||||
|
||||
- **Unit (`output_render.rs`):** the prose-asserting test
|
||||
`render_structure_with_relationships` (+ its snapshot) is removed; a
|
||||
new test asserts `render_structure` on a description **carrying** both
|
||||
inbound and outbound relationships emits the structure box but **no**
|
||||
`References:` / `Referenced by:` lines. The box/index/constraint tests
|
||||
are unaffected (their descriptions have no relationships).
|
||||
- **Integration (`walking_skeleton.rs`):** the misnamed
|
||||
`add_relationship_flow_shows_inbound_section_on_parent` (which sends an
|
||||
`AddColumn` and asserted the inbound prose) is inverted + renamed to
|
||||
assert the add-column confirmation shows the structure but **omits**
|
||||
the relationship prose.
|
||||
- **Unchanged:** the diagram tests (`show_list.rs` `show table`,
|
||||
`walking_skeleton.rs` `add relationship`) still pass — they already
|
||||
assert prose is absent and diagrams are present.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The diagram form and its per-surface defaults (ADR-0044) — unchanged.
|
||||
- The OOS-7 user-configurable display setting (always-prose / -diagram /
|
||||
auto-by-width) — still a future follow-up; this ADR removes the prose
|
||||
*renderer*, not the *idea* of a prose mode.
|
||||
@@ -0,0 +1,147 @@
|
||||
# ADR-0051: Bottom keybinding strip — context- and state-aware
|
||||
|
||||
## Status
|
||||
|
||||
**Accepted 2026-06-13 (issue #27).** Closes Gitea **#27**. All forks
|
||||
below were escalated to the user and user-chosen before any code was
|
||||
written; to be implemented test-first. Builds on ADR-0046 (nav focus),
|
||||
ADR-0003 (input modes), ADR-0049 (the #29 readline keys this strip now
|
||||
advertises), and ADR-0022 (the Tab-completion memo).
|
||||
|
||||
## Context
|
||||
|
||||
The bottom status line (`render_status_bar`, `ui.rs`) mixed keystrokes
|
||||
with typed-command words: `Enter submit · : advanced once · mode
|
||||
advanced switch · Ctrl-C quit`. That is redundant — the hint panel
|
||||
already teaches `help` and `Enter` when the input is empty — and it is
|
||||
static apart from a three-way mode branch, so it never reflects what the
|
||||
user can actually do *right now* (navigating the sidebar, cycling a
|
||||
completion, browsing history, editing a line).
|
||||
|
||||
Issue #27: repurpose the line as a **keybindings-only** strip that is
|
||||
**context-sensitive to nav focus** and **state-aware of the current
|
||||
transient interaction**, and move mode discovery into the empty-input
|
||||
hint.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. The strip is keybindings-only and state-selected
|
||||
|
||||
A single pure function `status_bar_bindings(app) -> Vec<Binding>`
|
||||
computes the strip from app state; `render_status_bar` is a thin
|
||||
renderer over it (so the binding sets are unit-testable without a
|
||||
Frame). `history_cursor` is private to `App`, so a small
|
||||
`pub fn is_browsing_history(&self) -> bool` accessor exposes the
|
||||
history-navigation predicate; `mode` / `nav_focus` / `last_completion`
|
||||
are already `pub` and `effective_mode()` is a `pub` method. The state is
|
||||
chosen by **priority — first match wins**:
|
||||
|
||||
| Priority | State (predicate) | Strip |
|
||||
|---|---|---|
|
||||
| 1 | **Sidebar focus** (`nav_focus` in a sidebar) | `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input` |
|
||||
| 2 | **Completion memo live** (`last_completion.is_some()`) | `Tab/Shift-Tab cycle · Esc cancel · Enter run` |
|
||||
| 3 | **History navigation** (`history_cursor.is_some()`) | `↑↓ browse · Esc clear · Enter run` |
|
||||
| 4 | **Editing** (Input focus, input non-empty) | `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` |
|
||||
| 5 | **Default** (Input focus, input empty) | `Ctrl-O sidebar · Tab complete · ↑ history · Enter run` |
|
||||
|
||||
Priority order matters: a completion memo or history navigation is a
|
||||
non-empty-input situation, so states 2 and 3 must precede state 4. The
|
||||
sidebar overlay occludes the input entirely (ADR-0046), so state 1 wins
|
||||
outright.
|
||||
|
||||
### 2. Mode discovery moves off the strip, into the empty-input hint
|
||||
|
||||
The typed-command advertisements (`mode advanced` / `mode simple`
|
||||
switch, the `:` one-shot) leave the strip — they are not keystrokes.
|
||||
Mode discovery moves to the **empty-input hint** (`resolve_hint_lines`'s
|
||||
`(None, None)` arm), in **simple mode only**:
|
||||
|
||||
- **Simple:** `… · \`mode advanced\` for SQL`
|
||||
- **Advanced (persistent):** no pointer.
|
||||
|
||||
The pointer omits the verb "type" — the surrounding prompt already
|
||||
implies it (we don't say "type `help`" either). Advanced mode shows
|
||||
**no** pointer (user decision, post-trial): a user who switched into
|
||||
advanced mode knows how they got there, and `help` covers the way back —
|
||||
a "switch back" pointer only reads naturally in the moment right after
|
||||
switching, so it earns its space poorly.
|
||||
|
||||
The one-shot advanced state's old `Backspace cancel one-shot` label is
|
||||
**subsumed** by the editing state (the input is non-empty in one-shot;
|
||||
Esc-clear and Backspace both cancel it). No behaviour is lost — only the
|
||||
dedicated label.
|
||||
|
||||
### 3. Width: no drop machinery; a budget test instead
|
||||
|
||||
The longest strip (state 4, editing) is ≈ **65 display columns**, which
|
||||
fits every supported width (90-col screencasts, 80-col terminals) with
|
||||
margin — so the priority-drop / abbreviation machinery considered would
|
||||
never trigger and is not built (user-confirmed). Ratatui's existing
|
||||
**clip-at-edge** is the trivial fallback for pathologically narrow
|
||||
(< 65-col) terminals. Instead, a **width-budget unit test** pins the
|
||||
longest rendered strip within an 80-col budget, keeping the strip lean
|
||||
*by construction* — a future over-long strip fails the test rather than
|
||||
silently clipping in a cast.
|
||||
|
||||
## Forks (all user-chosen)
|
||||
|
||||
- **Editing state — yes:** when the input has text, surface the #29
|
||||
readline keys (Esc-clear, Ctrl-A/E, Ctrl-W); the strip stays lean
|
||||
(nav/complete/history) when empty. (vs not advertising the #29 keys.)
|
||||
- **`Ctrl-C quit` — omitted** from the strip (vs always shown): quit is
|
||||
a near-universal convention; omitting it keeps the strips lean and
|
||||
matches the issue's sketch.
|
||||
- **Width — budget test, no drop logic** (vs graceful priority-drop /
|
||||
abbreviation): the strips fit at supported widths, so the machinery
|
||||
would be dead weight (user's own observation).
|
||||
|
||||
## Consequences
|
||||
|
||||
- The strip now teaches the keys for the *current* situation; learners
|
||||
see `Tab/Shift-Tab cycle` exactly while cycling, the editing keys
|
||||
exactly while editing, etc.
|
||||
- The #29 readline keys (ADR-0049) gain their on-screen advertisement,
|
||||
closing that ADR's deferred item.
|
||||
- 15 existing full-panel insta snapshots churn (the bottom line — and,
|
||||
on empty-input views, the hint pointer — changes in every one,
|
||||
including the rebuild-confirm modal view, whose modal box is itself
|
||||
unchanged); each diff was reviewed, not blind-accepted.
|
||||
- `requirements.md` is unaffected (an ADR-tracked UI refinement); the
|
||||
change is cross-referenced from the commit + this ADR.
|
||||
|
||||
## Tests
|
||||
|
||||
- **Tier-1 (`ui.rs` unit):** `status_bar_bindings` returns the expected
|
||||
key set for each of the five states (sidebar, completion-live,
|
||||
history-nav, editing, default) — the completion/history states driven
|
||||
through real key events (`update`) so the predicate transitions are
|
||||
exercised, the others by setting `App` fields; plus the width-budget
|
||||
assertion across states. (Per-state coverage is these unit tests, not
|
||||
snapshots — a one-line strip is asserted more precisely by its exact
|
||||
key list than by a full-panel snapshot.)
|
||||
- **Tier-1:** the empty-input hint appends the correct mode pointer in
|
||||
Simple vs Advanced, and does **not** append it when an ambient hint is
|
||||
showing (non-empty input).
|
||||
- **Tier-3 (`walking_skeleton`):** the old `status_bar_lists_quit_and_
|
||||
submit_in_all_modes` (which asserted the pre-ADR strip) is rewritten +
|
||||
renamed to assert the keystroke-only, state-aware strip end-to-end
|
||||
through the real render path (default → editing transition).
|
||||
- **Tier-2 (insta):** the 15 full-panel snapshots re-accepted (each diff
|
||||
reviewed — strip line and/or hint pointer only).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Modal-aware strip.** While a modal is open (load picker, rebuild /
|
||||
undo confirm) it owns the keyboard and carries its own in-box key
|
||||
hints; the bottom strip under a modal computes from input state
|
||||
exactly as it does today (modals render *over* the status bar). This
|
||||
issue does not redesign the modal case — pre-existing behaviour,
|
||||
unchanged and not worsened.
|
||||
- A persistent/togglable help overlay listing *all* keys (the strip is a
|
||||
contextual subset, not a cheatsheet).
|
||||
- Per-key colour theming beyond the existing key/label/separator styles.
|
||||
- Localisation of the new label strings beyond adding catalog entries.
|
||||
- The remaining I1b kill keys' (Ctrl-K/Ctrl-U) advertisement — the
|
||||
editing strip shows the highest-value subset (Esc/Ctrl-A/E/Ctrl-W) to
|
||||
stay within the width budget; Ctrl-K/U remain unadvertised muscle
|
||||
memory.
|
||||
@@ -0,0 +1,250 @@
|
||||
# ADR-0052: Mode-tagged history for cross-mode recall
|
||||
|
||||
## Status
|
||||
|
||||
**Accepted + implemented 2026-06-13 (issue #30).** Closes Gitea **#30** —
|
||||
both the feature ("reuse advanced history commands in simple mode by
|
||||
prepending `:`") and the bug reported in its comment (the `:` one-shot
|
||||
prefix lost across sessions). All forks user-chosen before any code.
|
||||
**Amends ADR-0034** (journal status field gains a `:adv` tag; *journaling
|
||||
moves from the worker to the dispatch layer*), **ADR-0015 §5/§6**
|
||||
(history.log leaves the worker transaction — `commit-db-last` now scopes
|
||||
yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure
|
||||
is best-effort, no longer fatal); references ADR-0003 (the `:` one-shot
|
||||
sigil). Plan: `docs/plans/20260613-issue-30-top-of-chain-journaling.md`
|
||||
(pre-build `/runda`, then a second `/runda` that drove the journaling
|
||||
relocation + the app-command exclusion). **2471 tests pass / 0 fail / 0
|
||||
skip (1 ignored), clippy clean.**
|
||||
|
||||
> **Why journaling moved (the key architectural turn).** The first draft
|
||||
> kept journaling in the worker and threaded the mode down to it (~30-site
|
||||
> plumbing). On review the user asked the right question: why is the
|
||||
> journal written deep in the worker at all, when the failure path already
|
||||
> journals at the top of the chain where command + mode + outcome are all
|
||||
> in scope? It shouldn't — `history.log` is a *journal of typed commands*,
|
||||
> not *state*. So success journaling moved up next to the failure path
|
||||
> (`spawn_dsl_dispatch` / `run_replay` / the app-command sites), the
|
||||
> mode-plumbing dilemma dissolved, and the worker's `finalize_persistence`
|
||||
> now writes only the state sources (yaml/csv). Consequence: the journal
|
||||
> write is best-effort (the command is already committed), consistent with
|
||||
> the failure path — see §5.
|
||||
|
||||
## Context
|
||||
|
||||
The input-history ring and `history.log` carry **no mode information**,
|
||||
which causes two coupled problems:
|
||||
|
||||
1. **Feature gap.** A command typed in advanced mode (`select * from T`)
|
||||
is stored bare. Recalled in simple mode it is not valid DSL → it just
|
||||
errors. There is no way to know it was an advanced (SQL) command and
|
||||
offer it back in a runnable form.
|
||||
|
||||
2. **Bug (issue #30 comment).** A `:`-one-shot advanced command in simple
|
||||
mode recalls correctly **in-session** (the in-memory ring stores the
|
||||
raw `:select 1`), but after quit+resume it comes back **without** the
|
||||
`:` and is unusable. Root cause: the ring stores the raw input
|
||||
(`:select 1`), but the worker journals the **stripped** `effective_input`
|
||||
(`select 1`) — submission strips the `:` before dispatch (ADR-0003) —
|
||||
so the on-disk `source` never carried the `:`, and hydration loses it.
|
||||
|
||||
Both reduce to: **history does not record the submission mode**, and the
|
||||
in-memory and on-disk representations disagree about the `:`.
|
||||
|
||||
## Decision
|
||||
|
||||
Record the **submission mode** per history entry, keep the on-disk
|
||||
`source` **canonical** (stripped — replay is unaffected), and have
|
||||
**recall reconstruct the runnable line** for the current mode.
|
||||
|
||||
### 1. In-memory ring stores the `:`-prefixed runnable form
|
||||
|
||||
`App.history` stays `Vec<String>` — no type change, so the public ring,
|
||||
the `ProjectSwitched` payload, and `seed_history` are untouched. An
|
||||
**advanced** entry is stored in its **simple-mode runnable form**, the
|
||||
`: `-prefixed string (e.g. `: select * from T`); a **simple** entry is
|
||||
stored bare. This is exactly what the in-session one-shot ring already
|
||||
does (`:select 1` recalls as typed) — generalised to *persistent*-advanced
|
||||
commands too, and made reconstructable on hydration. Because a simple
|
||||
DSL command can never begin with `:` (the sole sigil, ADR-0003), a
|
||||
leading `:` unambiguously marks an advanced entry.
|
||||
|
||||
`submit` builds the stored line from the submission: advanced →
|
||||
`": " + effective_input` (the `: ` matches the auto-space the typed
|
||||
one-shot inserts), simple → `effective_input`. This is computed **after**
|
||||
`effective_input` (today `push_history` runs on the raw `trimmed` before
|
||||
stripping; the reorder also drops a bare `:`, which never executed). The
|
||||
draft (`history_draft`) stays a plain `String`. `push_history` itself is
|
||||
unchanged — it still takes one `&str`.
|
||||
|
||||
### 2. Recall strips the `:` for advanced mode
|
||||
|
||||
`history_back` / `history_forward` set `self.input` from the stored
|
||||
string, then strip a leading `:` **iff the current persistent mode is
|
||||
Advanced**:
|
||||
|
||||
```
|
||||
if self.mode == Mode::Advanced && stored.starts_with(':') { stored[1..].trim_start() } else { stored }
|
||||
```
|
||||
|
||||
So an advanced entry recalls as `: select * from T` in **simple** mode
|
||||
(runs via the one-shot escape — the feature, and the cross-session bug
|
||||
fix) and bare `select * from T` in **advanced** mode (runs as SQL). A
|
||||
simple entry recalls bare in either mode (simple DSL already runs in
|
||||
advanced mode — issue #30). In-session and cross-session paths share the
|
||||
same stored form, so they finally agree.
|
||||
|
||||
### 3. On-disk: a mode tag in the status field
|
||||
|
||||
The record stays three pipe-separated fields `<ts>|<status>|<source>`
|
||||
(so `source` remains the last, pipe-tolerant, canonical field — replay
|
||||
reads it unchanged). The **status token** gains an optional `:adv`
|
||||
suffix:
|
||||
|
||||
| Submission | Success | Failure |
|
||||
|---|---|---|
|
||||
| Simple | `ok` | `err` |
|
||||
| Advanced (persistent or one-shot) | `ok:adv` | `err:adv` |
|
||||
|
||||
ADR-0034 §1 already reserved the status field for "additional values …
|
||||
a non-breaking future extension"; this is that extension. The status
|
||||
parser splits the token on `:`: the base (`ok`/`err`) gives replayability
|
||||
(`status_is_ok` ⇔ base == `ok`), the `adv` suffix gives the mode — so an
|
||||
unknown future token degrades to "not ok, simple" rather than mis-parsing.
|
||||
|
||||
### Journaling location: the dispatch layer, not the worker
|
||||
|
||||
Both tags are written **at the dispatch layer**, where command + mode +
|
||||
outcome are all in scope — so the mode needs no plumbing into the worker:
|
||||
|
||||
- **Success:** `spawn_dsl_dispatch`, immediately after
|
||||
`execute_command_typed` returns `Ok`, calls
|
||||
`append_history(source, submission_mode.is_advanced())` (best-effort).
|
||||
`run_replay` does the same per replayed line (tagged simple — replay is
|
||||
mode-agnostic), and the app-command sites (`perform_switch` /
|
||||
`spawn_export` / `spawn_rebuild`) journal **simple** (`advanced = false`
|
||||
— app commands run in any mode, so no `:` on recall; this also avoids a
|
||||
redundant `: undo`).
|
||||
- **Failure:** unchanged location (the App→`JournalFailure`→runtime path,
|
||||
already at the top), now carrying the mode — `JournalFailure` gains
|
||||
`advanced`, and `DslFailed` gains `submission_mode` for the
|
||||
worker-rejection sub-path (the parse-failure sub-path has it in
|
||||
`dispatch_dsl`). `Ok`/`Err` are exclusive, so success-in-spawn and
|
||||
failure-in-App-path never double-journal.
|
||||
|
||||
The worker's `finalize_persistence` and the four no-op-skip / three
|
||||
read-only sites **no longer journal** — they leave the state writes
|
||||
(yaml/csv) in the worker transaction and let the dispatch layer journal
|
||||
the `Ok` outcome.
|
||||
|
||||
### 4. Hydration reconstructs the `:`-prefixed form
|
||||
|
||||
`read_recent_sources` parses each record's status tag and, for an
|
||||
advanced record, **reconstructs** the `: `-prefixed string from the
|
||||
canonical `source` (`format!(": {source}")`); simple records pass through
|
||||
bare. It still returns `Vec<String>`, so `read_history_seed`,
|
||||
`seed_history`, and the `ProjectSwitched` payload are **unchanged**. A
|
||||
hydrated entry is therefore byte-identical to its in-session form, and
|
||||
recall behaves identically.
|
||||
|
||||
### Back-compatibility
|
||||
|
||||
Old `history.log` files have only `ok` / `err` tokens → parsed as
|
||||
`advanced = false` (simple). Their advanced commands stay un-`:`-able on
|
||||
recall — the pre-existing behaviour, not a regression; nothing migrates.
|
||||
`status_is_ok` keys off the base token, so `ok:adv` records replay
|
||||
exactly as `ok` does today (source is canonical either way).
|
||||
|
||||
### Journal write is best-effort (amends ADR-0040)
|
||||
|
||||
Because the journal is now written *after* the worker replies (i.e. after
|
||||
`tx.commit`), a journal-write failure can no longer roll the command back.
|
||||
It is **best-effort** — logged and ignored, exactly like the failure path
|
||||
already is (ADR-0034 §4) — so the two journal paths are finally
|
||||
consistent. State integrity is unchanged: yaml/csv/db still commit
|
||||
atomically in the worker (a *state*-write failure still rolls back and is
|
||||
fatal). The only property given up: on a rare journal-write failure (disk
|
||||
full) a committed command may be missing from `history.log` — not
|
||||
recallable/replayable next session, but the state is correct. User-chosen
|
||||
over keeping journaling coupled in the worker (which would have needed the
|
||||
~30-site mode plumbing). See the plan's §2 for the full trade-off.
|
||||
|
||||
## Forks (user-chosen)
|
||||
|
||||
- **Format = mode tag in the status field** (`ok:adv`/`err:adv`), over a
|
||||
new 4th field (ambiguous with unescaped pipes in old `source`s without
|
||||
a version bump) or a `:`-prefix in `source` (would make `source`
|
||||
non-canonical and force replay to strip it).
|
||||
- **Scope = unified** (bug + feature) over bug-only: one mechanism does
|
||||
both, and keeping `source` canonical for replay needs the mode tag
|
||||
regardless, so bug-only is barely smaller and leaves the main ask open.
|
||||
- **Journaling location = dispatch layer, best-effort** over keeping it
|
||||
worker-coupled-and-fatal (which needed the ~30-site mode plumbing). The
|
||||
user's architectural call (§Status).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Advanced history is reusable in simple mode; the `:` one-shot survives
|
||||
resume. The in-memory and on-disk representations agree.
|
||||
- **Journaling left the worker.** `finalize_persistence` and the
|
||||
no-op-skip / read-only sites no longer journal; success is journalled at
|
||||
the dispatch layer (`spawn_dsl_dispatch` / `run_replay` / app-command
|
||||
sites). The ring stays `Vec<String>`; `seed_history` / `ProjectSwitched`
|
||||
are untouched. The vestigial worker `source` plumbing has since been
|
||||
**fully unwound** (2026-06-14 follow-up): `_source` removed from
|
||||
`finalize_persistence` / `do_rebuild_from_text`; the three read-only
|
||||
`*_request` wrappers inlined and deleted; and — because the cascade ran
|
||||
deeper than first estimated — the now-dead `source` param dropped from
|
||||
the ~30 worker handlers (leaf + composite) that only forwarded it, plus
|
||||
the `source` field removed from the `DescribeTable` / `QueryData` /
|
||||
`RunSelect` requests and the matching `DatabaseHandle` method parameters
|
||||
(the ~164 call-site churn was mostly tests). The only `source` left in
|
||||
the worker is the snapshot/undo label (`snapshot_then` /
|
||||
`stage_pre_mutation` / `begin_batch`), passed at the match-arm level.
|
||||
Purely mechanical, compiler-guided, no behaviour change.
|
||||
- **App commands recall bare.** Because they are dispatched outside the
|
||||
`ExecuteDsl`/spawn path, app commands journal **simple** (`advanced =
|
||||
false`) at their own sites, and `submit` excludes them from the ring's
|
||||
`advanced` flag (`!is_app_command`) — so `mode advanced` / `undo` recall
|
||||
bare and run fine in simple mode, with no redundant `:`.
|
||||
- **Journaling is now uniform (user-confirmed).** The spawn journals on
|
||||
`outcome.is_ok()`, so **every** successful command is recorded — closing
|
||||
a pre-existing gap where `show table` / `show data` / `select` journalled
|
||||
but `show tables`/`show relationships`/`show indexes`, `show relationship
|
||||
<name>`, and `explain` did **not** (their worker arms carried no
|
||||
`source` / no journal call). The new behaviour matches ADR-0034 §1
|
||||
("record every submitted command"); those reads are now recallable and
|
||||
are re-run harmlessly on replay (`explain` never executes; shows produce
|
||||
output, no state change). A DA finding, accepted as the more-correct
|
||||
behaviour over re-adding command-outcome gating to preserve the old
|
||||
inconsistency.
|
||||
- **Replay re-journaling.** When `replay` re-dispatches a line, the
|
||||
re-written record is tagged from how replay dispatched (mode-agnostic →
|
||||
`ok`), so a replayed advanced command may be re-journalled without
|
||||
`:adv`. Replay correctness of execution is unchanged (it already parses
|
||||
mode-agnostically); this only affects the *tag* of the re-written line.
|
||||
Noted; not addressed here (replay's own mode-fidelity is out of scope).
|
||||
|
||||
## Tests
|
||||
|
||||
- **Tier-1 (`app.rs`):** an advanced one-shot / persistent-advanced
|
||||
submission is stored `: `-prefixed; it recalls as `: …` in simple mode
|
||||
and bare in advanced mode; a simple entry recalls bare in both; a bare
|
||||
`:` is not stored; a parse-failure is still recallable; dedup/cap hold.
|
||||
- **Tier-1 (`history.rs`):** the writer emits `ok:adv`/`err:adv`;
|
||||
`read_recent_sources` reconstructs the `: `-prefix for `:adv` records
|
||||
and leaves `ok`/`err` records bare (so old logs read as simple);
|
||||
`status_is_ok` is true for `ok` and `ok:adv`.
|
||||
- **Tier-3 (`iteration6_resume_history` / it):** the headline
|
||||
**regression** — type a `:`-one-shot advanced command, journal +
|
||||
hydrate, and assert it recalls **with** `:` in simple mode (fails on
|
||||
current code); plus a persistent-advanced command round-tripping to a
|
||||
`: …` recall.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Replay re-journaling mode-fidelity (above).
|
||||
- Special-casing app commands to avoid the redundant recall `: `.
|
||||
- Distinguishing one-shot from persistent advanced on recall (both are
|
||||
simply "advanced" — the `:` is what simple mode needs either way).
|
||||
- A format version marker / pipe-escaping in `source` (unneeded — the
|
||||
status-tag approach keeps `source` last and canonical).
|
||||
@@ -0,0 +1,428 @@
|
||||
# ADR-0053: Contextual `hint` — F1 live-input keybinding + `hint` command, with a tier-3 teaching corpus (H2)
|
||||
|
||||
## Status
|
||||
|
||||
Accepted — **implemented 2026-06-15** (plan:
|
||||
`docs/plans/20260614-adr-0053-contextual-hint-H2.md`; the F1 keybinding +
|
||||
`hint` command, the `hint_ids` per-form keying + `hint_key_for_input_in_mode`,
|
||||
`last_error_hint_key` + `friendly::error_hint_class`, the `note_hint*`
|
||||
renderers, and the `hint.cmd.*`/`hint.err.*` corpus for every command form
|
||||
+ the 9 runtime error classes, with the comprehensiveness coverage test
|
||||
and the ADR-0051 strip advertising F1). Closes **A1** + requirements
|
||||
**H2**. Deferred: the pre-submit-diagnostic route + `diagnostic.*` blocks
|
||||
(#38), clause-concept hints (#37). Revised after a `/runda` review
|
||||
(2026-06-14): corrected the verbosity-default fact; re-keyed tier-3
|
||||
content off `help_id`; split the pre-submit-diagnostic and runtime-error
|
||||
paths; added a comprehensiveness coverage test. Revised again during
|
||||
Phase B implementation (2026-06-15): the first exemplar showed per-*node*
|
||||
keying is too coarse for multi-form commands (`add`/`drop`/`show`/
|
||||
`create`), so D3 now keys tier-3 content **per form** via a
|
||||
`hint_ids: &[&str]` array mirroring `usage_ids` — and **clause-concept
|
||||
hints** are recorded as a deferred extension (issue #37). During Phase C
|
||||
the **pre-submit-diagnostic route + the ~33 `diagnostic.*` blocks** were
|
||||
**deferred** (issue #38) — `Diagnostic` doesn't carry its class key, so
|
||||
the route needs a broad change for marginal value (D6). v1 therefore
|
||||
ships command-form hints + the 9 runtime error-class hints. The parallel
|
||||
question of whether the in-app `help` command should likewise distinguish
|
||||
advanced-SQL forms is tracked **separately** as Gitea issue #36.
|
||||
|
||||
Decided in conversation 2026-06-14. Closes the last open piece of **A1**
|
||||
(the canonical app-command set, ADR-0003): every app command is
|
||||
implemented except `hint`, which ADR-0003's command table listed as
|
||||
*"Request a hint for the current input (ADR pending)."* This ADR is that
|
||||
pending decision. Tracked as **H2** in `docs/requirements.md`.
|
||||
|
||||
References ADR-0003 (app-command set + the `:` escape), ADR-0019 (the
|
||||
friendly error layer / H1), ADR-0021 (per-command usage templates / H1a),
|
||||
ADR-0022 (ambient typing assistance — colour + hint panel + completion),
|
||||
ADR-0027 (input validity indicator), ADR-0046 (sidebar navigation +
|
||||
responsive input hint), ADR-0049 (input-field readline keymap), and
|
||||
ADR-0051 (context/state-aware keybinding strip).
|
||||
|
||||
## Context
|
||||
|
||||
`hint` is the only unbuilt app command. The naive reading — "show a hint" —
|
||||
hides a real subtlety, and a real cost.
|
||||
|
||||
**The subtlety: a submitted `hint` command cannot see live input.** App
|
||||
commands are submitted with Enter, which empties the input buffer. By the
|
||||
time `hint` dispatches, the partial command it was meant to help with is
|
||||
gone. So "a hint for the current input" cannot be served by a submitted
|
||||
command alone — it needs a *keybinding* that acts on the live buffer
|
||||
without submitting. ADR-0003 said "current input"; `requirements.md`
|
||||
broadened it to "current input **or the most recent error**." Both are
|
||||
wanted; they map to two different trigger surfaces.
|
||||
|
||||
**The cost: the value of `hint` is content, not plumbing.** The app
|
||||
already carries two tiers of contextual text:
|
||||
|
||||
- **Tier 1** — terse, always-on: syntax colour (ADR-0022); the error
|
||||
*headline* alone (ADR-0019, when `messages_verbosity: Short`).
|
||||
- **Tier 2** — short contextual lines: the ambient typing prose /
|
||||
`expected` set, shown live while typing (ADR-0022, catalogue
|
||||
`hint.ambient_*` / `hint.value_slot_*`); and the error `hint:` field —
|
||||
which, because `Verbosity::Verbose` is the **default**
|
||||
(`src/friendly/translate.rs:46`), is shown **by default** beneath every
|
||||
error headline (`messages short` is the opt-*out*, not `messages
|
||||
verbose` the opt-in).
|
||||
|
||||
So the verbose error hint is **already on screen by default**. If `hint`
|
||||
merely re-showed it, it would duplicate what the user can already see (and
|
||||
the ambient panel). To justify itself, `hint` must add a **tier 3**: a
|
||||
genuinely deeper, *teaching*-grade explanation — what the command/error
|
||||
means, a worked example, and the underlying relational concept. That
|
||||
corpus does not exist yet, and
|
||||
authoring it (to the standard of a teaching tool, where "pedagogy wins
|
||||
ties") is the bulk of the work.
|
||||
|
||||
The mechanism is small and reuses everything already present: the command
|
||||
REGISTRY (`src/dsl/grammar/mod.rs`), the `AppCommand` enum
|
||||
(`src/dsl/command.rs`), key dispatch (`App::handle_key`,
|
||||
`src/app.rs:1155`), the `note_help`/`note_help_topic` renderers
|
||||
(`src/app.rs:2982`/`3021`), the parser/walker expected-set
|
||||
(`ParseError.expected`, `WalkResult.tail_expected`), the friendly
|
||||
catalogue + `t!` macro + `keys.rs` validation, and the output styling
|
||||
vocabulary (`OutputStyleClass::Hint`).
|
||||
|
||||
## Decision
|
||||
|
||||
### D1 — Two surfaces, no topic argument
|
||||
|
||||
`hint` is delivered through **two complementary surfaces**:
|
||||
|
||||
1. **F1 keybinding → live input.** Pressing **F1** while typing renders a
|
||||
tier-3 hint for the command currently in the buffer, into the output
|
||||
panel, **without submitting or altering the buffer**. This is the
|
||||
primary, most-valuable path (it serves the literal "current input").
|
||||
2. **`hint` command → most recent error.** Submitting `hint` renders the
|
||||
tier-3 expansion of the most recent error. This is why the command
|
||||
exists despite the empty-buffer problem: the thing it helps with is
|
||||
the *last thing you tried*, not the now-empty buffer.
|
||||
|
||||
`hint` takes **no topic argument**. Explicit per-command reference is
|
||||
already `help <topic>` (H3); `hint` is purely *contextual*, which keeps
|
||||
the two cleanly distinct (`hint` = "help me with what I'm doing right
|
||||
now"; `help insert` = "show me the insert reference").
|
||||
|
||||
F1 is a **read-only overlay**: it never alters the input buffer, the
|
||||
cursor, or the live completion memo (ADR-0022) — it only emits a block
|
||||
into the output journal. (It must therefore be handled in `handle_key`
|
||||
*before* the "any other key clears the memo" fall-through.)
|
||||
|
||||
### D2 — Trigger matrix
|
||||
|
||||
| Trigger | Buffer / state | Result |
|
||||
|---|---|---|
|
||||
| **F1** | non-empty input | tier-3 hint for the command being typed. (No "expected next" line — the always-on tier-2 ambient panel already shows it live; tier-2 owns position-awareness.) |
|
||||
| **F1** | empty input, a recent error exists | tier-3 expansion of that error |
|
||||
| **F1** | empty input, no recent error | a short "getting started" pointer (press F1 while typing a command; `help` for the full list) |
|
||||
| **`hint`** (submitted) | a recent error exists | tier-3 expansion of that error (primary use) |
|
||||
| **`hint`** (submitted) | no recent error | the same "getting started" pointer |
|
||||
|
||||
F1 is inert behind a modal and while a sidebar panel holds navigation
|
||||
focus (consistent with the existing `handle_key` gates, ADR-0046); it is
|
||||
active in the input context in both Simple and Advanced mode.
|
||||
|
||||
**Error routes.** **Runtime errors** (the 9 `translate_error` classes)
|
||||
occur *after* submit; the **`hint` command / empty-input F1** path reads
|
||||
them via the stored `last_error_hint_key` (D5) and renders their
|
||||
`hint.err.<class>` block. (A second route for **pre-submit diagnostics**
|
||||
on the F1 live-input path was specified but is **deferred** — D6 / issue
|
||||
#38; with a diagnostic present, F1 shows the command block and tier-2
|
||||
shows the diagnostic.) **`:`-prefix handling:**
|
||||
on the simple-mode one-shot escape (`: SELECT …`), command
|
||||
identification for the F1 path strips the leading `:` first, so the
|
||||
advanced form is matched.
|
||||
|
||||
### D3 — The tier-3 content model
|
||||
|
||||
Tier-3 blocks live in the friendly catalogue under the existing `hint:`
|
||||
top-level namespace (where tier-2 ambient strings already live), in two
|
||||
new sub-namespaces:
|
||||
|
||||
- **`hint.cmd.<hint_id>`** — one per command **form**, keyed by a **new
|
||||
`hint_ids: &'static [&'static str]`** field on `CommandNode`
|
||||
(`src/dsl/grammar/mod.rs:512`), **mirroring the existing `usage_ids`**.
|
||||
The F1 live-input path resolves the current input to its form's hint key
|
||||
via `hint_key_for_input_in_mode`, which reuses the same form-word
|
||||
disambiguation as `usage_key_for_input_in_mode`.
|
||||
|
||||
**Why an array mirroring `usage_ids`, not a per-node `hint_id`**
|
||||
*(`/runda`/implementation revision, 2026-06-15)*: a single per-node key
|
||||
is too coarse. Several entry words are **one node spanning many forms** —
|
||||
`add` (column/relationship/index/constraint), `drop` (table/column/
|
||||
relationship/index), `show` (data/table/tables/relationships/indexes),
|
||||
`create` (table/index). A live-input hint for `add 1:n relationship` is
|
||||
only useful if it is *specific to relationships*, so the content must be
|
||||
**per form**, not per node. The project already solved exactly this for
|
||||
usage templates (`usage_ids` is a per-form array, disambiguated by the
|
||||
form word), so `hint_ids` mirrors it. Single-form nodes carry one entry;
|
||||
multi-form nodes carry one per form. This also covers the advanced-SQL
|
||||
forms whose `usage_ids` are empty (`SQL_INSERT/UPDATE/DELETE`,
|
||||
`EXPLAIN_SQL`) — they get their own `hint_ids` directly, independent of
|
||||
usage, with mode-correct SQL examples. (The `help`-list collapse of
|
||||
advanced-SQL forms is a separate gap — issue #36.)
|
||||
|
||||
**Deferred extension — clause-concept hints** (issue #37): per-form is
|
||||
the right granularity for tier-3 *teaching* (position-awareness within a
|
||||
form is owned by tier-2 ambient + the live `Next:` line, D4). But some
|
||||
**concepts live inside a clause**, not a form — `… on delete ⟨cascade|
|
||||
set null|restrict⟩` (referential actions), the `create table` constraint
|
||||
slots (`primary`/`unique`/`check`/`foreign`), `with pk`, `1:n`/`m:n`
|
||||
cardinality. A learner parked in such a clause may want teaching deeper
|
||||
than tier-2's candidate list but narrower than the whole-form block. v1
|
||||
does **not** build this (it would multiply content for points whose value
|
||||
we can't yet measure, and we don't expect to accumulate usage statistics
|
||||
to drive it empirically — it will be tackled as a deliberate follow-up
|
||||
job). The keying does not lock it out: a later `hint.concept.<topic>`
|
||||
namespace can be surfaced when the cursor sits in a recognized clause,
|
||||
layered on top of the per-form block.
|
||||
- **`hint.err.<class>`** — one per error/diagnostic class, keyed by the
|
||||
friendly error/diagnostic key (e.g. `hint.err.foreign_key.child_side`,
|
||||
`hint.err.type_mismatch`, `hint.err.insert_arity_mismatch`). Used by
|
||||
both error routes (D2).
|
||||
|
||||
Each tier-3 block is a **structured entry with three labelled parts**, so
|
||||
the voice stays consistent and the renderer can style them uniformly:
|
||||
|
||||
```yaml
|
||||
hint.cmd.dsl.insert:
|
||||
what: "Add one or more rows to a table."
|
||||
example: "insert into Customers values ('Ann', 'ann@x.io')"
|
||||
concept: "A row is one record; each value lines up with a column, in
|
||||
order. Columns typed `serial`/`shortid` fill themselves — leave them out."
|
||||
```
|
||||
|
||||
- **`what`** — one or two plain sentences: what this command does / what
|
||||
this error means.
|
||||
- **`example`** — a single concrete, copyable line (rendered neutral, not
|
||||
muted, so it stands out as runnable).
|
||||
- **`concept`** — the underlying relational idea, in teaching voice; the
|
||||
part that makes this tier-3 rather than tier-2.
|
||||
|
||||
`concept` is optional where there is genuinely no concept beyond the
|
||||
mechanics (e.g. `quit`); `what` + `example` are always present.
|
||||
|
||||
### D4 — Rendering
|
||||
|
||||
Both surfaces render through the `App::note_hint*` family (sibling of
|
||||
`note_help`/`note_help_topic`, `src/app.rs`) via `emit_tier3_block`,
|
||||
emitting into the `output` buffer as `OutputKind::System`: a **`Hint`
|
||||
heading** followed by aligned **`What:` / `Example:` / `Concept:`** lines
|
||||
(labels + heading from `hint.block.*`). The `concept` line is muted
|
||||
(`OutputStyleClass::Hint`); the rest are plain. The block is
|
||||
**persistent** (scrolls in the journal), unlike the transient ambient
|
||||
panel — pressing F1 is an explicit request to *keep* the deeper guidance
|
||||
on screen. Its rendered shape is locked by an `insta` snapshot
|
||||
(`hint_block_insert`). The bottom keybinding strip (ADR-0051) advertises
|
||||
F1 in the editing (leading) and default states.
|
||||
|
||||
### D5 — "Most recent (runtime) error" state
|
||||
|
||||
The **runtime-error route** (submitted `hint`, and empty-input F1) needs
|
||||
to map the last runtime error back to its `hint.err.<class>` key. Runtime
|
||||
errors today live only as rendered text in the `output` buffer. We add a
|
||||
single small piece of `App` state — **`last_error_hint_key:
|
||||
Option<String>`** — set at the `translate_error` call sites
|
||||
(`runtime.rs:2615`, `app.rs:2424`) when a friendly error is rendered,
|
||||
cleared when a later command succeeds. Absent → the "getting started"
|
||||
pointer.
|
||||
|
||||
The **pre-submit-diagnostic route** (the F1 live-input path reading the
|
||||
under-cursor diagnostic) is **deferred** — see the scope note in D6.
|
||||
|
||||
### D6 — Content scope for v1
|
||||
|
||||
v1 ships tier-3 content for the **command forms and runtime error
|
||||
classes** — comprehensive for those (the graceful tier-2 fallback below
|
||||
is a safety net, not the plan):
|
||||
|
||||
- **~37 command forms** — every distinct node in `REGISTRY` gets its own
|
||||
`hint.cmd.<hint_id>` block (app + DSL + DDL + advanced-mode SQL forms),
|
||||
each with a **mode-correct example** (the advanced-SQL forms show SQL
|
||||
syntax, their simple siblings show DSL — no sharing).
|
||||
- **9 runtime error classes** — `unique`, `foreign_key` (child/parent
|
||||
side), `not_null`, `check`, `type_mismatch`, `not_found`,
|
||||
`already_exists`, `generic`, `invalid_value` — each gets a
|
||||
`hint.err.*` block.
|
||||
|
||||
**Deferred — the ~33 `diagnostic.*` pre-submit classes and the F1
|
||||
diagnostic route** *(Phase C scope decision, 2026-06-15; issue #38)*. The
|
||||
original "comprehensive" scope included them, but implementation revealed
|
||||
`Diagnostic` (`walker/outcome.rs`) carries only its rendered `message`,
|
||||
not its class key — so a live diagnostic can't be mapped to
|
||||
`hint.err.<class>` without adding a `class` field threaded through every
|
||||
diagnostic-creation site (a broad change). Weighed against the value, it
|
||||
isn't worth it for v1: pre-submit diagnostics are already surfaced by
|
||||
tier-2 (ambient message + validity indicator, ADR-0027); F1 still shows
|
||||
the useful command block when a diagnostic is present; and many
|
||||
diagnostic classes duplicate runtime classes already covered
|
||||
(`type_mismatch`, `unknown_table`↔`not_found`, arity↔`invalid_value`).
|
||||
Deferred to issue #38, additively (the keying doesn't lock it out).
|
||||
|
||||
The full enumerated checklist is the implementation plan's tracking
|
||||
artifact (see *Content inventory*, below).
|
||||
|
||||
**Fallback (safety net):** if a tier-3 key is ever missing at runtime,
|
||||
the surface degrades to tier 2 — the ambient prose for the command path,
|
||||
or the verbose error `hint:` for the error path — never to a blank or an
|
||||
error. The `keys.rs` build-time validation keeps the corpus honest, so a
|
||||
missing key is caught in tests, not in front of a student.
|
||||
|
||||
### D7 — Authoring process: exemplars-first
|
||||
|
||||
Because the corpus is large and its *voice* is a pedagogical decision the
|
||||
maintainer owns, content is produced in two stages:
|
||||
|
||||
1. This ADR carries **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; shipped as the rendered format)
|
||||
|
||||
**Command (F1 live-input), `insert`** (the rendered shape, locked by the
|
||||
`hint_block_insert` snapshot — a `Hint` heading + aligned labels, no
|
||||
`Next:` line since tier-2 owns position-awareness):
|
||||
|
||||
```
|
||||
Hint
|
||||
What: Add one or more rows to a table.
|
||||
Example: insert into Customers values ('Ann', 'ann@example.io')
|
||||
Concept: A row is one record; each value lines up with a column, in
|
||||
order. Columns typed serial/shortid fill themselves — leave
|
||||
them out.
|
||||
```
|
||||
|
||||
**Error (`hint` command), foreign-key child-side violation:**
|
||||
|
||||
```
|
||||
Hint
|
||||
What: The value you gave for the child column doesn't match any
|
||||
parent row, so the foreign key has nothing to point at.
|
||||
Example: First insert the parent (insert into Customers …), then the
|
||||
child that references it.
|
||||
Concept: A foreign key is a promise that every child points at a real
|
||||
parent, so the parent must exist first. To allow orphans on
|
||||
delete instead, set the relationship's `on delete` to
|
||||
`set null` or `cascade`.
|
||||
```
|
||||
|
||||
**Command (F1 live-input), `add 1:n relationship`:**
|
||||
|
||||
```
|
||||
Hint
|
||||
What: Link two tables so a parent row can own many child rows.
|
||||
Example: add 1:n relationship from Customers.id to Orders.customer_id
|
||||
Concept: The "1:n" means one parent, many children. The child column
|
||||
holds the foreign key; `--create-fk` adds it for you if it
|
||||
doesn't exist yet.
|
||||
```
|
||||
|
||||
## Forks (all user-chosen, 2026-06-14)
|
||||
|
||||
- **Trigger model:** both a keybinding (live input) and a submitted
|
||||
command (last error), rather than command-only or keybinding-only — the
|
||||
live-input path is the most useful, but the command completes the A1
|
||||
slot and serves the error case.
|
||||
- **Keybinding = F1:** the universal help convention; the key is
|
||||
genuinely free (no `KeyCode::F(1)` binding exists today — the `"F1"`
|
||||
strings in `input_render.rs`/tests are scenario labels, not the key, and
|
||||
ADR-0022 uses no `F1` requirement label). No collision with the ADR-0049
|
||||
readline keys, `Ctrl-O` (ADR-0046), `Esc`-clear, or the reserved
|
||||
`Ctrl-C` cancel (I5). Rejected: `?` (a typeable character — fiddly
|
||||
position-dependent handling) and a Ctrl/Alt chord (less discoverable, no
|
||||
advantage).
|
||||
- **No topic argument:** contextual only; `help <topic>` already owns
|
||||
explicit reference lookup.
|
||||
- **Comprehensive content for v1:** the full inventory, not a starter
|
||||
subset.
|
||||
- **Exemplars-first authoring:** lock the voice on a few blocks, then
|
||||
mass-author to template.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **A1 closes.** With `hint` registered and built, all 15 canonical
|
||||
app-level commands exist in both modes.
|
||||
- **A third contextual tier exists.** Students get on-demand, teaching-
|
||||
grade guidance that is deeper than the always-on colour, the headline,
|
||||
the ambient one-liner, and the verbose error hint — without cluttering
|
||||
those terse defaults.
|
||||
- **One new keybinding (F1)** joins the keymap and the ADR-0051 strip.
|
||||
- **A new `hint_ids: &[&str]` field on `CommandNode`** (mirroring
|
||||
`usage_ids`) + a `hint_key_for_input_in_mode` lookup (reusing the
|
||||
`usage_key_for_input_in_mode` form-disambiguation), one new field of
|
||||
`App` state (`last_error_hint_key`), and one new renderer family
|
||||
(`note_hint*`); the `AppCommand` enum gains `Hint`, the grammar a `HINT`
|
||||
node, the REGISTRY one entry.
|
||||
- **A durable content corpus** (~37 command blocks + 10 runtime
|
||||
error-class blocks) enters the catalogue under `hint.cmd.*` /
|
||||
`hint.err.*`, validated by `keys.rs`. This is ongoing surface area: new
|
||||
commands/error classes should ship with their tier-3 hint (a checklist
|
||||
item for future feature ADRs). (Diagnostic-class blocks deferred — #38.)
|
||||
- **Testing:** Tier-1 unit tests for the trigger matrix (F1 with
|
||||
empty/non-empty input; `hint` with/without a recent error;
|
||||
`last_error_hint_key` set on the `translate_error` sites and cleared on
|
||||
success; the mode-aware form resolution; the `:` strip), the
|
||||
command-identification logic, and the tier-2 fallback; Tier-2 `insta`
|
||||
snapshots for a representative rendered hint block; Tier-3 integration
|
||||
tests for the end-to-end flows (type a partial command → F1 → block
|
||||
appears, **buffer and completion memo untouched**; run a failing
|
||||
command → `hint` → error expansion). **A comprehensiveness coverage
|
||||
test** (enforces D6): iterate the REGISTRY and assert every node with a
|
||||
`hint_ids` entry resolves to a `hint.cmd.*` block, and every runtime
|
||||
error class resolves to a `hint.err.*` block — `keys.rs` only checks
|
||||
that *referenced* keys resolve, not that every command/error *has* one,
|
||||
so this test is what makes the scope enforceable rather than
|
||||
aspirational. (Diagnostic classes are out of this scope — D6 / #38.)
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Per-topic `hint <topic>`** — OOS (rejected): `help <topic>` already
|
||||
serves explicit lookup; a topic arg would overlap it and double the
|
||||
content-authoring surface.
|
||||
- **Re-showing tier-3 inline as the always-on ambient hint** — OOS
|
||||
(rejected): the ambient panel stays terse by design (ADR-0022); tier-3
|
||||
is on-demand. Promoting it would defeat the tiering.
|
||||
- **Localised tier-3 content beyond `en-US`** — OOS (deferred): the
|
||||
catalogue is structured for i18n (ADR-0019), but additional locales
|
||||
follow the project's English-only-for-v1 stance (requirements X2).
|
||||
- **`hint` for a *successful* command's deeper teaching** (e.g. "you just
|
||||
created a table — here's what an index would add") — OOS (deferred): a
|
||||
plausible future tier-3 use, but v1 scopes the command path to errors
|
||||
and the F1 path to in-progress input.
|
||||
- **Clause-concept hints** (`… on delete ⟨action⟩`, constraint slots,
|
||||
`with pk`, cardinality) — OOS (deferred, issue #37): a
|
||||
`hint.concept.<topic>` layer surfaced when the cursor sits in a
|
||||
recognized clause, deeper than tier-2's candidate list but narrower than
|
||||
the per-form block. Per-form keying (D3) does not lock it out. To be
|
||||
tackled as a deliberate follow-up job, not gated on usage statistics.
|
||||
- **Pre-submit-diagnostic route + `diagnostic.*` tier-3 blocks** — OOS
|
||||
(deferred, issue #38): needs a class field on `Diagnostic` threaded
|
||||
through every creation site (broad change) for marginal value, since
|
||||
tier-2 already surfaces diagnostics and many duplicate runtime classes
|
||||
(D6).
|
||||
|
||||
## Content inventory (implementation tracking)
|
||||
|
||||
The implementation plan enumerates and checks off every block:
|
||||
|
||||
- **`hint.cmd.<hint_id>`** — one per distinct `REGISTRY` node (~37), each
|
||||
with its own `hint_id` and a mode-correct example: app (`save`, `save
|
||||
as`, `load`, `new`, `rebuild`, `export`, `import`, `replay`, `undo`,
|
||||
`redo`, `mode`, `messages`, `copy`, `help`, `hint`, `quit`); DDL
|
||||
(`create table`, `create m:n`, `add column`/`relationship`/`index`,
|
||||
`drop`, `rename`, `change column`); DML (`insert`, `update`, `delete`,
|
||||
`show`, `seed`, `explain`, `select`/`with`). The **7 advanced-mode SQL
|
||||
forms** (`SQL CREATE TABLE`, `ALTER TABLE`, `CREATE/DROP INDEX`, `DROP
|
||||
TABLE`, `SQL INSERT/UPDATE/DELETE`, `EXPLAIN SQL`, raw `SELECT`/`WITH`)
|
||||
each get their **own** block with SQL syntax — they do **not** reuse
|
||||
their simple sibling's (this is the `/runda` correction; the parallel
|
||||
`help`-side gap is issue #36).
|
||||
- **`hint.err.*`** — one per runtime error class (`unique`,
|
||||
`foreign_key.{child,parent}_side`, `not_null`, `check`,
|
||||
`type_mismatch`, `not_found`, `already_exists`, `generic`,
|
||||
`invalid_value`). The `diagnostic.*` pre-submit classes are **deferred**
|
||||
(D6 / issue #38).
|
||||
+10
-5
File diff suppressed because one or more lines are too long
@@ -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,104 @@
|
||||
# CI subproject handoff — 2026-06-15 (ci-01)
|
||||
|
||||
First handover for the **CI / release subproject** (the `ci` branch). Kept in
|
||||
`docs/ci/handoff/`, a namespace separate from the project's global
|
||||
`docs/handoff/` session sequence so it can't collide with `main`'s numbering —
|
||||
the same split as `docs/ci/adr/`, and needed for the same reason: `main`
|
||||
independently wrote its own **handoff-70** this same day (just as it took
|
||||
**ADR-0049**), which would have collided.
|
||||
|
||||
A dedicated infrastructure session that built the project's **entire CI/CD
|
||||
pipeline** on the self-hosted Gitea Actions runner — from nothing to a live
|
||||
gate plus a six-target cross-platform release. Net: the **CI** /
|
||||
`requirements.md` **TT5** item and **D1**/**D2** are now done; **D3** and a
|
||||
couple of TT5 tails remain. Decisions are recorded in the sibling ADR namespace
|
||||
**`docs/ci/adr/`** (ADR-ci-001/002/003).
|
||||
|
||||
## §1. State at handoff
|
||||
|
||||
**Branch:** `ci` (worktree). **`main` has been merged into `ci`** (commit
|
||||
`138e766`, clean — `ci` and `main` touched disjoint files) so the gate runs
|
||||
against current `main` before CI lands there. Working tree clean except the
|
||||
in-progress doc updates from this handoff. Pushes/promotion are the user's
|
||||
step.
|
||||
|
||||
**Gate verified locally on the merged code:** `clippy -D warnings` clean;
|
||||
**`cargo test` 2488 passing / 0 failing / 1 ignored** (the long-standing
|
||||
`friendly` doctest). main's features came in with their tests (2424 → 2488).
|
||||
|
||||
**Pipeline (`.gitea/workflows/`):**
|
||||
|
||||
- `build-ci-image.yaml` — builds + pushes the CI image (`node:22-bookworm-slim`
|
||||
+ single-user nix + the flake's devShell pre-warmed) to the Gitea registry.
|
||||
Triggers only on image-input changes (Dockerfile / flake / toolchain).
|
||||
- `ci.yaml` — the gate: `clippy -D warnings` + `cargo test`, branch pushes + PRs
|
||||
(docs-only changes skipped).
|
||||
- `release.yaml` — on a `v*` tag: `test` → `build` matrix over the **four
|
||||
non-macOS** targets via `cargo-zigbuild`, upload to the Gitea release.
|
||||
- `release-macos.yaml` — **workflow_dispatch** (tag input) on the Tart
|
||||
Apple-Silicon runner (`runs-on: macos`): test → build both `*-apple-darwin`
|
||||
→ de-nix `libiconv` + ad-hoc re-sign → upload.
|
||||
|
||||
**Verified live this session:** the 4-target release published **8 assets**
|
||||
(binary + `.sha256` each) for tag `v.0.0.0-citest3`; the macOS build was proven
|
||||
portable (system-only deps) + signed + launches on the runner.
|
||||
|
||||
## §2. What was built (and the non-obvious bits)
|
||||
|
||||
- **Nix flake** (ADR-ci-002, relocated from a would-be `main` ADR-0049): one
|
||||
pinned toolchain (`1.95.0`) for dev *and* CI; `cargo-zigbuild` + `zig` (Linux
|
||||
only) for the cross targets; `apple-sdk` on darwin.
|
||||
- **Runner facts** (ADR-ci-001): jobs run *inside* a container (`ci-public` →
|
||||
`catthehacker/ubuntu`), so host nix is unreachable — hence the baked image.
|
||||
The Mac runner is **host execution**; its label is `macos` (`:host` in the
|
||||
registration is the act_runner backend, not part of the label).
|
||||
- **Cross-compile** (ADR-ci-003): `cargo-zigbuild` for the 4 non-macOS targets.
|
||||
Windows needs an **empty `libsynchronization.a` stub** (`ci/winstub/`, wired
|
||||
via `.cargo/config.toml`) — std links `-lsynchronization`, absent from
|
||||
rust-overlay's toolchain + zig's mingw, but forwarded by `kernel32`.
|
||||
- **macOS** (ADR-ci-003 amendment): built on **real Apple hardware** (Tart), so
|
||||
the SDK is fully licensed — no osxcross grey area. The darwin stdenv bakes a
|
||||
`/nix/store` `libiconv` path into the binary; the build rewrites it to
|
||||
`/usr/lib/libiconv.2.dylib` (`install_name_tool`) and re-signs ad-hoc
|
||||
(`codesign -f -s -`; `install_name_tool` invalidates the signature, arm64
|
||||
refuses unsigned). A guard fails the build on any remaining `/nix/store` dep.
|
||||
- **Cache hygiene (Mac):** the runner wipes the workspace each run, so cargo
|
||||
`target/` never accumulates; the persistent nix store is bounded by
|
||||
**generation** (record the devShell in a persistent profile, keep the 2
|
||||
newest via `nix-env --delete-generations +2`, GC the rest). First sweep
|
||||
reclaimed a ~3.8 GB one-time backlog of build scaffolding (source + build-only
|
||||
deps, *not* re-installed toolchains).
|
||||
|
||||
## §3. Immediate next steps (user)
|
||||
|
||||
1. **Push `ci`** → the gate re-runs in CI (should be green; no image rebuild —
|
||||
the merge didn't touch the flake/Dockerfile).
|
||||
2. **Promote:** `git checkout main && git merge ci` — a **fast-forward** (`ci`
|
||||
already contains `main`) — then push `main`. CI goes live; `release-macos`
|
||||
becomes dispatchable (workflow_dispatch needs the default branch).
|
||||
3. **First real release:** tag `v0.1.0` (auto-builds the 4 Linux/Windows
|
||||
assets), then **dispatch `release-macos` for `v0.1.0`** with the Mac up (adds
|
||||
the 2 macOS assets) → a full 6-binary release.
|
||||
4. **Cleanup:** delete the `v.0.0.0-citest*` test tags + their releases.
|
||||
5. **Runner-side:** add `min-free`/`max-free` to the Mac's `/etc/nix/nix.conf`
|
||||
as a hands-off nix-store backstop.
|
||||
|
||||
## §4. Known gaps / follow-ups
|
||||
|
||||
- **Versioning is not wired into the binary** (flagged by the user). The release
|
||||
**git tag is nowhere in the produced binary** — there is no `--version` flag,
|
||||
no `CARGO_PKG_VERSION` use anywhere in `src/`, and the release workflows use
|
||||
the tag only for the *release name* + *asset filenames*
|
||||
(`rdbms-playground-<tag>-<target>`). `Cargo.toml` is a static `version =
|
||||
"0.1.0"`, decoupled from the tag. So a `v0.5.0` tag yields a `…-v0.5.0-…`
|
||||
asset whose binary knows nothing of "0.5.0". To fix later: add a `--version`
|
||||
flag, and inject the tag at build time (e.g. a `build.rs` reading a
|
||||
CI-provided env, or bumping `Cargo.toml` as part of tagging) so the binary and
|
||||
the release agree.
|
||||
- **D3 packaging** — Homebrew / Scoop / winget / `cargo binstall` manifests
|
||||
(asset naming is already binstall-friendly).
|
||||
- **TT5 tails** — Windows is build-only (no execution runner); Tier-4 PTY (TT4)
|
||||
is unwired in CI.
|
||||
- **`fmt` gate** — deliberately off (tree isn't stock-`rustfmt`-clean); revisit
|
||||
on `main`.
|
||||
- **Website → Cloudflare** deploy — the separate, simpler workflow, still to do.
|
||||
@@ -0,0 +1,21 @@
|
||||
# CI / Build subproject — session handoffs
|
||||
|
||||
Handover notes for the **CI / release pipeline** work (the Gitea Actions
|
||||
workflows under `.gitea/`, the nix flake, the release tooling). Kept in their
|
||||
own namespace, separate from the project-wide session handoffs in
|
||||
[`docs/handoff/`](../../handoff/), so a CI-branch handoff never competes with
|
||||
`main`'s global handoff sequence for numbers — the same split the CI ADRs use
|
||||
([`docs/ci/adr/`](../adr/README.md)). This is not hypothetical: `main`
|
||||
independently wrote a `handoff-70` the same day this subproject's first handoff
|
||||
was drafted.
|
||||
|
||||
**Numbering.** Files are named `<date>-handoff-ci-<NN>.md` and referenced in
|
||||
prose as `handoff-ci-NN`. Assign the next free `NN` from this index.
|
||||
|
||||
## Index
|
||||
|
||||
- [handoff-ci-01 — the CI/release pipeline build-out](20260615-handoff-ci-01.md)
|
||||
— Gitea Actions gate (clippy + test) + a six-target release (four via
|
||||
`cargo-zigbuild` on a `v*` tag, two macOS via dispatch on a Tart runner), all
|
||||
on a nix flake; decisions in `docs/ci/adr/`. Built on the `ci` branch, merged
|
||||
`main` in, gate green (2488 tests), ready to promote to `main`.
|
||||
@@ -0,0 +1,173 @@
|
||||
# Session handoff — 2026-06-12 (68)
|
||||
|
||||
Sixty-eighth handover. Continues directly from handoff-67 (which
|
||||
triaged a manual-testing pass into fixes + filed issues). This was an
|
||||
**issue-burndown session**: six Gitea issues closed across five
|
||||
commits, each landed with the full phased workflow + a `/runda` +
|
||||
Devil's-Advocate pass before commit. Net: **six issues closed, five
|
||||
commits, +29 tests, zero regressions.**
|
||||
|
||||
## §1. State at handoff
|
||||
|
||||
**Branch:** `main`. Working tree **clean**; all work committed.
|
||||
**Five unpushed commits** (push is the user's step).
|
||||
|
||||
**Tests: 2436 passing / 0 failing / 0 skipped / 1 ignored** (the
|
||||
long-standing `friendly` doctest). **Clippy clean** (nursery, all
|
||||
targets). Breakdown: 1730 lib + 506 integration (`it`) + 200
|
||||
typing-surface-matrix. +29 over handoff-67's 2407.
|
||||
|
||||
**Commits since handoff-67:**
|
||||
```
|
||||
ee3ccd8 feat(hint): advertise the optional seed count in the hint panel (#26)
|
||||
deb0948 feat(seed): year-as-int + conventional choice-set heuristics (#33, #34)
|
||||
fde50ce fix(ui): mark sidebar focus with an accent colour, not bold (#25)
|
||||
3d4a0fd fix(render): trim IEEE-754 noise from displayed decimal arithmetic (#32)
|
||||
7e4bc12 fix(completion): treat a bare in-scope table alias as an alias, not an unknown column (#31)
|
||||
```
|
||||
|
||||
## §2. Issues closed this session (all committed, all tested, all `/runda`-reviewed)
|
||||
|
||||
Each closed on `git.lazyeval.net/oli/rdbms-playground` with a summary
|
||||
comment. The `/runda` pass earned its keep on every one — see the
|
||||
"DA caught" notes.
|
||||
|
||||
1. **#31 (`7e4bc12`) — bare table alias treated as unknown column.**
|
||||
A bare in-scope table alias in a SQL expression (`… GROUP BY o`,
|
||||
`o` aliasing `FROM Orders o`) got `no such column o on table …` and
|
||||
zero completions. Now: completion offers each FROM source's
|
||||
*qualifier* (alias-if-present-else-table) at a bare `sql_expr_ident`
|
||||
slot; the `matched.len()==0` arm emits a targeted
|
||||
`alias_used_as_column` / `table_used_as_column` hint after the
|
||||
projection-alias check. **DA caught** two real bugs pre-commit: a
|
||||
DSL leak (the hint fired for simple-mode `expr_column` refs, which
|
||||
have no `table.column` syntax) and wrong advice for an
|
||||
aliased-table-by-real-name — both fixed by gating on
|
||||
`role == "sql_expr_ident"` + matching the *effective qualifier*.
|
||||
ADR-0032 Amendment 3.
|
||||
|
||||
2. **#32 (`3d4a0fd`) — decimal aggregation float noise.** `decimal`
|
||||
is exact TEXT, but SQLite has no decimal type, so arithmetic
|
||||
coerces to IEEE-754 double; `sum(price*qty)` rendered
|
||||
`298.59999999999997`. Now `format_real_display` (db.rs) rounds REAL
|
||||
to 15 sig figs **for display only**, wired into `format_cell`.
|
||||
**DA caught** a real regression: I'd also wired it into
|
||||
`render_value`, which is a *canonical identity key* for the
|
||||
uniqueness dry-runs (`dry_run_unique`, `check_uniqueness_collisions`)
|
||||
— rounding there would report collisions the exact-valued engine
|
||||
wouldn't. Reverted `render_value` to exact; locked with a
|
||||
regression test. CSV/FK-key/EXPLAIN paths stay exact. ADR-0005
|
||||
Amendment 1.
|
||||
|
||||
3. **#25 (`fde50ce`) — sidebar focus accent colour, not bold.** Bold
|
||||
box-drawing glyphs render broken in asciinema casts.
|
||||
`panel_border_style` now uses a non-bold accent colour
|
||||
(`theme.mode_simple`); bold stays fine on text spans. **DA caught**
|
||||
that the issue's "Tier-2 snapshots need re-accepting" was wrong —
|
||||
`render_to_string` is text-only, so no snapshot changed. Added a
|
||||
render-level test that inspects the actual border *cells*.
|
||||
User visually confirmed. ADR-0046 Amendment 1.
|
||||
|
||||
4. **#33 + #34 (`deb0948`) — seed heuristics: year-as-int + choice
|
||||
sets.** Two additive D7 catalogue rules. **#33:** `year`/`*_year`/
|
||||
`published`/`founded` → bounded `int` year (1950–2025, or the
|
||||
`dob`-style birth window 1945–2007 for `birth`/`born`/`dob`); new
|
||||
`YearRecent`/`YearBirth` generators. Placed *after* the quantity
|
||||
rule so `year_count` stays a count. **#34:** type-gated `PickFrom`
|
||||
sets for `priority`/`prio`, `severity`, `rating`/`stars`; `status`
|
||||
**deliberately excluded** (user-confirmed on the issue — values too
|
||||
domain-specific). `priority` left `ENUM_TOKENS`. A user `IN`-CHECK
|
||||
still wins. **DA/process caught** that I'd skipped reading the issue
|
||||
*comments* (where the `status` decision + a website cast note lived)
|
||||
— **lesson: always read issue comments**. Also closed a
|
||||
pre-existing column-fill integration-test gap. ADR-0048 Amendment 1.
|
||||
|
||||
5. **#26 (`ee3ccd8`) — optional `count` advertised in the hint panel.**
|
||||
At `seed <table> ▮` only `set`/`--seed` chips showed; the optional
|
||||
row count (a bare positional number) was invisible, and the prior
|
||||
`IntroProse` attempt was reverted because `pending_hint_mode` is
|
||||
cleared by the trailing optionals. Now `walk_optional` stashes a
|
||||
skipped inner's `IntroProse` key into a new
|
||||
`WalkContext.surviving_intro_hint` (key + position) before the empty
|
||||
match clears it; a **position guard** (`pos == cursor`) stops it
|
||||
leaking past a later `set …` clause or once the count is given. Tab
|
||||
still cycles the keywords. Prose mentions the count, `.column`
|
||||
column-fill, `set`, and `--seed` (user-chosen scope). **DA caught**
|
||||
a coverage gap (advanced-mode path untested — seed runs in both
|
||||
modes); added the test. ADR-0022 Amendment 7.
|
||||
|
||||
## §3. Open issues — next session's candidates
|
||||
|
||||
Four open, all on `git.lazyeval.net/oli/rdbms-playground`. **All four
|
||||
are interaction/UX design changes that need a decision or two from the
|
||||
user up front — none is a pure mechanical fix.** Read each issue body
|
||||
**and its comments** before starting (the #33/#34 lesson).
|
||||
|
||||
- **#28 — Reconsider relationship prose in `add column` (incidental
|
||||
DDL) confirmations** *(enhancement)*. **Revisits a decided area** →
|
||||
needs a **new ADR** superseding the relevant part of ADR-0016 §5 /
|
||||
ADR-0044 §1. User preference (from the issue): do **not** show the
|
||||
`References:` / `Referenced by:` block in the add-column
|
||||
confirmation. Confirm scope with the user (just `add column`, or all
|
||||
incidental DDL). The highest-ceremony of the four.
|
||||
|
||||
- **#27 — Bottom status line: keybindings-only, context- and
|
||||
state-aware; add `mode advanced` to empty hint** *(enhancement)*.
|
||||
Per-nav-focus keybindings (Input vs sidebar), **including transient
|
||||
states** (Tab-cycle, history) per user preference. May warrant a
|
||||
small ADR. Touches `src/ui.rs` rendering + the nav-focus model
|
||||
(ADR-0046).
|
||||
|
||||
- **#29 — Command input keystroke support.** Esc / double-Esc to clear
|
||||
a partly-typed command; possibly Ctrl-A/Ctrl-E (Home/End). Relates
|
||||
to the deferred **I1b readline shortcuts** (`requirements.md`).
|
||||
**Needs a key-set decision** from the user before coding.
|
||||
|
||||
- **#30 — History brings back all commands in both modes.**
|
||||
Advanced-mode history entries can't replay in simple mode; proposal:
|
||||
if we can distinguish them, prepend `:` to reuse advanced history
|
||||
from simple mode. Interaction design; touches the input-history +
|
||||
mode model (ADR-0003).
|
||||
|
||||
No strong ordering. **#28** is the only one that *must* produce an ADR.
|
||||
**#29** is closest to "small once the key-set is decided." **#27** and
|
||||
**#30** are medium UX work.
|
||||
|
||||
## §4. Carried-over follow-up (not a `main`-branch task)
|
||||
|
||||
- **Website `seed` cast re-record** (from #34's comment thread). The
|
||||
`website` branch ships a `seed` cast exercising a `tickets` table
|
||||
with `priority`; now that `priority` collapses to `low/medium/high`,
|
||||
the cast should be re-recorded (`cd website && pnpm casts seed`,
|
||||
needs a `../target/debug` binary) so the table tightens. The issue
|
||||
comment notes it is **likely redundant** — casts get a full
|
||||
re-record sweep before publication. Tracked on the `website` branch,
|
||||
**not** here. `website/` is not in the `main` tree.
|
||||
|
||||
## §5. Other open roadmap (unchanged from handoff-67 §5)
|
||||
|
||||
`seed` is feature-complete (`requirements.md` SD1/SD2 `[x]`, now with
|
||||
the #33/#34 catalogue refinements noted inline). User's call:
|
||||
|
||||
- **H2 `hint`** — the last A1 gap (its own ADR).
|
||||
- **TT5 CI** — test infra exists; no CI workflow yet (the `ci` branch
|
||||
exists — check its state before starting).
|
||||
- **TT4 PTY (Tier-4)** — ADR-0008 specifies it; not wired.
|
||||
- Larger: **V4 journal**, **tutorial/lesson system** (each needs an ADR).
|
||||
|
||||
## §6. How to take over
|
||||
|
||||
1. Read handoffs 66 → 67 → 68, `CLAUDE.md`, `docs/requirements.md`.
|
||||
2. Confirm green baseline: `cargo test` (expect 2436 pass / 1 ignored)
|
||||
+ `cargo clippy --all-targets` (clean).
|
||||
3. Pick from §3 (#28/#27/#29/#30). **For each, read the issue body AND
|
||||
its comments** before designing, and **escalate the design fork to
|
||||
the user** before coding — all four have genuine UX decisions. #28
|
||||
needs a new ADR.
|
||||
4. Follow the project workflow: phased (requirements → divergent →
|
||||
eval → execute → verify), test-first (failing test before the fix),
|
||||
`/runda` + DA pass before every commit, ADR amendment for any
|
||||
decided-area change + the README index-upkeep rule, and confirm the
|
||||
commit message with the user before committing.
|
||||
5. Consider a `cargo sweep` at this milestone (`target/` grows across
|
||||
sessions; see CLAUDE.md "Build hygiene").
|
||||
@@ -0,0 +1,203 @@
|
||||
# Session handoff — 2026-06-14 (69)
|
||||
|
||||
Sixty-ninth handover. Continues from handoff-68 (an issue-burndown that
|
||||
closed #25/#26/#31/#32/#33/#34). This session **closed the four
|
||||
remaining open issues** — #29, #28, #27, #30 — each landed with the full
|
||||
phased workflow + `/runda` + Devil's-Advocate passes before commit, and
|
||||
each producing a new ADR. Net: **four issues closed, four commits, four
|
||||
new ADRs (0049–0052), +63 tests, zero regressions, the tracker is now
|
||||
empty.**
|
||||
|
||||
The four interlock: **#29** added the input-field readline keys, **#27**
|
||||
advertises them in a state-aware status strip, and **#30**'s history
|
||||
recall now respects modes. **#30** also turned into a real architecture
|
||||
change (journaling relocation) — read §2.4 carefully before touching that
|
||||
area.
|
||||
|
||||
## §1. State at handoff
|
||||
|
||||
**Branch:** `main`. Working tree **clean**; all work committed. The two
|
||||
most recent commits are local (normal working state — push is the user's
|
||||
step).
|
||||
|
||||
**Tests: 2471 passing / 0 failing / 0 skipped / 1 ignored** (the
|
||||
long-standing `friendly` doctest). **Clippy clean** (nursery, all
|
||||
targets). Breakdown: 1771 lib + 500 integration (`it`) + 200
|
||||
typing-surface-matrix. **+35 over handoff-68's 2436** (net: #29 +22, #28
|
||||
+0, #27 +9, #30 +4 — its new history.rs/app.rs/iteration6 tests minus the
|
||||
15 retired worker-journaling tests; trust the live `cargo test` count).
|
||||
|
||||
**Commits this session:**
|
||||
```
|
||||
4aeea55 feat(history): mode-tagged history + top-of-chain journaling (#30)
|
||||
eceedc1 feat(ui): context- and state-aware bottom keybinding strip (#27)
|
||||
8ac3537 feat(render): incidental-DDL confirmations show structure only, no relationships (#28)
|
||||
66c8bda feat(input): readline keymap — Esc-clear + Ctrl-A/E/W/K/U (#29)
|
||||
```
|
||||
|
||||
**Open Gitea issues: none.** `tea issues list --state open` is empty.
|
||||
|
||||
## §2. Issues closed this session (all committed, tested, `/runda`-reviewed)
|
||||
|
||||
Each closed on `git.lazyeval.net/oli/rdbms-playground` with a summary
|
||||
comment.
|
||||
|
||||
### 2.1 — #29 (`66c8bda`) — input-field readline keymap (ADR-0049)
|
||||
|
||||
Implements the deferred **I1b** readline shortcuts: `Esc` clears a
|
||||
partly-typed command (only when no completion memo is alive — the memo
|
||||
wins first, ADR-0022); `Ctrl-A`/`Ctrl-E` = Home/End; `Ctrl-W` deletes
|
||||
the previous word (readline-style, UTF-8 safe); `Ctrl-K`/`Ctrl-U` kill to
|
||||
end/start. Cursor-only keys leave history nav intact; buffer-mutating
|
||||
keys end it. **DA caught** the need for the `Ctrl-O`+`Esc` (sidebar
|
||||
nav-exit) interaction not to clear the draft — locked with a regression
|
||||
test. `requirements.md` I1b → `[x]`.
|
||||
|
||||
### 2.2 — #28 (`8ac3537`) — incidental-DDL confirmations: structure-only (ADR-0050)
|
||||
|
||||
Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/
|
||||
`rename`/`change column`, `add`/`drop index`) now render **structure
|
||||
only** — no `References:` / `Referenced by:` block. Relationship-subject
|
||||
surfaces (`show table`, `add`/`drop relationship`) keep their ADR-0044
|
||||
diagrams. The prose renderer (`relationship_prose_lines` + `cols_disp`)
|
||||
was deleted. **Supersedes** ADR-0044 §1's incidental-DDL prose clause and
|
||||
the relationship-block half of ADR-0016 §5 (both annotated).
|
||||
|
||||
### 2.3 — #27 (`eceedc1`) — context- and state-aware keybinding strip (ADR-0051)
|
||||
|
||||
The bottom status line is now keystrokes-only and **state-selected** by
|
||||
priority (sidebar focus / completion-memo / history-nav / editing /
|
||||
default). The editing state surfaces the #29 keys (closing ADR-0049's
|
||||
deferred advertisement). Mode-switch advertisements left the strip; the
|
||||
empty-input hint gained a simple-mode `` `mode advanced` for SQL `` pointer
|
||||
(advanced mode shows none — user decision). New `App::is_browsing_history()`
|
||||
exposes the private `history_cursor`. 15 full-panel snapshots re-accepted.
|
||||
|
||||
### 2.4 — #30 (`4aeea55`) — mode-tagged history + top-of-chain journaling (ADR-0052) **← read before touching journaling**
|
||||
|
||||
Closed both the feature (advanced history reusable in simple mode) and
|
||||
the bug (the `:` one-shot prefix lost across sessions). Two halves:
|
||||
|
||||
1. **Mode-tagged history.** The `history.log` status token gains an
|
||||
optional `:adv` suffix (`ok` / `ok:adv` / `err` / `err:adv`); `source`
|
||||
stays last + canonical so replay is unaffected. The in-memory ring
|
||||
(still `Vec<String>`) stores advanced entries in their `: `-prefixed
|
||||
simple-mode runnable form; recall **strips the `:` in advanced mode**
|
||||
and keeps it in simple; hydration reconstructs the prefix from the tag.
|
||||
App commands journal simple and are excluded from the ring's advanced
|
||||
flag, so they recall bare.
|
||||
|
||||
2. **Journaling relocation (the architecture change).** Success
|
||||
journaling **moved out of the worker** to the dispatch layer
|
||||
(`spawn_dsl_dispatch` / `run_replay` / the app-command sites), next to
|
||||
the already-top-level failure journaling — so the submission mode is in
|
||||
scope with no worker plumbing. `finalize_persistence` now writes only
|
||||
the **state** sources (yaml/csv); the journal write is **best-effort**
|
||||
(the command is already committed — consistent with the failure path).
|
||||
**Amends ADR-0015 §6** (history.log out of the worker tx; commit-db-last
|
||||
scopes yaml/csv/db only), **ADR-0034** (status tag + journaling
|
||||
location), **ADR-0040** (journal-write best-effort, not fatal).
|
||||
|
||||
**Two DA findings, both resolved:** (a) the app-command `advanced` flag
|
||||
must exclude app commands (else `: save as` diverges); (b) the spawn
|
||||
journals on `outcome.is_ok()`, so journaling is now **uniform** — read
|
||||
commands that didn't journal before (`show tables`/`show relationships`/
|
||||
`show indexes`, `show relationship <name>`, `explain`) now do, matching
|
||||
ADR-0034 §1. **User-confirmed** as the more-correct behaviour (harmless
|
||||
on replay — reads/`explain` don't mutate).
|
||||
|
||||
**Test migration:** 15 worker-level journaling tests were retired (the
|
||||
worker no longer journals — their yaml/csv/operation assertions were
|
||||
kept) and re-covered at the new layer: `history.rs` status-tag +
|
||||
`:`-reconstruct; `app.rs` recall matrix; the cross-session regression
|
||||
`advanced_command_journalled_then_hydrated_recalls_with_colon_in_simple`
|
||||
in `iteration6_resume_history`; the replay tests cover `run_replay`
|
||||
journaling.
|
||||
|
||||
Plan: `docs/plans/20260613-issue-30-top-of-chain-journaling.md`.
|
||||
|
||||
## §3. Next session — start here
|
||||
|
||||
The user's stated plan for the next session, in order:
|
||||
|
||||
1. **Pick up the ADR-0052 follow-up** (below).
|
||||
2. **Check for any newly-filed open issues** (`tea issues list --state
|
||||
open`) — none at handoff, but check fresh.
|
||||
3. **Then** take on remaining open tasks from the general requirements
|
||||
(`docs/requirements.md`) — see §5.
|
||||
|
||||
### The ADR-0052 follow-up — unwind the vestigial worker `source` plumbing
|
||||
|
||||
When journaling moved out of the worker, the `source` that the worker
|
||||
threaded purely for journaling became dead. To avoid orphaning the param
|
||||
across ~28 handlers, the refactor **left it in place** as vestigial:
|
||||
|
||||
- `finalize_persistence(conn, persistence, _source, changes)` — the
|
||||
`_source` param is now unused (kept so its ~28 callers still pass
|
||||
`source`, which they otherwise also use for `snapshot_then`).
|
||||
- `do_rebuild_from_text(conn, _persistence, _source, project_path)` —
|
||||
both `_persistence` and `_source` vestigial.
|
||||
- Three thin read-only wrappers in `db.rs` —
|
||||
`do_describe_table_request`, `do_query_data_request`,
|
||||
`do_run_select_request` — now just delegate to their non-`_request`
|
||||
twin (`do_describe_table` / `do_query_data` / `do_run_select`) with
|
||||
vestigial `_persistence` / `_source` params and one caller each
|
||||
(`db.rs` Request arms ~2409 / ~2749 / ~2759).
|
||||
|
||||
**The cleanup:** remove `_source` from `finalize_persistence` + drop the
|
||||
arg at its ~28 callers (the callers keep `source` for `snapshot_then`, so
|
||||
only the `finalize_persistence(...)` call loses the arg); remove the
|
||||
`_persistence`/`_source` params from `do_rebuild_from_text`; and inline
|
||||
the three `*_request` wrappers at their single call sites (replace
|
||||
`do_describe_table_request(conn, persistence, source, name)` with
|
||||
`do_describe_table(conn, &name)`, etc.), deleting the wrappers. Purely
|
||||
mechanical, compiler-guided, no behaviour change. Establish the green
|
||||
baseline first (`cargo test`), then verify nothing moved.
|
||||
|
||||
## §4. Carried-over follow-up (website branch, not `main`)
|
||||
|
||||
- **Website `seed` cast re-record** (from #34, handoff-68 §4) — still
|
||||
tracked on the `website` branch, not here. Likely redundant (full
|
||||
re-record sweep before publication).
|
||||
|
||||
## §5. Remaining roadmap — `docs/requirements.md` (next session's §3-step 3)
|
||||
|
||||
With the issue tracker empty, the next work comes from the document-based
|
||||
requirements. Open / partial items worth weighing (the user picks):
|
||||
|
||||
- **H2 `hint`** — the last A1 gap (contextual help for the current
|
||||
command); its own ADR. (`requirements.md` H2.)
|
||||
- **TT5 CI** — runs all tiers on Linux/macOS/Windows; no CI workflow yet
|
||||
(a `ci` branch reportedly exists — check its state first). Couples with
|
||||
**D1–D3** (cross-platform prebuilt binaries + Homebrew/Scoop).
|
||||
- **TT4 PTY (Tier-4)** — ADR-0008 specifies the PTY harness + four
|
||||
critical flows; still not wired (no PTY deps/tests).
|
||||
- **I1 multi-line input** (Ctrl-Enter submits, Enter inserts newline) and
|
||||
**I5 / B3 in-flight cancellation** (Ctrl-C cancels a running command).
|
||||
- **V4 session journal** — scrollable per-session log + Markdown export
|
||||
(the bigger UX project; own ADR).
|
||||
- **TU1 tutorial / lesson system** — design + ADR pending (acknowledged
|
||||
in scope).
|
||||
- Smaller partials: **C3a** modify relationship (drop+add covers it
|
||||
today), **C4** m:n convenience, **V3** ER-diagram export, the **NFR-***
|
||||
performance/visual targets (mostly unmeasured), **N4** global rolling
|
||||
history (OOS for v1).
|
||||
|
||||
No strong ordering — these are the user's call. Several need a new ADR
|
||||
(H2, V4, TU1); CI/release (TT5/D1–D3) is the most "shippable-product"
|
||||
track if that's the priority.
|
||||
|
||||
## §6. How to take over
|
||||
|
||||
1. Read handoffs 67 → 68 → 69, `CLAUDE.md`, `docs/requirements.md`.
|
||||
2. Confirm green baseline: `cargo test` (expect **2471 pass / 1 ignored**)
|
||||
+ `cargo clippy --all-targets` (clean).
|
||||
3. `tea issues list --state open` — pick up anything new first.
|
||||
4. Then the ADR-0052 follow-up (§3), then requirements (§5).
|
||||
5. Follow the project workflow: phased (requirements → divergent → eval →
|
||||
execute → verify), test-first, `/runda` + DA pass before every commit,
|
||||
ADR amendment for any decided-area change + the README index-upkeep
|
||||
rule, and confirm the commit message with the user before committing.
|
||||
6. Consider a `cargo sweep` at this milestone (`target/` grows across
|
||||
sessions; see CLAUDE.md "Build hygiene"). (`sweep.timestamp` was
|
||||
removed this session.)
|
||||
@@ -0,0 +1,165 @@
|
||||
# Session handoff — 2026-06-15 (70)
|
||||
|
||||
Seventieth handover. Continues from handoff-69 (which closed the last
|
||||
four Gitea issues and left the tracker empty). This session did the
|
||||
**ADR-0052 follow-up** (unwinding vestigial worker `source` plumbing),
|
||||
then **designed and fully implemented H2 — the contextual `hint`
|
||||
command + F1 keybinding (ADR-0053)** end to end (Phases A–D). The CI
|
||||
branch was also merged into `main` mid-session (not my work — see §5).
|
||||
|
||||
Net: **2 feature areas shipped, 1 new ADR (0053) + 1 ADR amendment
|
||||
(0052), 4 new Gitea issues (#35–#38), the `hint` corpus (~57 teaching
|
||||
blocks), and A1 + H2 closed in `requirements.md`.**
|
||||
|
||||
## §1. State at handoff
|
||||
|
||||
**Branch:** `main`. Working tree **clean**; all work committed. Commits
|
||||
are local (push is the user's step).
|
||||
|
||||
**Tests: 2499 passing / 0 failing / 0 skipped / 1 ignored** (the
|
||||
long-standing `friendly` doctest). **Clippy clean** (nursery, all
|
||||
targets). Breakdown: 1799 lib + 500 `it` + 200 typing-surface-matrix.
|
||||
|
||||
**Open Gitea issues (4, all enhancement, all filed this session):**
|
||||
- **#35** — enforce `cargo fmt` across the codebase (single reformat +
|
||||
CI gate). The tree is *not* fmt-clean (~1800 pre-existing diffs); do it
|
||||
once, coordinated with CI, before first publication.
|
||||
- **#36** — `help` collapses advanced-SQL forms onto their simple sibling
|
||||
(a `help`-list dedup artifact); they deserve distinct help content.
|
||||
- **#37** — `hint` clause-concept hints (`on delete` actions, constraint
|
||||
slots, `with pk`, cardinality) — a deferred `hint.concept.<topic>`
|
||||
layer.
|
||||
- **#38** — `hint` pre-submit-diagnostic route + the ~33 `diagnostic.*`
|
||||
tier-3 blocks (deferred; `Diagnostic` carries no class key).
|
||||
|
||||
## §2. ADR-0052 follow-up — vestigial worker `source` unwind (`e8fa859`)
|
||||
|
||||
The first task from handoff-69 §3. ADR-0052 moved success-journaling out
|
||||
of the worker, leaving the `source` that handlers threaded purely for the
|
||||
old `history.log` write dead. **Bigger than the handoff estimated** (it
|
||||
framed it as ~28 call-site edits): the cascade ran through ~30 worker
|
||||
handlers + the `DescribeTable`/`QueryData`/`RunSelect` request fields +
|
||||
their `DatabaseHandle` methods (~164 mostly-test call sites). Fully
|
||||
unwound, compiler-guided, **no behaviour change** (journaling uses a
|
||||
`source_for_journal` clone at the spawn, independent of the worker). The
|
||||
only worker `source` left is the snapshot/undo label. Amended ADR-0052
|
||||
*Consequences* + README. (Two scope forks escalated + user-approved.)
|
||||
|
||||
## §3. H2 — contextual `hint` (ADR-0053), Phases A–D — **shipped**
|
||||
|
||||
The bulk of the session. ADR-0053 settles the `hint` slot ADR-0003 left
|
||||
"ADR pending"; **closes A1** (all 15 app commands now exist) and
|
||||
**requirements H2**. Read ADR-0053 before touching this area — it went
|
||||
through three revisions and several user decisions.
|
||||
|
||||
### The design (all user-chosen)
|
||||
- **Two surfaces:** an **F1 keybinding** → tier-3 hint for the *live*
|
||||
partial input (read-only overlay — never touches buffer/cursor/memo);
|
||||
a submitted **`hint` command** → expands on the *most recent runtime
|
||||
error*. No topic arg (contextual only; `help <topic>` owns reference).
|
||||
- **Tier-3 teaching layer** beneath the existing tier-1 (colour / error
|
||||
headline) and tier-2 (ambient one-liner; the error `hint:` shown **by
|
||||
default** since `Verbosity::Verbose` is the default). Each block is
|
||||
`what` / `example` / `concept`, rendered as a `Hint` heading + aligned
|
||||
labels.
|
||||
- **Per-form keying** (Phase-B revision — the original per-node `hint_id`
|
||||
was too coarse for multi-form commands like `add`/`drop`/`show`): a new
|
||||
**`hint_ids: &[&str]`** field on `CommandNode` mirroring `usage_ids`,
|
||||
resolved by `hint_key_for_input_in_mode` (reuses `usage_key`'s
|
||||
form-word disambiguation + a mode-primary fallback for shared entry
|
||||
words so advanced `insert` → `sql_insert`, simple → `insert`).
|
||||
- **Comprehensive for v1 = command forms + 9 runtime error classes**
|
||||
(the ~33 `diagnostic.*` classes were **deferred**, #38 — see §4).
|
||||
|
||||
### Key files
|
||||
- `src/dsl/command.rs` — `AppCommand::Hint`.
|
||||
- `src/dsl/grammar/app.rs` — `HINT` node + `build_hint`.
|
||||
- `src/dsl/grammar/mod.rs` — the `hint_ids` field, `hint_key_for_input_in_mode`,
|
||||
the factored `pick_form_key`, and the two **comprehensiveness coverage
|
||||
tests** (every node has a resolving `hint.cmd.*`; every runtime error
|
||||
class has a `hint.err.*`).
|
||||
- `src/app.rs` — F1 arm in `handle_key` (read-only overlay, placed before
|
||||
the completion-memo clear); `note_hint_for_input` / `note_hint_for_recent_error`
|
||||
/ `note_getting_started` / `emit_tier3_block`; `last_error_hint_key`
|
||||
state (set in `handle_dsl_failure`, cleared in `submit` for DSL
|
||||
commands).
|
||||
- `src/friendly/translate.rs` — `error_hint_class` (maps a `DbError` +
|
||||
ctx to its `hint.err.<class>`; mirrors `translate`'s dispatch — keep in
|
||||
sync, unit-tested).
|
||||
- `src/friendly/strings/en-US.yaml` + `keys.rs` — the corpus under
|
||||
`hint.cmd.<form>` / `hint.err.<class>` + `hint.block.*` labels +
|
||||
`shortcut.hint`.
|
||||
- `src/ui.rs` — ADR-0051 strip advertises **F1** (editing + default
|
||||
states); 12 full-panel snapshots re-accepted.
|
||||
|
||||
### Phases (one commit each unless noted)
|
||||
- **A** (`050b363`) skeleton + tier-2 fallback; **B** (`4a5fd1b`) per-form
|
||||
keying + 3 exemplars; **C** content in 5 batches (`4bdfce6` app,
|
||||
`6429b56` DDL, `9c4d520` DML, `97970f2` advanced-SQL, `b6b98ad` runtime
|
||||
errors) + `417cbc8` diagnostic deferral; **D** (`447112b`) coverage gate
|
||||
+ F1 strip + status flips; **/runda fix** (`329adfc`) — see §3.1.
|
||||
|
||||
### 3.1 — what the final `/runda` caught (don't skip)
|
||||
Per-batch substring tests masked a **presentation gap**: `emit_tier3_block`
|
||||
was emitting three *bare, unlabelled* lines, deviating from the approved
|
||||
exemplar format. Fixed to render a `Hint` heading + aligned `What:` /
|
||||
`Example:` / `Concept:` lines, **locked by an `insta` snapshot**
|
||||
(`hint_block_insert`). Also confirmed the `Next:` line (ADR D2 exemplar)
|
||||
is correctly **omitted** — tier-2 ambient already owns live
|
||||
position-awareness. Lesson for the next content/UI work: **add a rendered
|
||||
snapshot early**; substring asserts don't see layout.
|
||||
|
||||
## §4. Deferrals (all tracked, all user-confirmed)
|
||||
|
||||
- **#38 diagnostic route + `diagnostic.*` blocks** — `Diagnostic`
|
||||
(`walker/outcome.rs`) carries only its rendered `message`, not a class
|
||||
key, so the F1 diagnostic route would need a `class` field threaded
|
||||
through every diagnostic site (broad) for marginal value (tier-2
|
||||
already surfaces diagnostics; many duplicate runtime classes). F1 still
|
||||
shows the useful command block when a diagnostic is present.
|
||||
- **#37 clause-concept hints** — per-form is the right tier-3 granularity;
|
||||
clause-level concepts are a separate `hint.concept.<topic>` layer for
|
||||
later.
|
||||
- **#36 `help` advanced-SQL** — out of H2's scope (touches shipped `help`).
|
||||
|
||||
## §5. CI branch merged into `main` (not my work)
|
||||
|
||||
Mid-session the **`ci` branch was merged** (commits `47a0816`, `138e766`
|
||||
+ the `ci:`/`build:`/`docs(ci):` commits). `main` now carries a CI
|
||||
pipeline, a nix flake, and **D1 cross-platform release builds** (matrix +
|
||||
macOS), documented under a **new `docs/ci/adr/` namespace** (ci-001..003).
|
||||
Implications for the roadmap: **D1 (cross-platform binaries) is now
|
||||
substantially underway** — re-assess D1/D2/D3 status against what landed
|
||||
before treating them as open. My H2 work is layered cleanly on top (all
|
||||
green post-merge).
|
||||
|
||||
## §6. Next session — start here
|
||||
|
||||
1. **Push** (user step) — 30-odd local commits incl. the CI merge + all
|
||||
of H2.
|
||||
2. **Re-baseline the roadmap** against the merged CI work: D1/D2/D3 and
|
||||
**TT5 CI** are partly/largely done now — read `docs/ci/adr/` and the
|
||||
workflows before assuming they're open (handoff-69 §5 predates this).
|
||||
3. **#35 (cargo fmt gate)** is the natural pairing with the now-merged CI
|
||||
— the user wanted it done once, before first publication.
|
||||
4. Other `requirements.md` open items (verify against CI merge first):
|
||||
**TT4** PTY tier-4 (still unwired), **I1** multi-line input, **I5/B3**
|
||||
in-flight cancellation, **V4** session journal (own ADR), **TU1**
|
||||
tutorial system (own ADR). H2/A1 are now **done**.
|
||||
5. The H2 deferrals (#36/#37/#38) are available if the user wants to
|
||||
round out the hint/help surface.
|
||||
|
||||
## §7. How to take over
|
||||
|
||||
1. Read handoffs 68 → 69 → 70, `CLAUDE.md`, `docs/requirements.md`.
|
||||
2. Confirm green: `cargo test` (expect **2499 pass / 1 ignored**) +
|
||||
`cargo clippy --all-targets` (clean).
|
||||
3. Read `docs/ci/adr/` (the merged CI work) before touching CI/release/D*.
|
||||
4. For anything in the `hint` area, read **ADR-0053** first (3 revisions
|
||||
+ deferrals #37/#38). For journaling, ADR-0052 (+ its 2026-06-14
|
||||
follow-up note).
|
||||
5. Project workflow unchanged: phased, test-first, `/runda` + DA before
|
||||
commits, ADR amendment + README index-upkeep for decided-area changes,
|
||||
confirm commit messages with the user.
|
||||
6. Consider a `cargo sweep` at this milestone (`target/` grows; see
|
||||
CLAUDE.md "Build hygiene").
|
||||
@@ -0,0 +1,120 @@
|
||||
# Session handoff — 2026-06-15 (71)
|
||||
|
||||
Short, focused handover. Continues immediately from handoff-70 (which
|
||||
shipped H2 / the contextual `hint`, ADR-0053). **A user smoke-test
|
||||
surfaced a correctness bug in the hint content, and it implicates the
|
||||
whole corpus.** This handoff exists so the next session does a
|
||||
**systematic semantic verification pass over every hint block** — context
|
||||
ran too low to do it now.
|
||||
|
||||
## §1. State
|
||||
|
||||
**Branch:** `main`, clean, all committed (local; push pending). **2499
|
||||
pass / 1 ignored, clippy clean.** Open issues: #35–#38 (see handoff-70).
|
||||
H2 / ADR-0053 is *functionally* complete; the **content is not
|
||||
trustworthy** until the pass below is done.
|
||||
|
||||
## §2. The bug (confirmed)
|
||||
|
||||
`hint.cmd.create_table` (in `src/friendly/strings/en-US.yaml`) reads:
|
||||
|
||||
```
|
||||
What: Create a new table — its columns, their types, and a primary key.
|
||||
Example: create table Customers with pk id(serial), name(text), email(text)
|
||||
Concept: A table is a set of rows that share the same columns. The primary
|
||||
key uniquely identifies each row; a `serial` key numbers the rows for you.
|
||||
```
|
||||
|
||||
**This is wrong.** In the DSL, **everything after `with pk` is the
|
||||
primary-key column list** (a possibly *compound* PK, ADR-0005). So the
|
||||
example does **not** create a table with `pk=id` plus regular columns
|
||||
`name`/`email` — it creates a table whose **compound primary key is
|
||||
(id, name, email)**. Non-key columns are added *separately* with
|
||||
`add column`. The `what` ("its columns, their types") and the example
|
||||
both mislead a learner badly.
|
||||
|
||||
- **Evidence:** real test usage is `create table Orders with pk
|
||||
id(serial), CustId(int)` (a 2-column *compound PK*) and the common form
|
||||
`create table X with pk id(int)` (single-column PK only). The usage
|
||||
template `create table <Name> with pk [<col>(<type>)[, ...]]` is itself
|
||||
misleading — the `[, ...]` is the PK list, not regular columns.
|
||||
- **Correct mental model:** `create table <T> with pk <pk-cols…>` then
|
||||
`add column <T>: <name> (<type>)` for each non-key column. Confirm
|
||||
against ADR-0005 (compound PK) and ADR-0009 (DSL syntax) when fixing.
|
||||
|
||||
## §3. Root cause — why this needs a *full* pass
|
||||
|
||||
During Phase C I verified *some* examples against `parse.usage.*`
|
||||
templates and real test greps, but for others I **extrapolated** beyond
|
||||
verified syntax. For `create_table` I saw `... with pk id(int)` (single
|
||||
col) and wrongly generalised to "pk + more columns," misreading the
|
||||
`with pk` list as a column list. The examples are **syntactically**
|
||||
checked but not **semantically** — i.e. not verified to *do what the
|
||||
`what`/`concept` claims*.
|
||||
|
||||
So the corpus needs a pass that, for **every** `hint.cmd.*` and
|
||||
`hint.err.*` block, checks:
|
||||
1. the `example` parses **and runs**, and
|
||||
2. it actually demonstrates what `what`/`concept` says, and
|
||||
3. `what`/`concept` are factually true of the real behaviour.
|
||||
|
||||
**Don't trust grep+extrapolation.** Prefer: run the example in the app
|
||||
(or a Tier-3 test), or check it against the authoritative ADR.
|
||||
|
||||
## §4. The pass — how to do it (next session)
|
||||
|
||||
The corpus lives in `src/friendly/strings/en-US.yaml` under `hint.cmd.*`
|
||||
(per command form) and `hint.err.*` (per runtime error class). The
|
||||
inventory and authoritative syntax sources:
|
||||
|
||||
- **`hint.cmd.<form>`** — for each, cross-check the example against the
|
||||
matching `parse.usage.<form>` template **and** the form's ADR, and run
|
||||
it. Highest-risk (extrapolated, verify first): **DDL** — `create_table`
|
||||
(known wrong), `add_column`, `add_index`, `add_constraint`,
|
||||
`change_column`, `drop_*`, `create_m2n`; **advanced-SQL** — confirm
|
||||
each is in the supported SQL subset (`select`, `with` CTE,
|
||||
`sql_insert/update/delete`, `sql_create_table`, `sql_alter_table`,
|
||||
`sql_create_index/drop_index/drop_table`, `explain_sql`); **DML** —
|
||||
`seed` forms, `explain`, `show_*`, `update`/`delete` (`--all-rows` /
|
||||
required-WHERE wording). App commands are lower-risk (reference-style).
|
||||
- **`hint.err.<class>`** — verify the fix recipe in `example` is actually
|
||||
the right remedy and `concept` matches the engine's real behaviour
|
||||
(FK sides, `on delete` actions, check/not_null/unique semantics).
|
||||
- Relevant ADRs: 0005 (types + compound PK), 0009 (DSL syntax), 0011 (FK
|
||||
type compat), 0013 (relationships/rebuild), 0014 (data ops +
|
||||
required-WHERE), 0025 (indexes), 0028/0039 (explain), 0030–0036 (SQL
|
||||
subset), 0048 (seed). `docs/requirements.md` for scope.
|
||||
|
||||
**Suggested method:** drive the app (`/run` or a small PTY/Tier-3 harness)
|
||||
and actually execute each example; or add a test that parses+runs every
|
||||
`hint.cmd.*` example and asserts success. The latter would also be a
|
||||
durable regression guard — consider adding it as part of the pass (it
|
||||
upgrades the comprehensiveness coverage test from "a block exists" to
|
||||
"the example actually works").
|
||||
|
||||
## §5. Immediate fix ready to apply
|
||||
|
||||
`create_table` is diagnosed (§2). The corrected block should make the
|
||||
example a PK-only `create table` and move the regular columns to a
|
||||
follow-up `add column`, e.g.:
|
||||
|
||||
```
|
||||
What: Create a new table with its primary key.
|
||||
Example: create table Customers with pk id(serial)
|
||||
Concept: A table is a set of rows sharing the same columns. `with pk`
|
||||
declares the primary key (one column, or several for a compound
|
||||
key); add the other columns afterwards with `add column`.
|
||||
```
|
||||
|
||||
Apply this (and re-check `create_m2n` / `add_*` while there), but only as
|
||||
part of the systematic pass — a one-off fix risks leaving siblings wrong.
|
||||
|
||||
## §6. How to take over
|
||||
|
||||
1. Read handoffs 70 → 71, `CLAUDE.md`.
|
||||
2. Confirm green: `cargo test` (2499 / 1 ignored), `cargo clippy
|
||||
--all-targets`.
|
||||
3. Do the §4 pass (consider the run-every-example test in §4). Test-first,
|
||||
`/runda` before commit, confirm the commit message with the user.
|
||||
4. Pedagogy wins — these are teaching strings; correctness and clarity
|
||||
over cleverness.
|
||||
@@ -0,0 +1,247 @@
|
||||
# Plan — issue #30: mode-tagged history + top-of-chain journaling
|
||||
|
||||
**Status:** draft for `/runda` review (2026-06-13).
|
||||
**Issue:** #30 — advanced history reusable in simple mode (prepend `:`),
|
||||
and the bug: the `:` one-shot prefix is lost across sessions.
|
||||
**ADR:** ADR-0052 (new); amends ADR-0015 §6, ADR-0034, ADR-0040;
|
||||
references ADR-0003.
|
||||
|
||||
## 1. Goal & root cause
|
||||
|
||||
Two coupled needs, one root cause — **history entries carry no mode**:
|
||||
- **Bug:** the in-memory ring stores the raw `:select 1`, but the worker
|
||||
journals the *stripped* `select 1`, so cross-session the `:` is lost
|
||||
and the command recalls bare (unusable in simple mode).
|
||||
- **Feature:** persistent-advanced commands (`select 1` typed in advanced
|
||||
mode) can't be told apart from simple DSL, so they can't be offered
|
||||
back with a `:` in simple mode.
|
||||
|
||||
Fix: **record the submission mode per entry** (status tag `:adv`), keep
|
||||
the on-disk `source` canonical, and have **recall prepend/strip `:`** for
|
||||
the current mode.
|
||||
|
||||
## 2. The architecture insight (why this plan is shaped this way)
|
||||
|
||||
Journaling **success** lives deep in the worker: `finalize_persistence`
|
||||
(db.rs:3096-3099) writes `history.log` *inside the db transaction, before
|
||||
`tx.commit()`*, alongside yaml/csv — plus four no-op-skip sites and three
|
||||
read-only helpers. **Failure** journaling already lives at the top
|
||||
(runtime.rs:484-495, best-effort). Threading the mode *down* to the
|
||||
worker would mean ~30 `Request` variants + `Database` methods +
|
||||
`execute_command_typed` arms — because the journal write is far from
|
||||
where the mode is known.
|
||||
|
||||
So instead: **move success journaling up to the dispatch layer**, next to
|
||||
where failure journaling already is and where mode + outcome + source are
|
||||
all in scope. The mode then needs no plumbing. This is the correct
|
||||
separation anyway — `history.log` is an append-only *journal of what was
|
||||
typed*, not *state*; the state sources (yaml/csv/db) stay atomic in the
|
||||
worker.
|
||||
|
||||
### Semantic changes this entails (must be vetted)
|
||||
|
||||
1. **history.log leaves the worker transaction** (amends ADR-0015 §6).
|
||||
`commit-db-last` still governs yaml/csv/db (the state); the journal is
|
||||
written *after* the worker replies (i.e. after `tx.commit`), at the
|
||||
dispatch layer.
|
||||
2. **Success-journal write failure: fatal → best-effort** (amends
|
||||
ADR-0040). Today a failed `history.log` write on a *successful*
|
||||
command rolls the command back and shows a fatal banner. After: the
|
||||
command stays committed; the journal write is best-effort (logged +
|
||||
ignored), exactly like the failure path already is. The two journal
|
||||
paths become *consistent*.
|
||||
3. **Consequence:** on a rare journal-write failure (disk full /
|
||||
permissions) a successful command is applied but may be missing from
|
||||
`history.log` — not recallable next session, not replayable. The state
|
||||
(yaml/csv/db) is unaffected and consistent. This is a graceful
|
||||
degradation, not corruption, and is logged. (Today the same disk-full
|
||||
instead kills the app mid-command.)
|
||||
|
||||
**Open question for review/user:** is trading "fatal on journal-write
|
||||
failure" for "best-effort, command still succeeds" acceptable? The plan
|
||||
assumes **yes** (a journal is auxiliary; killing the app over it is worse
|
||||
UX). If not, journaling must stay coupled in the worker and we pay the
|
||||
~30-site mode plumbing instead.
|
||||
|
||||
## 3. On-disk format (mode tag in status — already chosen + partly built)
|
||||
|
||||
Record stays `<ts>|<status>|<source>`; the **status token** gains an
|
||||
optional `:adv` suffix (ADR-0052). `source` stays canonical so replay is
|
||||
unaffected.
|
||||
|
||||
| Submission | Success | Failure |
|
||||
|---|---|---|
|
||||
| Simple / app command | `ok` | `err` |
|
||||
| Advanced (SQL, persistent or one-shot) | `ok:adv` | `err:adv` |
|
||||
|
||||
**Done already** (history.rs / mod.rs):
|
||||
- `status_token(base, advanced)`, `parse_status(status) -> (is_ok, advanced)`.
|
||||
- `parse_record_source` reconstructs `": {cmd}"` for `:adv` records.
|
||||
- `parse_journal_record.status_is_ok` via `parse_status` (so `ok:adv` replays).
|
||||
- `append_history(text, advanced)`, `append_history_failure(text, advanced)`.
|
||||
|
||||
Back-compat: old `ok`/`err` logs → simple; nothing migrates.
|
||||
|
||||
## 4. In-memory ring & recall (app.rs) — the #30 behaviour
|
||||
|
||||
The ring stays `Vec<String>`. An **advanced** entry is stored in its
|
||||
`: `-prefixed simple-mode runnable form (matching the existing in-session
|
||||
one-shot ring); a **simple** entry bare. A leading `:` unambiguously
|
||||
marks advanced (simple DSL can never start with `:`).
|
||||
|
||||
- **`submit`** (app.rs:1704): compute `effective_input` + `submission_mode`,
|
||||
parse once for the app-command check (already done at 1751), then build
|
||||
the ring line. The **`advanced` flag excludes app commands** —
|
||||
`advanced = submission_mode.is_advanced() && !is_app_command` — because
|
||||
app commands (`undo`, `mode …`, `save as`, …) run in *any* mode and must
|
||||
**not** get a `:` on recall. Ring line: `": " + effective_input` if
|
||||
`advanced`, else `effective_input`; `push_history(&ring_line)`. (Today it
|
||||
pushes the raw `trimmed` *before* stripping; the reorder also drops a
|
||||
bare `:`, which executed nothing, and is what lets the app-command check
|
||||
precede the push.) `ExecuteDsl.source` stays the **canonical**
|
||||
`effective_input`.
|
||||
- *Why the app-command exclusion matters (DA finding):* without it,
|
||||
`: save as foo` (an app command via the one-shot) would store `: save
|
||||
as foo` in the ring but journal `save as foo` (app commands journal
|
||||
simple at their own sites, §5) — the very in-session-vs-cross-session
|
||||
divergence #30 is fixing, re-introduced for app commands. Excluding
|
||||
them keeps ring and disk agreeing (both bare).
|
||||
- **`history_back` / `history_forward`**: after cloning the stored entry
|
||||
into `self.input`, strip a leading `:` **iff `self.mode == Advanced`**
|
||||
(so an advanced entry runs as bare SQL in advanced mode, and as `: …`
|
||||
one-shot in simple mode). A small helper `recall_display(stored)`.
|
||||
- `seed_history` / `ProjectSwitched` payload: **unchanged** (`Vec<String>`);
|
||||
hydration already returns the `: `-prefixed form (§3).
|
||||
|
||||
Recall matrix:
|
||||
| entry \ current mode | Simple | Advanced |
|
||||
|---|---|---|
|
||||
| advanced (`: select 1`) | `: select 1` (one-shot) | `select 1` (SQL) |
|
||||
| simple (`create …`) | `create …` | `create …` |
|
||||
|
||||
## 5. Move success journaling worker → dispatch layer
|
||||
|
||||
**Remove** (worker stops journaling success):
|
||||
- `finalize_persistence` history write (db.rs:3096-3099). Keep yaml/csv.
|
||||
The now-unused `source` param: remove it + drop the arg at its ~30
|
||||
callers (mechanical, compiler-guided). (Handlers keep their own
|
||||
`source` for `snapshot_then`.)
|
||||
- The 4 no-op-skip `append_history` (db.rs:2267, 2311, 2524, 2560) — these
|
||||
outcomes (`SchemaSkipped` etc.) are `Ok` at the dispatch layer, so the
|
||||
new top-level journal covers them.
|
||||
- The 3 read-only helper `append_history` (db.rs:8372 show table, 9996
|
||||
show data, 10014 select) — `Ok(Query)`/`Ok(ShowList)` at the top.
|
||||
|
||||
**Add** (dispatch-layer journaling, all best-effort + logged):
|
||||
- **`spawn_dsl_dispatch`** (runtime.rs ~1433): pass `project_path` in;
|
||||
after `execute_command_typed`, `if outcome.is_ok() {
|
||||
Persistence::new(path).append_history(&source_for_journal,
|
||||
submission_mode.is_advanced()) }`. (Failures stay in the existing path,
|
||||
§6 — no double-journal, since Ok and Err are exclusive.)
|
||||
- **`run_replay`** (runtime.rs ~2540): after each line's
|
||||
`execute_command_typed`, `if outcome.is_ok() { append_history(
|
||||
&command_text, false) }` — replay is mode-agnostic, journalled
|
||||
**simple**. (Preserves ADR-0034 §3 "replayed sub-commands land in
|
||||
history"; a replayed advanced command re-journals without `:adv` — a
|
||||
documented OOS, not a regression: today it re-journals as plain `ok`.)
|
||||
- **`spawn_rebuild`** (runtime.rs ~503): after a successful rebuild,
|
||||
`append_history("rebuild"/source, false)`. (Rebuild journalled via
|
||||
`finalize_persistence` today; that write is gone, so add it here.)
|
||||
|
||||
**Unchanged** (already at the dispatch layer, app commands):
|
||||
- `perform_switch` (974: save-as/load/new) and `spawn_export` (1043) —
|
||||
already best-effort `append_history(&source)`; add the new `advanced`
|
||||
arg as `false` (app commands run in any mode → no `:` needed on recall;
|
||||
this also fixes the would-be "redundant `: undo`" — app commands
|
||||
journal **simple** because they're dispatched here, never via
|
||||
`ExecuteDsl`/the spawn).
|
||||
- `undo`/`redo`/`copy`/`help`/`quit`: not journalled today; unchanged.
|
||||
- The **`replay` command itself**: dispatched as `Action::Replay`, never
|
||||
reaches the spawn → not journalled (preserves the ADR-0034 §3 exclusion
|
||||
without extra work); nested `replay` skip in `run_replay` unchanged.
|
||||
|
||||
### DA-confirmed design choice: split, don't unify
|
||||
|
||||
Success journals in the spawn (`Ok` arm); **all** failures stay in the
|
||||
existing App→`JournalFailure`→runtime path (just gaining the mode).
|
||||
Considered and rejected: moving worker-rejection failures into the spawn
|
||||
too (to "unify"). It doesn't actually unify — parse failures never reach
|
||||
the spawn, so they'd stay in the App path regardless — and it adds a
|
||||
double-journal hazard (must also strip the App's `DslFailed`→
|
||||
`JournalFailure` emission). The split keeps the failure path **untouched
|
||||
in structure** (lowest risk); `Ok`/`Err` are exclusive so there is no
|
||||
double-journal. **Verified safe:** undo/redo never touches `history.log`
|
||||
(the snapshot copies db+yaml+csv only, undo.rs:15-16), and `snapshot_then`'s
|
||||
redo-clear keys on `source.is_some()`, independent of journaling — so
|
||||
removing the worker journal write does not perturb undo/snapshot at all.
|
||||
|
||||
## 6. Failure journaling — add the mode (location unchanged)
|
||||
|
||||
Keep both failure origins where they are (best-effort, dispatch/App
|
||||
layer); thread the mode so they tag `err:adv`:
|
||||
- **`Action::JournalFailure`** (action.rs:42): add `advanced: bool` (or
|
||||
`submission_mode`).
|
||||
- **`AppEvent::DslFailed`** (event.rs): add `submission_mode` (the
|
||||
worker-rejection path — the App can't recover the mode from an async
|
||||
reply otherwise).
|
||||
- **App**: the parse-failure path (`dispatch_dsl` Err arm) has
|
||||
`submission_mode` directly; the `DslFailed` handler reads it off the
|
||||
event. Both emit `JournalFailure { source, advanced }`.
|
||||
- **runtime.rs:492**: `append_history_failure(&source, advanced)`.
|
||||
|
||||
## 7. Tests
|
||||
|
||||
- **history.rs (Tier-1):** `status_token`/`parse_status` round-trip;
|
||||
`read_recent_sources` reconstructs `": …"` for `:adv` and leaves
|
||||
`ok`/`err` bare; `status_is_ok` true for `ok` & `ok:adv`; old-log
|
||||
back-compat.
|
||||
- **app.rs (Tier-1):** advanced submission stored `: `-prefixed; recall
|
||||
prepends in simple / strips in advanced; simple bare in both; bare `:`
|
||||
not stored; a parse-failure is still recallable; dedup/cap hold.
|
||||
- **iteration6_resume_history (Tier-3) — headline regression:** journal
|
||||
an advanced command (`append_history(text, true)`), hydrate, recall in
|
||||
simple → `: …`; and the full bug repro through `submit` + journal +
|
||||
hydrate if feasible.
|
||||
- **replay_command (Tier-3):** replayed commands still land in
|
||||
history.log (now via `run_replay`'s call); the `replay`-self-exclusion
|
||||
+ nested-skip still hold; advanced lines replay (status `ok:adv`
|
||||
treated as ok).
|
||||
- **Journaling relocation:** a success no longer fatals on a journal
|
||||
write failure (best-effort) — if cheaply testable; at minimum a worker
|
||||
test that previously asserted worker-side journaling is updated/removed.
|
||||
- **Update mechanical call sites:** `append_history(_, advanced)` /
|
||||
`append_history_failure(_, advanced)` at the db.rs inline tests
|
||||
(8372/9996/10014/11324 — likely now removed with the production sites),
|
||||
iteration6 (144-170), mod.rs (600).
|
||||
|
||||
## 8. ADR work
|
||||
|
||||
- **ADR-0052 (new):** the #30 feature + bug, the status-tag format, the
|
||||
`: `-prefixed ring + recall, AND the journaling relocation (it's the
|
||||
enabling refactor). Forks: status-tag format; unified scope;
|
||||
dispatch-layer journaling (best-effort).
|
||||
- **ADR-0015 §6 amendment:** history.log out of the worker transaction;
|
||||
commit-db-last now scopes yaml/csv/db; journal is a dispatch-layer
|
||||
best-effort side-record.
|
||||
- **ADR-0034 amendment:** journaling location (dispatch layer);
|
||||
status-field `:adv` extension (it already reserved the field).
|
||||
- **ADR-0040 amendment:** a success-path journal-write failure is no
|
||||
longer fatal — best-effort, consistent with the failure path.
|
||||
- README index upkeep for every ADR touched.
|
||||
|
||||
## 9. Risks / watch-list
|
||||
|
||||
- **Double-journaling**: ensure Ok→spawn and Err→App-path stay exclusive;
|
||||
do NOT also leave a worker journal.
|
||||
- **Under/over-journaling vs today**: top-level "journal on every Ok"
|
||||
must match today's "journal every command with a source" — verified:
|
||||
reads + skips are Ok outcomes, internal ops never reach the spawn.
|
||||
- **finalize_persistence source-param removal**: 30 mechanical call-site
|
||||
edits; compiler-guided.
|
||||
- **Replay re-journal mode fidelity**: replayed advanced commands
|
||||
re-journal as simple (OOS, not a regression).
|
||||
- **best-effort journal**: rare write-failure leaves a command unjournaled
|
||||
(logged). User decision (§2 open question).
|
||||
- **app-command mode**: journalled simple by construction (dispatched
|
||||
outside the spawn) — this is correct (they run in any mode), and
|
||||
resolves the earlier "redundant `: undo`" worry.
|
||||
@@ -0,0 +1,243 @@
|
||||
# Plan — ADR-0053: contextual `hint` command + F1 keybinding (H2)
|
||||
|
||||
Implements ADR-0053. Closes the last open piece of **A1** (the canonical
|
||||
app-command set) and requirements **H2**. No Gitea issue — this is
|
||||
requirements-driven work; any genuine "later" item found en route gets
|
||||
its own issue (cf. #36, already filed for the parallel `help`-side gap).
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Give learners on-demand, **teaching-grade** contextual help — a *third*
|
||||
tier beneath the existing terse always-on text (tier 1) and the
|
||||
short contextual lines that are already shown (tier 2: the live ambient
|
||||
prose, and the error `hint:` which is on by default since
|
||||
`Verbosity::Verbose` is the default). Two surfaces:
|
||||
|
||||
- **F1** (read-only overlay) → a tier-3 block for the **live partial
|
||||
input**, or — on empty input — for the **most recent runtime error**.
|
||||
- **`hint`** (submitted app command) → the tier-3 block for the **most
|
||||
recent runtime error** (the buffer is empty post-submit, so it can only
|
||||
act on recent context).
|
||||
|
||||
The mechanism is small; the **content corpus is the feature** (~80
|
||||
blocks, comprehensive for v1, authored exemplars-first per ADR-0053 D7).
|
||||
|
||||
## 2. The shape of the work (why this order)
|
||||
|
||||
The mechanism and the content are separable, and the mechanism should
|
||||
land first with **graceful tier-2 fallback** so every surface works
|
||||
before any tier-3 text exists. That lets us:
|
||||
|
||||
- build + test the trigger matrix / routing / `:`-strip / read-only-
|
||||
overlay behaviour against a skeleton (TDD), then
|
||||
- pour in content in reviewable batches without re-touching the wiring,
|
||||
- and turn on the **comprehensiveness coverage test** only once the
|
||||
corpus is complete (it is red until then — by design).
|
||||
|
||||
Build order: **Phase A** (mechanism skeleton, falls back to tier-2) →
|
||||
**Phase B** (catalogue structure + the three approved exemplars) →
|
||||
**Phase C** (comprehensive content, batched) → **Phase D** (polish:
|
||||
strip advertisement, snapshots, full green).
|
||||
|
||||
## 3. Grammar: the `hint_ids` field + the `HINT` node
|
||||
|
||||
### 3a. New `CommandNode.hint_ids` (per-form — revised in Phase B)
|
||||
- Add `pub hint_ids: &'static [&'static str]` to `CommandNode`
|
||||
(`src/dsl/grammar/mod.rs:512`, beside `help_id` / `usage_ids`),
|
||||
**mirroring `usage_ids`** — *not* a per-node `Option<&str>`. The Phase-B
|
||||
exemplar (`add 1:n relationship`) showed per-*node* keying is too coarse:
|
||||
`add`/`drop`/`show`/`create` are each one node spanning many forms, and
|
||||
a live-input hint must be specific to the typed form. Compiler forces
|
||||
every node literal (~37, across `grammar/app.rs`, `data.rs`, `ddl.rs`) to
|
||||
set it — Phase A/B leave most `&[]` (tier-2 fallback); Phase C fills them.
|
||||
**Multi-form nodes list ALL their form keys** (e.g. `add` →
|
||||
`["add_column", "add_relationship", "add_index", "add_constraint"]`) so
|
||||
the form-word disambiguation resolves correctly and unauthored forms fall
|
||||
back at render rather than mis-resolving to a sibling.
|
||||
- **Lookup:** `hint_key_for_input_in_mode(source, mode)` returns the single
|
||||
typed form's hint stem, reusing `pick_form_key` (factored out of
|
||||
`usage_key_for_input_in_mode` — shared digit/`m:n`/suffix disambiguation).
|
||||
- **Why a new field, not `help_id`** (ADR-0053 D3): `help_id` is `None` on
|
||||
the 7 advanced-SQL forms purely to dedup the `help` *list*; those forms
|
||||
have distinct SQL syntax and need their own block. `hint_ids` is per
|
||||
form. (The parallel `help`-side gap is issue #36; clause-concept hints
|
||||
are deferred — issue #37.)
|
||||
|
||||
### 3b. `AppCommand::Hint` + the `HINT` node
|
||||
- `AppCommand::Hint` variant (no fields — no topic arg) in
|
||||
`src/dsl/command.rs:544`.
|
||||
- `pub static HINT: CommandNode` in `grammar/app.rs` mirroring `HELP` but
|
||||
with **no topic shape** (bare keyword, like `UNDO`): `entry:
|
||||
Word::keyword("hint")`, `shape: EMPTY_SEQ` (as `UNDO`,
|
||||
`grammar/app.rs:333`), `ast_builder:
|
||||
build_hint` (returns `Command::App(AppCommand::Hint)`), `help_id:
|
||||
Some("app.hint")`, `hint_id: Some("app.hint")`, `usage_ids:
|
||||
&["parse.usage.hint"]`.
|
||||
- Register `(&app::HINT, CommandCategory::Simple)` in `REGISTRY`
|
||||
(`grammar/mod.rs`), beside `HELP`. (App commands are available in both
|
||||
modes via the existing mechanism.)
|
||||
|
||||
## 4. Command identification (live-input → node)
|
||||
|
||||
The F1 live-input path needs "which command form is being typed." **The
|
||||
lookup machinery already exists** — do not rebuild entry matching:
|
||||
- `command_for_entry_word(word) -> Option<(usize, &'static CommandNode)>`
|
||||
(`grammar/mod.rs:811`) returns the matched node for an entry word
|
||||
(Simple-first; the caller extracts the first word of the input).
|
||||
- `usage_keys_for_input_in_mode(source, mode)` (`grammar/mod.rs:564`)
|
||||
already performs the **mode-aware** Simple/Advanced selection the hint
|
||||
path needs (advanced `create` → the SQL nodes, simple → the DSL node) —
|
||||
it just returns `usage_ids` rather than the node.
|
||||
- **The only new bit:** a thin `hint_id_for_input_in_mode(source, mode)`
|
||||
(or a node-returning sibling of `usage_keys_for_input_in_mode`) that
|
||||
applies the same mode selection and returns the chosen node's
|
||||
`hint_id`. Mirror the existing function; don't duplicate its matching.
|
||||
- **`:`-strip:** in Simple mode, strip a leading `:` (one-shot escape,
|
||||
ADR-0003) before identification so `: SELECT …` resolves to the
|
||||
advanced `SELECT` node.
|
||||
- No match (empty / unrecognised entry word) → the "getting started"
|
||||
pointer (D2).
|
||||
|
||||
## 5. F1 keybinding (read-only overlay)
|
||||
|
||||
In `App::handle_key` (`src/app.rs:1155`):
|
||||
- Add an F1 arm (`KeyCode::F(1)`) **after** the modal gate and the
|
||||
sidebar-nav gate (inert there, per D2), and **before** the
|
||||
"any other key clears the completion memo" fall-through (`_ =>
|
||||
self.last_completion = None`, ~line 1228) — F1 must **not** clear the
|
||||
memo or touch the buffer/cursor (D1).
|
||||
- Behaviour (the trigger matrix, D2):
|
||||
- non-empty input → `note_hint_for_input()` (the command's `hint.cmd`
|
||||
block + the live "Next:" expected-set from the walker).
|
||||
- empty input + `last_error_hint_key` set → `note_hint_for_error()`.
|
||||
- empty input + no recent error → `note_getting_started()`.
|
||||
- Returns `Vec::new()` (pure output emission, like `help`).
|
||||
- `demo_badge_label` (`app.rs:520`) gains an `F1 → "[F1]"` entry so demo
|
||||
mode surfaces it (ADR-0047).
|
||||
|
||||
## 6. The two error routes (D2 / D5)
|
||||
|
||||
- **Runtime errors:** add `last_error_hint_key: Option<String>` to `App`.
|
||||
Set it where friendly errors are rendered (`runtime.rs:2615`,
|
||||
`app.rs:2424`) from the error's class key; clear on the next successful
|
||||
command. The `hint` command and empty-input F1 read it.
|
||||
- **Pre-submit diagnostics:** the F1 live-input path, when the input
|
||||
carries an under-cursor diagnostic, reads it straight from the walker
|
||||
(`input_diagnostics_in_mode`, the same source the ambient panel uses)
|
||||
and renders that diagnostic's `hint.err.<class>` block instead of (or
|
||||
alongside) the command block. No stored state.
|
||||
- Both render from `hint.err.*`.
|
||||
|
||||
## 7. Rendering: the `note_hint*` family (D4)
|
||||
|
||||
- New `App::note_hint_for_input`, `note_hint_for_error`,
|
||||
`note_getting_started` (siblings of `note_help`/`note_help_topic`,
|
||||
`app.rs:2982`/`3021`).
|
||||
- A tier-3 block is **structured** (`what` / `example` / `concept`, plus
|
||||
the live `Next:` line on the input path). The catalogue stores each part
|
||||
under sub-keys (`hint.cmd.<id>.what`, `.example`, `.concept`); the
|
||||
renderer fetches each via `t!` and lays them out as a small framed
|
||||
block.
|
||||
- Styling: `OutputKind::System`; `OutputStyleClass::Hint` (muted) on
|
||||
`what`/`concept`/`Next`, `Neutral` on `example` so the runnable line
|
||||
stands out. Reuse `OutputLine::styled` + `push_category_three_prose`
|
||||
patterns (`app.rs:3121`).
|
||||
- **Fallback:** if a node's `hint_id` is `None` or a key is missing,
|
||||
degrade to tier-2 (ambient prose for the input path; the verbose error
|
||||
`hint:` for the error path) — never blank.
|
||||
|
||||
## 8. Catalogue + `keys.rs`
|
||||
|
||||
- New sub-namespaces under the existing top-level `hint:` in
|
||||
`src/friendly/strings/en-US.yaml`: `hint.cmd.<hint_id>.{what,example,
|
||||
concept}` and `hint.err.<class>.{what,example,concept}`.
|
||||
- Register every key + its placeholders in `src/friendly/keys.rs`
|
||||
(`KEYS_AND_PLACEHOLDERS`) so the build-time validation covers them.
|
||||
- `parse.usage.hint` + `help.app.hint` strings for the command itself.
|
||||
|
||||
## 9. Content (Phase C — the bulk, batched per D7)
|
||||
|
||||
Exemplars approved in the ADR (`insert` live-input, FK child-side error,
|
||||
`add relationship`) are the template. Author in reviewable batches:
|
||||
1. **App commands** (~16): save/save as/load/new/rebuild/export/import/
|
||||
replay/undo/redo/mode/messages/copy/help/hint/quit.
|
||||
2. **DDL** (simple): create table, create m:n, add column/relationship/
|
||||
index, drop, rename, change column.
|
||||
3. **DML** (simple): insert, update, delete, show, seed, explain,
|
||||
select/with.
|
||||
4. **Advanced-mode SQL forms** (7): SQL CREATE TABLE, ALTER TABLE,
|
||||
CREATE/DROP INDEX, DROP TABLE, SQL INSERT/UPDATE/DELETE, EXPLAIN SQL —
|
||||
**own blocks, SQL-syntax examples**.
|
||||
5. **Runtime error classes** (9): unique, foreign_key ×{child,parent},
|
||||
not_null, check, type_mismatch, not_found, already_exists, generic,
|
||||
invalid_value.
|
||||
6. **`diagnostic.*` classes** (~33): arity/type/unknown-table-column/etc.
|
||||
|
||||
Each block: `what` (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.
|
||||
```
|
||||
+61
-14
@@ -61,11 +61,32 @@ since ADR-0027.)
|
||||
|
||||
## Distribution and install
|
||||
|
||||
- [ ] **D1** Cross-platform binaries: Linux, macOS, Windows on
|
||||
- [x] **D1** Cross-platform binaries: Linux, macOS, Windows on
|
||||
x86_64 and aarch64.
|
||||
- [ ] **D2** Single static binary, no runtime dependencies.
|
||||
*(Done 2026-06-15 — CI produces all six. The four non-macOS
|
||||
targets (Linux musl + Windows gnu/gnullvm × x86_64/aarch64) are
|
||||
cross-built from the Linux runner with `cargo-zigbuild` on a `v*`
|
||||
tag (`release.yaml`); the two `*-apple-darwin` targets build
|
||||
natively on a Tart Apple-Silicon runner via the dispatched
|
||||
`release-macos.yaml`. All uploaded to the Gitea release with a
|
||||
`.sha256` each. Decisions in `docs/ci/adr/` (ADR-ci-001/002/003).
|
||||
Runtime-verified by the user: Linux x86_64 + Windows aarch64; the
|
||||
others are link-clean / valid format.)*
|
||||
- [x] **D2** Single static binary, no runtime dependencies.
|
||||
*(Done 2026-06-15, per platform: **Linux** is fully static (musl +
|
||||
`crt-static`); **Windows** is a standalone `.exe` (Zig statically
|
||||
links libc — no mingw runtime DLLs); **macOS** links only system
|
||||
libraries (`libSystem` + the AppKit/Foundation frameworks —
|
||||
inherent on every Mac, never user-installed; the build rewrites the
|
||||
one nix-store `libiconv` path to `/usr/lib` and re-signs ad-hoc).
|
||||
No target requires anything the user must install. ADR-ci-003.)*
|
||||
- [ ] **D3** Released via prebuilt binaries plus Homebrew, Scoop,
|
||||
`winget`, and `cargo binstall`.
|
||||
*(Prebuilt binaries + checksums now published to Gitea releases
|
||||
(D1); the package-manager manifests (Homebrew / Scoop / winget /
|
||||
`cargo binstall`) remain to do. The asset naming
|
||||
`rdbms-playground-<tag>-<target>` is already binstall-friendly.
|
||||
Tracked under ADR-ci-003 "Deferred".)*
|
||||
|
||||
## TUI shell
|
||||
|
||||
@@ -147,11 +168,19 @@ since ADR-0027.)
|
||||
cursor editing and is complete on its own terms; the separate
|
||||
**multi-line** entry goal is tracked under I1, which is
|
||||
genuinely not started.)*
|
||||
- [ ] **I1b** Readline-style cursor shortcuts: Ctrl-A / Ctrl-E
|
||||
- [x] **I1b** Readline-style cursor shortcuts: Ctrl-A / Ctrl-E
|
||||
as aliases for Home / End for users on keyboards without those
|
||||
keys (and for ergonomics in command-driven workflows). Likely
|
||||
followed by Ctrl-W (delete previous word), Ctrl-K (delete to
|
||||
end), Ctrl-U (delete to start). Pending.
|
||||
end), Ctrl-U (delete to start).
|
||||
*(Done 2026-06-12 — ADR-0049, issue #29: the full set —
|
||||
Esc-clear + Ctrl-A/E/W/K/U — wired in `App::handle_key`
|
||||
(`src/app.rs`) with helpers `clear_input` / `delete_prev_word`
|
||||
/ `kill_to_end` / `kill_to_start`; Esc clears only when no
|
||||
completion memo is alive (the memo wins first, ADR-0022);
|
||||
cursor-only keys leave history navigation intact, kill keys
|
||||
end it; 22 Tier-1 tests. On-screen advertisement of these keys
|
||||
is issue #27's bottom-status-line work.)*
|
||||
- [x] **I2** Persistent navigable input history (project-scoped).
|
||||
*(Implemented across Iterations 2 + 6: per-command append to
|
||||
`history.log` (Iter 2); on project open, the in-memory
|
||||
@@ -242,16 +271,13 @@ since ADR-0027.)
|
||||
|
||||
## App-level commands (per ADR-0003)
|
||||
|
||||
- [/] **A1** All canonical app-level commands implemented and
|
||||
- [x] **A1** All canonical app-level commands implemented and
|
||||
available in both modes: `save`, `save as`, `load`, `new`,
|
||||
`rebuild`, `export`, `import`, `seed`, `replay`, `undo`,
|
||||
`redo`, `mode`, `help`, `hint`, `quit`.
|
||||
*(Partial: **14 of 15** implemented and available in both modes —
|
||||
`quit`/`q`, `mode simple|advanced`, `help`, `save`, `save as`,
|
||||
`load`, `new`, `rebuild`, `export`, `import`, `replay`, `undo`,
|
||||
`redo`, and now **`seed`** (ADR-0048 / SD1, done 2026-06-11).
|
||||
**Only `hint`** (tracked as H2) remains unregistered. A1 closes
|
||||
when H2 lands.)*
|
||||
*(Done 2026-06-15: the last command, **`hint`**, landed with H2
|
||||
(ADR-0053). All 15 canonical app commands are now registered and
|
||||
available in both modes.)*
|
||||
|
||||
## DSL data commands
|
||||
|
||||
@@ -696,7 +722,10 @@ since ADR-0027.)
|
||||
`Generator`, and full completion / highlight / validity / help /
|
||||
parse-error-pedagogy wiring. Deferred SD2 increments:
|
||||
user-defined custom generators, NULL injection, multi-locale,
|
||||
recursive parent auto-seed.)*
|
||||
recursive parent auto-seed. Later catalogue refinements:
|
||||
**#33** year-as-int (`year`/`*_year`/`published`/`founded`) and
|
||||
**#34** conventional choice sets (`priority`/`severity`/`rating`,
|
||||
`status` excluded) — ADR-0048 Amendment 1.)*
|
||||
|
||||
## Query analysis
|
||||
|
||||
@@ -782,8 +811,16 @@ since ADR-0027.)
|
||||
`returning `) still shows the raw expression first-set —
|
||||
typing-time completion already offers the right candidates
|
||||
there, so the payoff is small.
|
||||
- [ ] **H2** `hint` provides contextual help for the current
|
||||
- [x] **H2** `hint` provides contextual help for the current
|
||||
input or the most recent error.
|
||||
*(Done 2026-06-15, ADR-0053: an **F1** keybinding gives a tier-3
|
||||
teaching hint for the live partial input (read-only overlay), and a
|
||||
submitted **`hint`** command expands on the most recent runtime error.
|
||||
A new `hint.cmd.<form>` / `hint.err.<class>` catalogue tier
|
||||
(`what`/`example`/`concept`) covers every command form + the 9 runtime
|
||||
error classes, enforced by a comprehensiveness coverage test. Deferred:
|
||||
the pre-submit-diagnostic route + `diagnostic.*` blocks (#38),
|
||||
clause-concept hints (#37).)*
|
||||
- [x] **H3** `help` provides general reference and per-command
|
||||
help.
|
||||
*(Done 2026-06-07: the **general reference** is `help` (no arg) —
|
||||
@@ -867,8 +904,18 @@ since ADR-0027.)
|
||||
PTY. Correcting a stale `CLAUDE.md` line that read "Tier 4 is
|
||||
wired only for the listed critical flows" — it was not wired at
|
||||
all. Genuinely deferred.)*
|
||||
- [ ] **TT5** CI runs all tiers on Linux, macOS, and Windows on
|
||||
- [/] **TT5** CI runs all tiers on Linux, macOS, and Windows on
|
||||
stable Rust.
|
||||
*(Partial, 2026-06-15. **CI is live** on the self-hosted Gitea
|
||||
Actions (`docs/ci/adr/`): the gate runs `clippy -D warnings` +
|
||||
`cargo test` (Tiers 1–3) on the **Linux** runner for every branch
|
||||
push / PR, and `release-macos` runs the suite natively on the
|
||||
**macOS** runner. **Windows is build-only** — cross-compiled, not
|
||||
executed (no Windows runner). **Tier 4** (PTY, TT4) is still
|
||||
unwired, so "all tiers" is not yet fully met. "Stable Rust" is
|
||||
satisfied by the flake's pinned `1.95.0` (a stable release, not
|
||||
nightly). Remaining for full TT5: a Windows execution runner and
|
||||
Tier-4 PTY in CI.)*
|
||||
|
||||
## Cross-cutting
|
||||
|
||||
|
||||
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",
|
||||
]
|
||||
@@ -41,6 +41,11 @@ pub enum Action {
|
||||
/// §4). `source` is the original user-typed text.
|
||||
JournalFailure {
|
||||
source: String,
|
||||
/// Whether the failed submission was advanced (ADR-0052): tags the
|
||||
/// `err` record `err:adv` so a failed advanced command hydrates in
|
||||
/// its `:`-prefixed form, recallable in simple mode. App commands
|
||||
/// (mode-agnostic) are `false`.
|
||||
advanced: bool,
|
||||
},
|
||||
/// User issued the `rebuild` app-level command (ADR-0015
|
||||
/// §7, §11). Runtime computes a summary from
|
||||
|
||||
+894
-24
File diff suppressed because it is too large
Load Diff
@@ -753,6 +753,51 @@ pub fn candidates_at_cursor_with_in_mode(
|
||||
);
|
||||
}
|
||||
|
||||
// Source 1.95: in-scope table aliases (issue #31). At a bare
|
||||
// `sql_expr_ident` slot — one *not* already past a `qualifier .`
|
||||
// (handled by §10.5 column narrowing) — the partial may be a
|
||||
// FROM-source the learner is mid-typing as a qualifier
|
||||
// (`group by o` → `o.<column>`). Offer each binding's *qualifier*:
|
||||
// its alias if it has one, else the table name (an aliased source
|
||||
// must be referenced by its alias, not the raw table name). This
|
||||
// makes aliases Tab-discoverable and — since a non-empty candidate
|
||||
// set overlapping the partial suppresses the under-cursor error
|
||||
// (the `typing_over_diag` path) — keeps the alias from flashing as
|
||||
// a bogus "unknown column" while typing. Mixed into `identifiers`
|
||||
// so it sorts/dedups/colours uniformly with column candidates.
|
||||
let alias_candidates: Vec<String> =
|
||||
if has_sql_expr_slot && prefix_qualifier.is_none() {
|
||||
// Once the partial *exactly* matches an in-scope qualifier,
|
||||
// discoverability is served — the learner has a whole alias
|
||||
// in hand and now needs the "add `.column`" hint
|
||||
// (`diagnostic.alias_used_as_column`), not sibling aliases
|
||||
// that merely share the prefix. Offering them would also let
|
||||
// the `typing_over_diag` path suppress that very hint. So in
|
||||
// the exact-match case we emit no alias candidates and let
|
||||
// the targeted diagnostic surface.
|
||||
let partial_is_exact_alias = resolution_from_scope.iter().any(|b| {
|
||||
let q = b.alias.as_deref().unwrap_or(b.table.as_str());
|
||||
q.eq_ignore_ascii_case(&partial_prefix)
|
||||
});
|
||||
if partial_is_exact_alias {
|
||||
Vec::new()
|
||||
} else {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for binding in resolution_from_scope {
|
||||
let qualifier =
|
||||
binding.alias.as_deref().unwrap_or(binding.table.as_str());
|
||||
if matches_prefix(qualifier)
|
||||
&& !out.iter().any(|q| q.eq_ignore_ascii_case(qualifier))
|
||||
{
|
||||
out.push(qualifier.to_string());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Source 2: schema identifiers — accumulated across every
|
||||
// matching schema-listable `Ident { source }` expectation.
|
||||
// `NewName` / `Types` / `Free` sources don't query the
|
||||
@@ -788,6 +833,10 @@ pub fn candidates_at_cursor_with_in_mode(
|
||||
})
|
||||
.filter(|name| matches_prefix(name))
|
||||
.collect();
|
||||
// Fold in the in-scope alias qualifiers (Source 1.95). They are
|
||||
// already prefix-filtered; dedup against any column of the same
|
||||
// spelling happens via the shared sort/dedup below.
|
||||
identifiers.extend(alias_candidates);
|
||||
identifiers.sort();
|
||||
identifiers.dedup();
|
||||
// If an identifier shares its name with a keyword candidate
|
||||
@@ -1930,6 +1979,67 @@ mod tests {
|
||||
cache
|
||||
}
|
||||
|
||||
fn two_table_alias_cache() -> SchemaCache {
|
||||
use crate::dsl::types::Type;
|
||||
let mut cache = schema_with_table("a", &[("id", Type::Int), ("name", Type::Text)]);
|
||||
cache.tables.push("b".to_string());
|
||||
cache.columns.push("total".to_string());
|
||||
cache.table_columns.insert(
|
||||
"b".to_string(),
|
||||
vec![
|
||||
TableColumn::new("id", Type::Int),
|
||||
TableColumn::new("total", Type::Real),
|
||||
],
|
||||
);
|
||||
cache
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_expr_slot_offers_in_scope_aliases() {
|
||||
// Issue #31: at a bare SQL-expression slot (here GROUP BY) the
|
||||
// in-scope FROM aliases are Tab-discoverable, so a learner can
|
||||
// reach `o.<column>` without guessing the alias.
|
||||
let cache = two_table_alias_cache();
|
||||
let input = "select a.id from a o join b z on o.id = z.id group by ";
|
||||
let cs = cands_with(input, input.len(), &cache);
|
||||
assert!(cs.contains(&"o".to_string()), "alias `o` must be offered; got {cs:?}");
|
||||
assert!(cs.contains(&"z".to_string()), "alias `z` must be offered; got {cs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_expr_slot_narrows_aliases_by_partial_prefix() {
|
||||
// A partial that prefix-matches several aliases offers each;
|
||||
// an exact match (`o`) is the learner's whole alias — no
|
||||
// sibling-alias noise, so the `alias_used_as_column` hint can
|
||||
// surface instead (issue #31).
|
||||
let cache = two_table_alias_cache();
|
||||
let input = "select a.id from a aa join b ab on aa.id = ab.id group by a";
|
||||
let cs = cands_with(input, input.len(), &cache);
|
||||
assert!(cs.contains(&"aa".to_string()), "alias `aa` must be offered; got {cs:?}");
|
||||
assert!(cs.contains(&"ab".to_string()), "alias `ab` must be offered; got {cs:?}");
|
||||
|
||||
// Exact-alias partial: the alias source steps aside.
|
||||
let exact = "select aa.id from a aa join b ab on aa.id = ab.id group by aa";
|
||||
let cs2 = cands_with(exact, exact.len(), &cache);
|
||||
assert!(
|
||||
!cs2.iter().any(|c| c == "ab"),
|
||||
"an exact-alias partial must not surface sibling aliases; got {cs2:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alias_not_offered_after_a_qualifier_dot() {
|
||||
// Past `o.` the §10.5 column-narrowing owns the slot; aliases
|
||||
// are not candidates there.
|
||||
let cache = two_table_alias_cache();
|
||||
let input = "select a.id from a o join b z on o.id = z.id group by o.";
|
||||
let cs = cands_with(input, input.len(), &cache);
|
||||
assert!(
|
||||
!cs.iter().any(|c| c == "o" || c == "z"),
|
||||
"aliases must not be offered after a qualifier dot; got {cs:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_set_offers_only_current_table_columns() {
|
||||
use crate::dsl::types::Type;
|
||||
|
||||
@@ -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"],};
|
||||
|
||||
+32
-1
@@ -438,6 +438,17 @@ const LIMIT_CLAUSE: Node = Node::Seq(LIMIT_CLAUSE_NODES);
|
||||
const SEED_COUNT: Node = Node::NumberLit {
|
||||
validator: Some(LIMIT_VALIDATOR),
|
||||
};
|
||||
/// Issue #26: the row count is a bare positional number, so it produces
|
||||
/// no Tab candidate and was invisible in the hint panel at
|
||||
/// `seed <table> ▮` (only `set` / `--seed` showed). Wrapping it in
|
||||
/// `IntroProse` advertises it (and the other options) in prose; the
|
||||
/// skipped-optional carry (`surviving_intro_hint`) makes the hint reach
|
||||
/// the resolver despite the trailing optionals. Tab still cycles the
|
||||
/// keyword candidates.
|
||||
const SEED_COUNT_HINTED: Node = Node::Hinted {
|
||||
mode: crate::dsl::grammar::HintMode::IntroProse("hint.seed_count"),
|
||||
inner: &SEED_COUNT,
|
||||
};
|
||||
/// `--seed <n>` — a reproducible-generation flag carrying a numeric
|
||||
/// seed (ADR-0048 D4). The only flag in the DSL that takes a value;
|
||||
/// `build_seed` reads the number immediately after the flag.
|
||||
@@ -567,7 +578,7 @@ const SEED_NODES: &[Node] = &[
|
||||
// against this table.
|
||||
TABLE_NAME_WRITES,
|
||||
SEED_DOT_COLUMN,
|
||||
Node::Optional(&SEED_COUNT),
|
||||
Node::Optional(&SEED_COUNT_HINTED),
|
||||
Node::Optional(&SEED_SET_CLAUSE),
|
||||
Node::Optional(&SEED_FLAG),
|
||||
];
|
||||
@@ -1779,6 +1790,13 @@ pub static SHOW: CommandNode = CommandNode {
|
||||
shape: SHOW_SHAPE,
|
||||
ast_builder: build_show,
|
||||
help_id: Some("data.show"),
|
||||
hint_ids: &[
|
||||
"show_data",
|
||||
"show_table",
|
||||
"show_tables",
|
||||
"show_relationships",
|
||||
"show_indexes",
|
||||
],
|
||||
usage_ids: &[
|
||||
"parse.usage.show_data",
|
||||
"parse.usage.show_table",
|
||||
@@ -1794,6 +1812,7 @@ pub static SEED: CommandNode = CommandNode {
|
||||
shape: SEED_SHAPE,
|
||||
ast_builder: build_seed,
|
||||
help_id: Some("data.seed"),
|
||||
hint_ids: &["seed"],
|
||||
usage_ids: &["parse.usage.seed"],
|
||||
};
|
||||
|
||||
@@ -1802,6 +1821,8 @@ pub static INSERT: CommandNode = CommandNode {
|
||||
shape: INSERT_SHAPE,
|
||||
ast_builder: build_insert,
|
||||
help_id: Some("data.insert"),
|
||||
// ADR-0053 Phase-B exemplar.
|
||||
hint_ids: &["insert"],
|
||||
usage_ids: &["parse.usage.insert"],};
|
||||
|
||||
pub static UPDATE: CommandNode = CommandNode {
|
||||
@@ -1809,6 +1830,7 @@ pub static UPDATE: CommandNode = CommandNode {
|
||||
shape: UPDATE_SHAPE,
|
||||
ast_builder: build_update,
|
||||
help_id: Some("data.update"),
|
||||
hint_ids: &["update"],
|
||||
usage_ids: &["parse.usage.update"],};
|
||||
|
||||
pub static DELETE: CommandNode = CommandNode {
|
||||
@@ -1816,6 +1838,7 @@ pub static DELETE: CommandNode = CommandNode {
|
||||
shape: DELETE_SHAPE,
|
||||
ast_builder: build_delete,
|
||||
help_id: Some("data.delete"),
|
||||
hint_ids: &["delete"],
|
||||
usage_ids: &["parse.usage.delete"],};
|
||||
|
||||
pub static REPLAY: CommandNode = CommandNode {
|
||||
@@ -1823,6 +1846,7 @@ pub static REPLAY: CommandNode = CommandNode {
|
||||
shape: REPLAY_PATH,
|
||||
ast_builder: build_replay,
|
||||
help_id: Some("data.replay"),
|
||||
hint_ids: &["replay"],
|
||||
usage_ids: &["parse.usage.replay"],};
|
||||
|
||||
pub static EXPLAIN: CommandNode = CommandNode {
|
||||
@@ -1830,6 +1854,7 @@ pub static EXPLAIN: CommandNode = CommandNode {
|
||||
shape: EXPLAIN_SHAPE,
|
||||
ast_builder: build_explain,
|
||||
help_id: Some("data.explain"),
|
||||
hint_ids: &["explain"],
|
||||
usage_ids: &["parse.usage.explain"],};
|
||||
|
||||
/// `explain` over advanced-mode SQL (ADR-0039).
|
||||
@@ -1849,6 +1874,7 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode {
|
||||
// too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE`
|
||||
// precedent; otherwise `note_help` would print `explain` twice.
|
||||
help_id: None,
|
||||
hint_ids: &["explain_sql"],
|
||||
usage_ids: &[],};
|
||||
|
||||
/// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032).
|
||||
@@ -1864,6 +1890,7 @@ pub static SELECT: CommandNode = CommandNode {
|
||||
shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL),
|
||||
ast_builder: build_select,
|
||||
help_id: None,
|
||||
hint_ids: &["select"],
|
||||
usage_ids: &["parse.usage.select"],};
|
||||
|
||||
/// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c).
|
||||
@@ -1878,6 +1905,7 @@ pub static WITH: CommandNode = CommandNode {
|
||||
shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL),
|
||||
ast_builder: build_select,
|
||||
help_id: None,
|
||||
hint_ids: &["with"],
|
||||
usage_ids: &["parse.usage.with"],};
|
||||
|
||||
/// SQL `INSERT` — the `Advanced`-category node of the shared
|
||||
@@ -1895,6 +1923,7 @@ pub static SQL_INSERT: CommandNode = CommandNode {
|
||||
shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE),
|
||||
ast_builder: build_sql_insert,
|
||||
help_id: None,
|
||||
hint_ids: &["sql_insert"],
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
@@ -1908,6 +1937,7 @@ pub static SQL_UPDATE: CommandNode = CommandNode {
|
||||
shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE),
|
||||
ast_builder: build_sql_update,
|
||||
help_id: None,
|
||||
hint_ids: &["sql_update"],
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
@@ -1923,6 +1953,7 @@ pub static SQL_DELETE: CommandNode = CommandNode {
|
||||
shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE),
|
||||
ast_builder: build_sql_delete,
|
||||
help_id: None,
|
||||
hint_ids: &["sql_delete"],
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
|
||||
@@ -968,6 +968,13 @@ pub static DROP: CommandNode = CommandNode {
|
||||
shape: DROP_SHAPE,
|
||||
ast_builder: build_drop,
|
||||
help_id: Some("ddl.drop"),
|
||||
hint_ids: &[
|
||||
"drop_table",
|
||||
"drop_column",
|
||||
"drop_relationship",
|
||||
"drop_index",
|
||||
"drop_constraint",
|
||||
],
|
||||
usage_ids: &[
|
||||
"parse.usage.drop_table",
|
||||
"parse.usage.drop_column",
|
||||
@@ -981,6 +988,16 @@ pub static ADD: CommandNode = CommandNode {
|
||||
shape: ADD_SHAPE,
|
||||
ast_builder: build_add,
|
||||
help_id: Some("ddl.add"),
|
||||
// Per-form (ADR-0053 D3): every form is listed so the form-word
|
||||
// disambiguation resolves correctly; forms without an authored
|
||||
// block yet fall back to tier-2 at render. `add_relationship` is
|
||||
// authored as a Phase-B exemplar.
|
||||
hint_ids: &[
|
||||
"add_column",
|
||||
"add_relationship",
|
||||
"add_index",
|
||||
"add_constraint",
|
||||
],
|
||||
usage_ids: &[
|
||||
"parse.usage.add_column",
|
||||
"parse.usage.add_relationship",
|
||||
@@ -993,6 +1010,7 @@ pub static RENAME: CommandNode = CommandNode {
|
||||
shape: RENAME_COLUMN,
|
||||
ast_builder: build_rename_column,
|
||||
help_id: Some("ddl.rename"),
|
||||
hint_ids: &["rename_column"],
|
||||
usage_ids: &["parse.usage.rename_column"],};
|
||||
|
||||
pub static CHANGE: CommandNode = CommandNode {
|
||||
@@ -1000,6 +1018,7 @@ pub static CHANGE: CommandNode = CommandNode {
|
||||
shape: CHANGE_COLUMN,
|
||||
ast_builder: build_change_column,
|
||||
help_id: Some("ddl.change"),
|
||||
hint_ids: &["change_column"],
|
||||
usage_ids: &["parse.usage.change_column"],};
|
||||
|
||||
// =================================================================
|
||||
@@ -1360,6 +1379,7 @@ pub static CREATE: CommandNode = CommandNode {
|
||||
shape: CREATE_TABLE,
|
||||
ast_builder: build_create_table,
|
||||
help_id: Some("ddl.create"),
|
||||
hint_ids: &["create_table"],
|
||||
usage_ids: &["parse.usage.create_table"],};
|
||||
|
||||
// =================================================================
|
||||
@@ -1428,6 +1448,7 @@ pub static CREATE_M2N: CommandNode = CommandNode {
|
||||
shape: CREATE_M2N_SHAPE,
|
||||
ast_builder: build_create_m2n,
|
||||
help_id: Some("ddl.create_m2n"),
|
||||
hint_ids: &["create_m2n"],
|
||||
usage_ids: &["parse.usage.create_m2n"],
|
||||
};
|
||||
|
||||
@@ -1858,6 +1879,7 @@ pub static SQL_CREATE_TABLE: CommandNode = CommandNode {
|
||||
shape: Node::Subgrammar(&super::sql_create_table::SQL_CREATE_TABLE_SHAPE),
|
||||
ast_builder: build_sql_create_table,
|
||||
help_id: Some("ddl.sql_create_table"),
|
||||
hint_ids: &["sql_create_table"],
|
||||
usage_ids: &["parse.usage.sql_create_table"],
|
||||
};
|
||||
|
||||
@@ -1877,6 +1899,7 @@ pub static SQL_DROP_TABLE: CommandNode = CommandNode {
|
||||
shape: SQL_DROP_TABLE_SHAPE,
|
||||
ast_builder: build_sql_drop_table,
|
||||
help_id: Some("ddl.sql_drop_table"),
|
||||
hint_ids: &["sql_drop_table"],
|
||||
usage_ids: &["parse.usage.sql_drop_table"],
|
||||
};
|
||||
|
||||
@@ -1896,6 +1919,7 @@ pub static SQL_DROP_INDEX: CommandNode = CommandNode {
|
||||
shape: SQL_DROP_INDEX_SHAPE,
|
||||
ast_builder: build_sql_drop_index,
|
||||
help_id: Some("ddl.sql_drop_index"),
|
||||
hint_ids: &["sql_drop_index"],
|
||||
usage_ids: &["parse.usage.sql_drop_index"],
|
||||
};
|
||||
|
||||
@@ -1977,6 +2001,7 @@ pub static SQL_CREATE_INDEX: CommandNode = CommandNode {
|
||||
shape: SQL_CREATE_INDEX_SHAPE,
|
||||
ast_builder: build_sql_create_index,
|
||||
help_id: Some("ddl.sql_create_index"),
|
||||
hint_ids: &["sql_create_index"],
|
||||
usage_ids: &["parse.usage.sql_create_index"],
|
||||
};
|
||||
|
||||
@@ -2535,6 +2560,7 @@ pub static SQL_ALTER_TABLE: CommandNode = CommandNode {
|
||||
shape: SQL_ALTER_TABLE_SHAPE,
|
||||
ast_builder: build_sql_alter_table,
|
||||
help_id: Some("ddl.sql_alter_table"),
|
||||
hint_ids: &["sql_alter_table"],
|
||||
usage_ids: &["parse.usage.sql_alter_table"],
|
||||
};
|
||||
|
||||
|
||||
+217
-39
@@ -530,6 +530,18 @@ pub struct CommandNode {
|
||||
/// so a newly-registered command appears in `help`
|
||||
/// automatically (ADR-0024 §help_id).
|
||||
pub help_id: Option<&'static str>,
|
||||
/// Catalog key stems (`hint.cmd.<id>`) for this command's
|
||||
/// **tier-3** contextual hints (ADR-0053 / H2), **one per form**,
|
||||
/// mirroring `usage_ids`. A single-form command carries one; a
|
||||
/// multi-form command (`add`, `drop`, `show`, `create`) carries
|
||||
/// one per form so a live-input hint can be specific to the form
|
||||
/// being typed (`hint.cmd.add_relationship`, not a shared `add`
|
||||
/// block). `hint_key_for_input_in_mode` disambiguates by the form
|
||||
/// word, reusing `usage_key_for_input_in_mode`'s logic. Empty
|
||||
/// until a form's tier-3 block is authored (the surface falls back
|
||||
/// to tier-2 ambient/error text). Distinct from `help_id` (which is
|
||||
/// `None` on advanced-SQL forms purely to dedup the `help` list).
|
||||
pub hint_ids: &'static [&'static str],
|
||||
/// Catalog keys under `parse.usage.*` to render in the
|
||||
/// "usage:" block when a parse error fires for this command
|
||||
/// (ADR-0021 §1, ADR-0024 §architecture). Multi-form families
|
||||
@@ -574,32 +586,100 @@ pub fn usage_keys_for_input_in_mode(
|
||||
source: &str,
|
||||
mode: crate::mode::Mode,
|
||||
) -> Option<(&'static str, Vec<&'static str>)> {
|
||||
let pick = selected_nodes_for_input_in_mode(source, mode);
|
||||
if pick.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut keys: Vec<&'static str> = Vec::new();
|
||||
for (_, node, _) in &pick {
|
||||
for k in node.usage_ids {
|
||||
if !keys.contains(k) {
|
||||
keys.push(*k);
|
||||
}
|
||||
}
|
||||
}
|
||||
if keys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let entry = pick[0].1.entry.primary;
|
||||
Some((entry, keys))
|
||||
}
|
||||
|
||||
/// The single tier-3 hint key (`hint.cmd.<id>` stem) for the command
|
||||
/// **form** `source` is currently typing, in `mode` (H2 / ADR-0053).
|
||||
///
|
||||
/// Mirrors [`usage_key_for_input_in_mode`]: the union of the
|
||||
/// mode-selected nodes' `hint_ids`, disambiguated to the typed form by
|
||||
/// [`pick_form_key`] — so `add 1:n relationship` resolves to the
|
||||
/// relationship hint, and an advanced-SQL form resolves to its own
|
||||
/// (not its simple sibling's). `None` if no entry word matches or the
|
||||
/// form has no tier-3 block yet (the caller falls back to tier-2).
|
||||
#[must_use]
|
||||
pub fn hint_key_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> {
|
||||
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
||||
let nodes = selected_nodes_for_input_in_mode(source, mode);
|
||||
if nodes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Mode-ordered union (advanced-primary first in advanced mode), so a
|
||||
// shared entry word resolves to the surface the user is in.
|
||||
let mut keys: Vec<&'static str> = Vec::new();
|
||||
for (_, node, _) in &nodes {
|
||||
for k in node.hint_ids {
|
||||
if !keys.contains(k) {
|
||||
keys.push(*k);
|
||||
}
|
||||
}
|
||||
}
|
||||
if keys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if keys.len() == 1 {
|
||||
return Some(keys[0]);
|
||||
}
|
||||
// A bare multi-form entry word (no form word yet — `add`⏎) has no
|
||||
// chosen form: defer to tier-2, which lists the choices.
|
||||
let start = skip_whitespace(source, 0);
|
||||
if let Some((_, entry_end)) = consume_ident(source, start)
|
||||
&& skip_whitespace(source, entry_end) >= source.len()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// A form word picks the form (`drop column` → `drop_column`); when
|
||||
// the second token isn't a form word (`insert into …`, `update …
|
||||
// set`), fall back to the mode-primary key — in advanced mode the
|
||||
// SQL form, in simple mode the DSL form.
|
||||
pick_form_key(source, &keys).or_else(|| keys.first().copied())
|
||||
}
|
||||
|
||||
/// Shared mode-aware command-form selection for the entry word at the
|
||||
/// start of `source`.
|
||||
///
|
||||
/// Extracted so the usage-key and hint-id lookups agree on which form
|
||||
/// the user is typing.
|
||||
///
|
||||
/// Advanced mode: every candidate form is reachable — the SQL nodes
|
||||
/// are primary, and the DSL nodes remain valid via fallback (verified:
|
||||
/// `create table … with pk` and `drop column …` both run in advanced
|
||||
/// mode). Mode-primary (Advanced) first, so a hint never hides input
|
||||
/// that works. Simple mode: only the DSL forms — the SQL-only forms
|
||||
/// hit the "this is SQL" rail and are not reachable. (ADR-0042 G3.)
|
||||
/// Degenerate guard: an advanced-only word in simple mode leaves the
|
||||
/// selection empty; fall back to all candidates.
|
||||
fn selected_nodes_for_input_in_mode(
|
||||
source: &str,
|
||||
mode: crate::mode::Mode,
|
||||
) -> Vec<(usize, &'static CommandNode, CommandCategory)> {
|
||||
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
||||
let start = skip_whitespace(source, 0);
|
||||
let (kw_start, kw_end) = consume_ident(source, start)?;
|
||||
let Some((kw_start, kw_end)) = consume_ident(source, start) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let word = &source[kw_start..kw_end];
|
||||
let candidates = commands_for_entry_word(word);
|
||||
if candidates.is_empty() {
|
||||
return None;
|
||||
return Vec::new();
|
||||
}
|
||||
let union = |nodes: &[(usize, &'static CommandNode, CommandCategory)]| -> Vec<&'static str> {
|
||||
let mut keys: Vec<&'static str> = Vec::new();
|
||||
for (_, node, _) in nodes {
|
||||
for k in node.usage_ids {
|
||||
if !keys.contains(k) {
|
||||
keys.push(*k);
|
||||
}
|
||||
}
|
||||
}
|
||||
keys
|
||||
};
|
||||
// Advanced mode: every candidate form is reachable — the SQL
|
||||
// nodes are primary, and the DSL nodes remain valid via fallback
|
||||
// (verified: `create table … with pk` and `drop column …` both
|
||||
// run in advanced mode). Show them all, mode-primary (Advanced)
|
||||
// first, so the usage hint never hides input that works. Simple
|
||||
// mode: only the DSL forms — the SQL-only forms hit the "this is
|
||||
// SQL" rail and are not reachable. (ADR-0042 G3.)
|
||||
let selected: Vec<(usize, &'static CommandNode, CommandCategory)> =
|
||||
if mode == crate::mode::Mode::Advanced {
|
||||
let mut v: Vec<_> = candidates
|
||||
@@ -621,17 +701,7 @@ pub fn usage_keys_for_input_in_mode(
|
||||
.filter(|(_, _, c)| *c == CommandCategory::Simple)
|
||||
.collect()
|
||||
};
|
||||
// Degenerate guard: an advanced-only word in simple mode (not
|
||||
// normally reachable — it hits the SQL rail first) leaves
|
||||
// `selected` empty; fall back to all candidates so a usage block
|
||||
// still renders rather than the available-commands fallback.
|
||||
let pick = if selected.is_empty() { candidates } else { selected };
|
||||
let keys = union(&pick);
|
||||
if keys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let entry = pick[0].1.entry.primary;
|
||||
Some((entry, keys))
|
||||
if selected.is_empty() { candidates } else { selected }
|
||||
}
|
||||
|
||||
/// The single usage template most relevant to `source`, when
|
||||
@@ -658,14 +728,24 @@ pub fn usage_key_for_input_in_mode(
|
||||
source: &str,
|
||||
mode: crate::mode::Mode,
|
||||
) -> Option<&'static str> {
|
||||
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
||||
let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?;
|
||||
pick_form_key(source, &keys)
|
||||
}
|
||||
|
||||
/// From the form word after the entry keyword, pick the single `keys`
|
||||
/// entry for the form `source` names.
|
||||
///
|
||||
/// A single-entry list resolves to its one key; a multi-form list
|
||||
/// disambiguates by the form word (`add 1:n relationship` → the
|
||||
/// `…relationship` key, `create m:n …` → the `…m2n` key, else the
|
||||
/// identifier form word matched against each key's suffix). Shared by
|
||||
/// the usage-template and tier-3-hint single-key lookups so they agree.
|
||||
fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> {
|
||||
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
||||
let first = *keys.first()?;
|
||||
if keys.len() == 1 {
|
||||
return Some(first);
|
||||
}
|
||||
// Multi-form: the form is named by the token right after
|
||||
// the entry keyword.
|
||||
let start = skip_whitespace(source, 0);
|
||||
let (_, entry_end) = consume_ident(source, start)?;
|
||||
let after = skip_whitespace(source, entry_end);
|
||||
@@ -674,14 +754,12 @@ pub fn usage_key_for_input_in_mode(
|
||||
return keys.iter().copied().find(|k| k.ends_with("relationship"));
|
||||
}
|
||||
// The `create m:n relationship` form (ADR-0045) opens with `m:n`
|
||||
// — a letter, so the digit branch misses it, and its usage key ends
|
||||
// `…create_m2n` (not `relationship`).
|
||||
// — a letter, so the digit branch misses it; its key ends `…m2n`.
|
||||
if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) {
|
||||
return keys.iter().copied().find(|k| k.ends_with("m2n"));
|
||||
}
|
||||
// Otherwise the form word is an identifier — `column`,
|
||||
// `index`, `table`, `relationship` — matched against the
|
||||
// usage key's suffix.
|
||||
// Otherwise the form word is an identifier — `column`, `index`,
|
||||
// `table`, `relationship` — matched against each key's suffix.
|
||||
let (s, e) = consume_ident(source, after)?;
|
||||
let form = source[s..e].to_ascii_lowercase();
|
||||
keys.iter().copied().find(|k| k.ends_with(form.as_str()))
|
||||
@@ -712,6 +790,7 @@ pub fn entry_words_alphabetised() -> Vec<&'static str> {
|
||||
pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
|
||||
(&app::QUIT, CommandCategory::Simple),
|
||||
(&app::HELP, CommandCategory::Simple),
|
||||
(&app::HINT, CommandCategory::Simple),
|
||||
(&app::REBUILD, CommandCategory::Simple),
|
||||
(&app::SAVE, CommandCategory::Simple),
|
||||
(&app::NEW, CommandCategory::Simple),
|
||||
@@ -836,6 +915,105 @@ pub fn commands_for_entry_word(
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hint_key_tests {
|
||||
use super::hint_key_for_input_in_mode;
|
||||
use crate::mode::Mode;
|
||||
|
||||
/// Per-form hint keying (ADR-0053 D3): a multi-form command
|
||||
/// resolves the *typed* form, not the node — `add 1:n
|
||||
/// relationship` → the relationship hint, `add column` → the
|
||||
/// (as-yet-unauthored) column hint, never the wrong form.
|
||||
#[test]
|
||||
fn hint_key_resolves_the_typed_form() {
|
||||
assert_eq!(
|
||||
hint_key_for_input_in_mode("add 1:n relationship from A.x to B.y", Mode::Simple),
|
||||
Some("add_relationship")
|
||||
);
|
||||
assert_eq!(
|
||||
hint_key_for_input_in_mode("add column Note text to T", Mode::Simple),
|
||||
Some("add_column")
|
||||
);
|
||||
assert_eq!(
|
||||
hint_key_for_input_in_mode("insert into T values (1)", Mode::Simple),
|
||||
Some("insert")
|
||||
);
|
||||
// Multi-form DROP disambiguates to the typed form too.
|
||||
assert_eq!(
|
||||
hint_key_for_input_in_mode("drop table T", Mode::Simple),
|
||||
Some("drop_table")
|
||||
);
|
||||
// Mode picks the surface for a shared entry word whose second
|
||||
// token isn't a form word: SQL form in advanced, DSL in simple.
|
||||
assert_eq!(
|
||||
hint_key_for_input_in_mode("insert into T values (1)", Mode::Advanced),
|
||||
Some("sql_insert")
|
||||
);
|
||||
assert_eq!(
|
||||
hint_key_for_input_in_mode("insert into T values (1)", Mode::Simple),
|
||||
Some("insert")
|
||||
);
|
||||
// `create table` shares a form word — advanced-first ordering
|
||||
// resolves it to the SQL form in advanced mode.
|
||||
assert_eq!(
|
||||
hint_key_for_input_in_mode("create table T (id int)", Mode::Advanced),
|
||||
Some("sql_create_table")
|
||||
);
|
||||
// Unknown entry word → None (tier-2 fallback).
|
||||
assert_eq!(hint_key_for_input_in_mode("zzz", Mode::Simple), None);
|
||||
}
|
||||
|
||||
/// Comprehensiveness gate (ADR-0053 D6): every command form in the
|
||||
/// REGISTRY carries at least one `hint_id`, and each resolves to a
|
||||
/// tier-3 `hint.cmd.<id>` block. `keys.rs` checks referenced keys
|
||||
/// resolve; this checks every command *has* one.
|
||||
#[test]
|
||||
fn every_command_form_has_a_tier3_block() {
|
||||
let cat = crate::friendly::catalog();
|
||||
for (node, _category) in super::REGISTRY {
|
||||
assert!(
|
||||
!node.hint_ids.is_empty(),
|
||||
"command `{}` has no hint_ids (ADR-0053 D6)",
|
||||
node.entry.primary
|
||||
);
|
||||
for id in node.hint_ids {
|
||||
let key = format!("hint.cmd.{id}.what");
|
||||
assert!(
|
||||
cat.get(&key).is_some(),
|
||||
"missing tier-3 block `{key}` for command `{}`",
|
||||
node.entry.primary
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Comprehensiveness gate (ADR-0053 D6): every runtime error class
|
||||
/// `friendly::error_hint_class` can return resolves to a tier-3
|
||||
/// `hint.err.<class>` block. Keep this list in sync with
|
||||
/// `error_hint_class` (its own unit tests pin the outputs).
|
||||
/// Diagnostic classes are deferred (issue #38), so not checked here.
|
||||
#[test]
|
||||
fn every_runtime_error_class_has_a_tier3_block() {
|
||||
let cat = crate::friendly::catalog();
|
||||
let classes = [
|
||||
"unique",
|
||||
"foreign_key.child_side",
|
||||
"foreign_key.parent_side",
|
||||
"not_null",
|
||||
"check",
|
||||
"type_mismatch",
|
||||
"not_found",
|
||||
"already_exists",
|
||||
"generic",
|
||||
"invalid_value",
|
||||
];
|
||||
for c in classes {
|
||||
let key = format!("hint.err.{c}.what");
|
||||
assert!(cat.get(&key).is_some(), "missing tier-3 error block `{key}`");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod usage_key_tests {
|
||||
use super::usage_key_for_input;
|
||||
|
||||
@@ -134,6 +134,17 @@ pub struct WalkContext<'a> {
|
||||
/// resolver reads this directly instead of inferring the
|
||||
/// slot kind from the shape of the expected set.
|
||||
pub pending_hint_mode: Option<crate::dsl::grammar::HintMode>,
|
||||
/// An `IntroProse` hint captured from an *optional* slot that
|
||||
/// the walk skipped (issue #26). Unlike `pending_hint_mode`
|
||||
/// (cleared on the very next match — including the empty match
|
||||
/// of a skipped `Optional`), this survives the trailing
|
||||
/// optional siblings so the hint reaches the resolver for a
|
||||
/// position like `seed <table> ▮`, where the optional row
|
||||
/// count is otherwise invisible. Carries the catalog key and
|
||||
/// the byte position the optional was skipped at; the resolver
|
||||
/// uses it only when that position is the cursor (so it doesn't
|
||||
/// leak past a later-consumed clause).
|
||||
pub surviving_intro_hint: Option<(&'static str, usize)>,
|
||||
/// The columns the user explicitly listed in
|
||||
/// `insert into <T> (col1, col2, …) values (…)` (Form A),
|
||||
/// in declaration order.
|
||||
@@ -232,6 +243,7 @@ impl<'a> WalkContext<'a> {
|
||||
pending_value_type: None,
|
||||
pending_value_column: None,
|
||||
pending_hint_mode: None,
|
||||
surviving_intro_hint: None,
|
||||
user_listed_columns: None,
|
||||
subgrammar_depth: 0,
|
||||
from_scope_stack: vec![ScopeFrame::default()],
|
||||
@@ -254,6 +266,7 @@ impl<'a> WalkContext<'a> {
|
||||
pending_value_type: None,
|
||||
pending_value_column: None,
|
||||
pending_hint_mode: None,
|
||||
surviving_intro_hint: None,
|
||||
user_listed_columns: None,
|
||||
subgrammar_depth: 0,
|
||||
from_scope_stack: vec![ScopeFrame::default()],
|
||||
|
||||
@@ -990,6 +990,21 @@ fn walk_seq(
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #26: when an `Optional` is skipped (its inner didn't engage),
|
||||
/// stash any `IntroProse` hint the inner left in `pending_hint_mode`
|
||||
/// into the surviving slot before it is cleared by this empty match.
|
||||
/// `position` is where the optional was skipped — the resolver compares
|
||||
/// it to the cursor so the hint only shows while the cursor sits at that
|
||||
/// optional, not after a later clause consumes input past it. Only
|
||||
/// `IntroProse` is carried (it is the "introduce an optional position"
|
||||
/// mode); `ProseOnly` / `ForceProse` mark active slots and reach the
|
||||
/// resolver through the normal `pending_hint_mode` path.
|
||||
const fn capture_skipped_intro_hint(ctx: &mut WalkContext, position: usize) {
|
||||
if let Some(crate::dsl::grammar::HintMode::IntroProse(key)) = ctx.pending_hint_mode {
|
||||
ctx.surviving_intro_hint = Some((key, position));
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_optional(
|
||||
source: &str,
|
||||
position: usize,
|
||||
@@ -1008,6 +1023,7 @@ fn walk_optional(
|
||||
// Inner didn't engage at all — skip the Optional
|
||||
// but carry the inner's expectations so the caller's
|
||||
// expected-set sees them.
|
||||
capture_skipped_intro_hint(ctx, position);
|
||||
path.items.truncate(saved_path_len);
|
||||
per_byte.truncate(saved_byte_len);
|
||||
NodeWalkResult::Matched {
|
||||
@@ -1019,6 +1035,7 @@ fn walk_optional(
|
||||
// Inner reported Incomplete without consuming
|
||||
// anything — same as NoMatch from the user's
|
||||
// perspective. Roll back and skip.
|
||||
capture_skipped_intro_hint(ctx, position);
|
||||
path.items.truncate(saved_path_len);
|
||||
per_byte.truncate(saved_byte_len);
|
||||
let _ = p;
|
||||
|
||||
@@ -116,6 +116,19 @@ pub fn hint_resolution_at_input_in_mode(
|
||||
use crate::dsl::grammar::HintMode;
|
||||
|
||||
let snap = expected_for_hint_snapshot(source, schema, mode);
|
||||
// Issue #26: an optional positional slot with no candidate text
|
||||
// (the `seed <table>` row count) left an `IntroProse` hint that
|
||||
// survived the trailing optionals. It is shown even for an
|
||||
// otherwise-complete command (empty expected set) — that is exactly
|
||||
// the `seed users ▮` case where the count is invisible. Checked
|
||||
// first, before the complete-command short-circuit below.
|
||||
if let Some(key) = snap.surviving_intro_hint {
|
||||
return Some(HintResolution {
|
||||
mode: HintMode::IntroProse(key),
|
||||
column: None,
|
||||
form_b_autogen_skipped: Vec::new(),
|
||||
});
|
||||
}
|
||||
// Empty expected set means the command is already complete
|
||||
// (`WalkOutcome::Match`) — no slot to hint at.
|
||||
if snap.expected.is_empty() {
|
||||
@@ -1163,6 +1176,60 @@ fn schema_existence_diagnostics(
|
||||
// Allowed-clause alias ref — silent.
|
||||
continue;
|
||||
}
|
||||
// Issue #31: the bare ident is itself an
|
||||
// in-scope FROM source — a table alias
|
||||
// (`o` from `FROM Orders o`) or, when the
|
||||
// source is un-aliased, the table name. The
|
||||
// learner means a *column* of that source
|
||||
// (`o.<column>`); calling it an "unknown
|
||||
// column" misleads. Point at the qualified
|
||||
// form.
|
||||
//
|
||||
// Two guards keep the advice correct:
|
||||
// - SQL only (`role == "sql_expr_ident"`):
|
||||
// the DSL `Expr` (role `expr_column`)
|
||||
// has no `table.column` syntax, so the
|
||||
// qualified-form advice would be wrong;
|
||||
// it keeps the generic unknown_column.
|
||||
// - Match the *effective qualifier*
|
||||
// (alias if present, else table name),
|
||||
// not the table name independently. An
|
||||
// aliased source must be referenced by
|
||||
// its alias — `FROM Orders o … Orders`
|
||||
// is invalid SQL, so it must NOT be
|
||||
// advised as `Orders.<column>`. Mirrors
|
||||
// the completion side's qualifier rule.
|
||||
let qualifier_binding = (role
|
||||
== "sql_expr_ident")
|
||||
.then(|| {
|
||||
bindings.iter().find(|b| {
|
||||
let q = b
|
||||
.alias
|
||||
.as_deref()
|
||||
.unwrap_or(b.table.as_str());
|
||||
q.eq_ignore_ascii_case(&item.text)
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
if let Some(binding) = qualifier_binding {
|
||||
let key = if binding.alias.is_some() {
|
||||
"diagnostic.alias_used_as_column"
|
||||
} else {
|
||||
"diagnostic.table_used_as_column"
|
||||
};
|
||||
diagnostics.push(Diagnostic {
|
||||
severity: Severity::Error,
|
||||
span: item.span,
|
||||
message: crate::friendly::translate(
|
||||
key,
|
||||
&[(
|
||||
"name",
|
||||
&item.text as &dyn std::fmt::Display,
|
||||
)],
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let table_arg = if bindings.len() == 1 {
|
||||
bindings[0].table.clone()
|
||||
} else {
|
||||
@@ -2545,6 +2612,11 @@ struct HintWalkSnapshot {
|
||||
/// The grammar-declared `HintMode` at the cursor's slot
|
||||
/// (`Node::Hinted` annotation, ADR-0024 §HintMode-per-node).
|
||||
pending_hint_mode: Option<crate::dsl::grammar::HintMode>,
|
||||
/// An `IntroProse` catalog key for an *optional* positional slot at
|
||||
/// the cursor that produced no candidate (issue #26 — `seed <table>`
|
||||
/// row count). Survives the trailing optional siblings that clear
|
||||
/// `pending_hint_mode`; already filtered to the cursor position.
|
||||
surviving_intro_hint: Option<&'static str>,
|
||||
current_table_columns: Option<Vec<crate::completion::TableColumn>>,
|
||||
/// `Some` when the input used Form A's explicit column list.
|
||||
/// `None` for Form B (`insert into T values …`) and for
|
||||
@@ -2571,6 +2643,7 @@ fn expected_for_hint_snapshot(
|
||||
pending_value_type: None,
|
||||
pending_value_column: None,
|
||||
pending_hint_mode: None,
|
||||
surviving_intro_hint: None,
|
||||
current_table_columns: None,
|
||||
user_listed_columns: None,
|
||||
};
|
||||
@@ -2598,6 +2671,14 @@ fn expected_for_hint_snapshot(
|
||||
pending_value_type: ctx.pending_value_type,
|
||||
pending_value_column: ctx.pending_value_column,
|
||||
pending_hint_mode: ctx.pending_hint_mode,
|
||||
// Issue #26: only surface the skipped-optional hint when the
|
||||
// optional was skipped *at the cursor* (the end of the walked
|
||||
// slice). Captured earlier (before a later clause consumed past
|
||||
// it) → stale, so drop it.
|
||||
surviving_intro_hint: ctx
|
||||
.surviving_intro_hint
|
||||
.filter(|(_, pos)| *pos == source.len())
|
||||
.map(|(key, _)| key),
|
||||
current_table_columns: ctx.current_table_columns,
|
||||
user_listed_columns: ctx.user_listed_columns,
|
||||
}
|
||||
@@ -6330,6 +6411,121 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Issue #31 — bare table alias / table used as a column ----
|
||||
|
||||
#[test]
|
||||
fn bare_table_alias_in_group_by_is_alias_hint_not_unknown_column() {
|
||||
// Issue #31: `… GROUP BY o` where `o` aliases a FROM source.
|
||||
// The learner means `o.<column>`; the diagnostic must point
|
||||
// at the qualified form, NOT call `o` an unknown column.
|
||||
let schema = two_table_schema();
|
||||
let diags = diag_keys(
|
||||
"select a.id from a o join b on a.id = b.id group by o",
|
||||
&schema,
|
||||
);
|
||||
assert!(
|
||||
diags
|
||||
.iter()
|
||||
.any(|d| d.contains("`o` is a table alias") && d.contains("o.<column>")),
|
||||
"expected alias_used_as_column hint; got {diags:?}",
|
||||
);
|
||||
assert!(
|
||||
!diags.iter().any(|d| d.contains("no such column")),
|
||||
"unknown_column must not fire for an in-scope alias; got {diags:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_table_alias_in_projection_is_alias_hint() {
|
||||
// The same applies outside GROUP BY — a bare alias in the
|
||||
// projection (`SELECT o …`) is equally not a column.
|
||||
let schema = two_table_schema();
|
||||
let diags =
|
||||
diag_keys("select o from a o join b on a.id = b.id", &schema);
|
||||
assert!(
|
||||
diags.iter().any(|d| d.contains("`o` is a table alias")),
|
||||
"expected alias_used_as_column hint in projection; got {diags:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_unaliased_table_used_as_column_is_table_hint() {
|
||||
// An un-aliased FROM source referenced bare gets the
|
||||
// table-form hint (qualify with the table name).
|
||||
let schema = two_table_schema();
|
||||
let diags = diag_keys("select id from a group by a", &schema);
|
||||
assert!(
|
||||
diags
|
||||
.iter()
|
||||
.any(|d| d.contains("`a` is a table") && d.contains("a.<column>")),
|
||||
"expected table_used_as_column hint; got {diags:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genuine_unknown_column_still_reports_no_such_column() {
|
||||
// Regression guard: the alias branch must not swallow a
|
||||
// genuine typo. `nope` matches no alias, no table, no column.
|
||||
let schema = two_table_schema();
|
||||
let diags = diag_keys(
|
||||
"select a.id from a o join b on a.id = b.id group by nope",
|
||||
&schema,
|
||||
);
|
||||
assert!(
|
||||
diags.iter().any(|d| d.contains("no such column") && d.contains("nope")),
|
||||
"a genuine unknown column must still report no such column; got {diags:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aliased_table_referenced_by_real_name_is_not_table_hint() {
|
||||
// DA guard (issue #31): a source aliased as `x` must be
|
||||
// referenced by the alias — `FROM a x … GROUP BY a` is invalid
|
||||
// SQL, so we must NOT advise `a.<column>`. The branch matches
|
||||
// the *effective qualifier* (the alias when present), so `a`
|
||||
// (the now-shadowed table name) falls through to the generic
|
||||
// unknown_column rather than wrong qualified-form advice.
|
||||
let schema = two_table_schema();
|
||||
let diags = diag_keys(
|
||||
"select x.id from a x join b on x.id = b.id group by a",
|
||||
&schema,
|
||||
);
|
||||
assert!(
|
||||
diags.iter().any(|d| d.contains("no such column") && d.contains("`a`")),
|
||||
"an aliased table referenced by its real name must fall through to \
|
||||
unknown_column; got {diags:?}",
|
||||
);
|
||||
assert!(
|
||||
!diags.iter().any(|d| d.contains("is a table")),
|
||||
"must not advise `a.<column>` when `a` is aliased as `x`; got {diags:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dsl_bare_table_name_in_where_keeps_unknown_column() {
|
||||
// DA guard (issue #31): the alias/table hint is SQL-only
|
||||
// (role `sql_expr_ident`). The DSL `Expr` (role `expr_column`)
|
||||
// has no `table.column` syntax, so advising the qualified form
|
||||
// would be wrong. A DSL bare table-name ref stays the generic
|
||||
// unknown_column it was before issue #31.
|
||||
let schema =
|
||||
schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]);
|
||||
for input in [
|
||||
"show data Customers where Customers = 5",
|
||||
"update Customers set Name = 'x' where Customers = 5",
|
||||
] {
|
||||
let diags = diag_keys_simple(input, &schema);
|
||||
assert!(
|
||||
diags.iter().any(|d| d.contains("no such column")),
|
||||
"DSL bare table ref must stay unknown_column for {input:?}; got {diags:?}",
|
||||
);
|
||||
assert!(
|
||||
!diags.iter().any(|d| d.contains("is a table")),
|
||||
"DSL must not get SQL qualified-form advice for {input:?}; got {diags:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ADR-0032 §11.2 — compound_arity_mismatch ----
|
||||
|
||||
#[test]
|
||||
@@ -6714,6 +6910,7 @@ mod dispatch_3a_tests {
|
||||
shape: Node::Word(Word::keyword("dsltail")),
|
||||
ast_builder: dsl_builder,
|
||||
help_id: None,
|
||||
hint_ids: &[],
|
||||
usage_ids: &[],
|
||||
};
|
||||
static SMOKE_SQL: CommandNode = CommandNode {
|
||||
@@ -6721,6 +6918,7 @@ mod dispatch_3a_tests {
|
||||
shape: Node::Word(Word::keyword("sqltail")),
|
||||
ast_builder: sql_builder,
|
||||
help_id: None,
|
||||
hint_ids: &[],
|
||||
usage_ids: &[],
|
||||
};
|
||||
|
||||
|
||||
@@ -161,6 +161,11 @@ pub enum AppEvent {
|
||||
/// commands, so an execution failure would otherwise be
|
||||
/// lost across sessions.
|
||||
source: String,
|
||||
/// Whether the rejected command was submitted in an advanced
|
||||
/// effective mode (ADR-0052): threaded so the App can tag the
|
||||
/// `err` record `err:adv` and the failed advanced command
|
||||
/// hydrates in its `:`-prefixed, simple-mode-recallable form.
|
||||
advanced: bool,
|
||||
},
|
||||
/// Refreshed list of tables in the database.
|
||||
TablesRefreshed(Vec<String>),
|
||||
|
||||
+196
-5
@@ -38,6 +38,7 @@
|
||||
/// `(key, expected_placeholders)`. Sorted by key for grep-ability.
|
||||
pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
// ---- Pre-submit diagnostics (ADR-0027) ----
|
||||
("diagnostic.alias_used_as_column", &["name"]),
|
||||
("diagnostic.ambiguous_column", &["column", "qualifiers"]),
|
||||
("diagnostic.auto_column_overridden", &["column", "type"]),
|
||||
("diagnostic.compound_arity_mismatch", &["op", "left_n", "right_n"]),
|
||||
@@ -63,6 +64,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("diagnostic.not_null_missing", &["column"]),
|
||||
("diagnostic.like_numeric", &["column", "type"]),
|
||||
("diagnostic.projection_alias_misplaced", &["alias", "clause"]),
|
||||
("diagnostic.table_used_as_column", &["name"]),
|
||||
("diagnostic.type_mismatch", &["column", "type"]),
|
||||
("diagnostic.unknown_column", &["name", "table"]),
|
||||
("diagnostic.unknown_qualifier", &["qualifier"]),
|
||||
@@ -178,6 +180,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("help.unknown_topic", &["topic"]),
|
||||
("help.app.quit", &[]),
|
||||
("help.app.help", &[]),
|
||||
("help.app.hint", &[]),
|
||||
("help.app.rebuild", &[]),
|
||||
("help.app.save", &[]),
|
||||
("help.app.new", &[]),
|
||||
@@ -220,6 +223,183 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
&["message", "usage"],
|
||||
),
|
||||
("hint.ambient_expected", &["expected"]),
|
||||
("hint.getting_started", &[]),
|
||||
("hint.block.heading", &[]),
|
||||
("hint.block.what", &[]),
|
||||
("hint.block.example", &[]),
|
||||
("hint.block.concept", &[]),
|
||||
// Tier-3 teaching blocks (ADR-0053 D3) — Phase-B exemplars.
|
||||
("hint.cmd.insert.what", &[]),
|
||||
("hint.cmd.insert.example", &[]),
|
||||
("hint.cmd.insert.concept", &[]),
|
||||
("hint.cmd.add_relationship.what", &[]),
|
||||
("hint.cmd.add_relationship.example", &[]),
|
||||
("hint.cmd.add_relationship.concept", &[]),
|
||||
("hint.err.foreign_key.child_side.what", &[]),
|
||||
("hint.err.foreign_key.child_side.example", &[]),
|
||||
("hint.err.foreign_key.child_side.concept", &[]),
|
||||
// Phase C batch 5 — runtime error-class hints.
|
||||
("hint.err.foreign_key.parent_side.what", &[]),
|
||||
("hint.err.foreign_key.parent_side.example", &[]),
|
||||
("hint.err.foreign_key.parent_side.concept", &[]),
|
||||
("hint.err.unique.what", &[]),
|
||||
("hint.err.unique.example", &[]),
|
||||
("hint.err.unique.concept", &[]),
|
||||
("hint.err.not_null.what", &[]),
|
||||
("hint.err.not_null.example", &[]),
|
||||
("hint.err.not_null.concept", &[]),
|
||||
("hint.err.check.what", &[]),
|
||||
("hint.err.check.example", &[]),
|
||||
("hint.err.check.concept", &[]),
|
||||
("hint.err.type_mismatch.what", &[]),
|
||||
("hint.err.type_mismatch.example", &[]),
|
||||
("hint.err.type_mismatch.concept", &[]),
|
||||
("hint.err.not_found.what", &[]),
|
||||
("hint.err.not_found.example", &[]),
|
||||
("hint.err.not_found.concept", &[]),
|
||||
("hint.err.already_exists.what", &[]),
|
||||
("hint.err.already_exists.example", &[]),
|
||||
("hint.err.already_exists.concept", &[]),
|
||||
("hint.err.generic.what", &[]),
|
||||
("hint.err.generic.example", &[]),
|
||||
("hint.err.invalid_value.what", &[]),
|
||||
("hint.err.invalid_value.example", &[]),
|
||||
// Phase C batch 1 — app-lifecycle command hints.
|
||||
("hint.cmd.quit.what", &[]),
|
||||
("hint.cmd.quit.example", &[]),
|
||||
("hint.cmd.help.what", &[]),
|
||||
("hint.cmd.help.example", &[]),
|
||||
("hint.cmd.help.concept", &[]),
|
||||
("hint.cmd.hint.what", &[]),
|
||||
("hint.cmd.hint.example", &[]),
|
||||
("hint.cmd.rebuild.what", &[]),
|
||||
("hint.cmd.rebuild.example", &[]),
|
||||
("hint.cmd.rebuild.concept", &[]),
|
||||
("hint.cmd.save.what", &[]),
|
||||
("hint.cmd.save.example", &[]),
|
||||
("hint.cmd.new.what", &[]),
|
||||
("hint.cmd.new.example", &[]),
|
||||
("hint.cmd.load.what", &[]),
|
||||
("hint.cmd.load.example", &[]),
|
||||
("hint.cmd.export.what", &[]),
|
||||
("hint.cmd.export.example", &[]),
|
||||
("hint.cmd.export.concept", &[]),
|
||||
("hint.cmd.import.what", &[]),
|
||||
("hint.cmd.import.example", &[]),
|
||||
("hint.cmd.mode.what", &[]),
|
||||
("hint.cmd.mode.example", &[]),
|
||||
("hint.cmd.mode.concept", &[]),
|
||||
("hint.cmd.messages.what", &[]),
|
||||
("hint.cmd.messages.example", &[]),
|
||||
("hint.cmd.messages.concept", &[]),
|
||||
("hint.cmd.undo.what", &[]),
|
||||
("hint.cmd.undo.example", &[]),
|
||||
("hint.cmd.undo.concept", &[]),
|
||||
("hint.cmd.redo.what", &[]),
|
||||
("hint.cmd.redo.example", &[]),
|
||||
("hint.cmd.copy.what", &[]),
|
||||
("hint.cmd.copy.example", &[]),
|
||||
// Phase C batch 2 — DDL command hints.
|
||||
("hint.cmd.create_table.what", &[]),
|
||||
("hint.cmd.create_table.example", &[]),
|
||||
("hint.cmd.create_table.concept", &[]),
|
||||
("hint.cmd.create_m2n.what", &[]),
|
||||
("hint.cmd.create_m2n.example", &[]),
|
||||
("hint.cmd.create_m2n.concept", &[]),
|
||||
("hint.cmd.add_column.what", &[]),
|
||||
("hint.cmd.add_column.example", &[]),
|
||||
("hint.cmd.add_column.concept", &[]),
|
||||
("hint.cmd.add_index.what", &[]),
|
||||
("hint.cmd.add_index.example", &[]),
|
||||
("hint.cmd.add_index.concept", &[]),
|
||||
("hint.cmd.add_constraint.what", &[]),
|
||||
("hint.cmd.add_constraint.example", &[]),
|
||||
("hint.cmd.add_constraint.concept", &[]),
|
||||
("hint.cmd.drop_table.what", &[]),
|
||||
("hint.cmd.drop_table.example", &[]),
|
||||
("hint.cmd.drop_table.concept", &[]),
|
||||
("hint.cmd.drop_column.what", &[]),
|
||||
("hint.cmd.drop_column.example", &[]),
|
||||
("hint.cmd.drop_column.concept", &[]),
|
||||
("hint.cmd.drop_relationship.what", &[]),
|
||||
("hint.cmd.drop_relationship.example", &[]),
|
||||
("hint.cmd.drop_relationship.concept", &[]),
|
||||
("hint.cmd.drop_index.what", &[]),
|
||||
("hint.cmd.drop_index.example", &[]),
|
||||
("hint.cmd.drop_index.concept", &[]),
|
||||
("hint.cmd.drop_constraint.what", &[]),
|
||||
("hint.cmd.drop_constraint.example", &[]),
|
||||
("hint.cmd.drop_constraint.concept", &[]),
|
||||
("hint.cmd.rename_column.what", &[]),
|
||||
("hint.cmd.rename_column.example", &[]),
|
||||
("hint.cmd.rename_column.concept", &[]),
|
||||
("hint.cmd.change_column.what", &[]),
|
||||
("hint.cmd.change_column.example", &[]),
|
||||
("hint.cmd.change_column.concept", &[]),
|
||||
// Phase C batch 3 — DML command hints.
|
||||
("hint.cmd.update.what", &[]),
|
||||
("hint.cmd.update.example", &[]),
|
||||
("hint.cmd.update.concept", &[]),
|
||||
("hint.cmd.delete.what", &[]),
|
||||
("hint.cmd.delete.example", &[]),
|
||||
("hint.cmd.delete.concept", &[]),
|
||||
("hint.cmd.show_data.what", &[]),
|
||||
("hint.cmd.show_data.example", &[]),
|
||||
("hint.cmd.show_data.concept", &[]),
|
||||
("hint.cmd.show_table.what", &[]),
|
||||
("hint.cmd.show_table.example", &[]),
|
||||
("hint.cmd.show_table.concept", &[]),
|
||||
("hint.cmd.show_tables.what", &[]),
|
||||
("hint.cmd.show_tables.example", &[]),
|
||||
("hint.cmd.show_relationships.what", &[]),
|
||||
("hint.cmd.show_relationships.example", &[]),
|
||||
("hint.cmd.show_relationships.concept", &[]),
|
||||
("hint.cmd.show_indexes.what", &[]),
|
||||
("hint.cmd.show_indexes.example", &[]),
|
||||
("hint.cmd.show_indexes.concept", &[]),
|
||||
("hint.cmd.seed.what", &[]),
|
||||
("hint.cmd.seed.example", &[]),
|
||||
("hint.cmd.seed.concept", &[]),
|
||||
("hint.cmd.explain.what", &[]),
|
||||
("hint.cmd.explain.example", &[]),
|
||||
("hint.cmd.explain.concept", &[]),
|
||||
("hint.cmd.replay.what", &[]),
|
||||
("hint.cmd.replay.example", &[]),
|
||||
("hint.cmd.replay.concept", &[]),
|
||||
// Phase C batch 4 — advanced-mode SQL command hints.
|
||||
("hint.cmd.sql_create_table.what", &[]),
|
||||
("hint.cmd.sql_create_table.example", &[]),
|
||||
("hint.cmd.sql_create_table.concept", &[]),
|
||||
("hint.cmd.sql_alter_table.what", &[]),
|
||||
("hint.cmd.sql_alter_table.example", &[]),
|
||||
("hint.cmd.sql_alter_table.concept", &[]),
|
||||
("hint.cmd.sql_create_index.what", &[]),
|
||||
("hint.cmd.sql_create_index.example", &[]),
|
||||
("hint.cmd.sql_create_index.concept", &[]),
|
||||
("hint.cmd.sql_drop_index.what", &[]),
|
||||
("hint.cmd.sql_drop_index.example", &[]),
|
||||
("hint.cmd.sql_drop_index.concept", &[]),
|
||||
("hint.cmd.sql_drop_table.what", &[]),
|
||||
("hint.cmd.sql_drop_table.example", &[]),
|
||||
("hint.cmd.sql_drop_table.concept", &[]),
|
||||
("hint.cmd.sql_insert.what", &[]),
|
||||
("hint.cmd.sql_insert.example", &[]),
|
||||
("hint.cmd.sql_insert.concept", &[]),
|
||||
("hint.cmd.sql_update.what", &[]),
|
||||
("hint.cmd.sql_update.example", &[]),
|
||||
("hint.cmd.sql_update.concept", &[]),
|
||||
("hint.cmd.sql_delete.what", &[]),
|
||||
("hint.cmd.sql_delete.example", &[]),
|
||||
("hint.cmd.sql_delete.concept", &[]),
|
||||
("hint.cmd.select.what", &[]),
|
||||
("hint.cmd.select.example", &[]),
|
||||
("hint.cmd.select.concept", &[]),
|
||||
("hint.cmd.with.what", &[]),
|
||||
("hint.cmd.with.example", &[]),
|
||||
("hint.cmd.with.concept", &[]),
|
||||
("hint.cmd.explain_sql.what", &[]),
|
||||
("hint.cmd.explain_sql.example", &[]),
|
||||
("hint.cmd.explain_sql.concept", &[]),
|
||||
(
|
||||
"hint.ambient_invalid_ident",
|
||||
&["kind", "found"],
|
||||
@@ -229,6 +409,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
// slot (`create table T (`) so the otherwise-invisible
|
||||
// column-name role reads as the dominant first move.
|
||||
("hint.create_table_element", &[]),
|
||||
("hint.seed_count", &[]),
|
||||
("hint.value_literal_slot", &[]),
|
||||
(
|
||||
"hint.ambient_typing_name_then",
|
||||
@@ -296,6 +477,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("parse.usage.rename_column", &[]),
|
||||
("parse.usage.export", &[]),
|
||||
("parse.usage.help", &[]),
|
||||
("parse.usage.hint", &[]),
|
||||
("parse.usage.import", &[]),
|
||||
("parse.usage.copy", &[]),
|
||||
("parse.usage.load", &[]),
|
||||
@@ -443,6 +625,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("undo.redo_failed", &["error"]),
|
||||
// ---- Status bar + panels ----
|
||||
("panel.hint_empty", &[]),
|
||||
("panel.hint_mode_advanced", &[]),
|
||||
("panel.hint_title", &[]),
|
||||
("panel.output_title", &[]),
|
||||
("panel.relationships_empty", &[]),
|
||||
@@ -459,18 +642,26 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
|
||||
("save.title_as", &[]),
|
||||
("save.title_save", &[]),
|
||||
// ---- Shortcut hint labels ----
|
||||
("shortcut.advanced_once", &[]),
|
||||
("shortcut.back_to_list", &[]),
|
||||
("shortcut.browse", &[]),
|
||||
("shortcut.browse_path", &[]),
|
||||
("shortcut.cancel", &[]),
|
||||
("shortcut.cancel_one_shot", &[]),
|
||||
("shortcut.clear", &[]),
|
||||
("shortcut.complete", &[]),
|
||||
("shortcut.confirm", &[]),
|
||||
("shortcut.cycle", &[]),
|
||||
("shortcut.del_word", &[]),
|
||||
("shortcut.hint", &[]),
|
||||
("shortcut.history", &[]),
|
||||
("shortcut.home_end", &[]),
|
||||
("shortcut.load", &[]),
|
||||
("shortcut.nav", &[]),
|
||||
("shortcut.next_pane", &[]),
|
||||
("shortcut.no", &[]),
|
||||
("shortcut.quit", &[]),
|
||||
("shortcut.run", &[]),
|
||||
("shortcut.scroll", &[]),
|
||||
("shortcut.select", &[]),
|
||||
("shortcut.submit", &[]),
|
||||
("shortcut.switch", &[]),
|
||||
("shortcut.to_input", &[]),
|
||||
("shortcut.yes", &[]),
|
||||
// ---- mode / messages banners ----
|
||||
("messages.set_short", &[]),
|
||||
|
||||
+1
-1
@@ -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,259 @@ hint:
|
||||
ambient_complete: "Submit with Enter"
|
||||
ambient_expected: "Next: {expected}"
|
||||
ambient_error_with_usage: "{message} — usage: {usage}"
|
||||
# H2 / ADR-0053: shown by `hint` / F1 when there is nothing specific
|
||||
# to expand on (no recent error, empty input).
|
||||
getting_started: "Start typing a command and press F1 for a hint, or type `help` for the full command list."
|
||||
# Tier-3 block scaffolding (ADR-0053 D4): the heading + the labels the
|
||||
# `what` / `example` / `concept` parts render under.
|
||||
block:
|
||||
heading: "Hint"
|
||||
what: "What"
|
||||
example: "Example"
|
||||
concept: "Concept"
|
||||
# ── Tier-3 teaching blocks (ADR-0053 D3) ──────────────────────────
|
||||
# Per-form command hints (`hint.cmd.<form>`) and per-class error
|
||||
# hints (`hint.err.<class>`), each a `what` (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."
|
||||
# DML — querying and changing data (Phase C batch 3).
|
||||
update:
|
||||
what: "Change values in the rows that match a condition."
|
||||
example: "update Customers set email = 'new@example.io' where id = 1"
|
||||
concept: "The `where` clause picks which rows change, and it's required — pass `--all-rows` to change the whole table on purpose — so you never update more than you meant to."
|
||||
delete:
|
||||
what: "Remove the rows that match a condition."
|
||||
example: "delete from Orders where status = 'cancelled'"
|
||||
concept: "A `where` is required (use `--all-rows` to clear the table on purpose). Rows a relationship points at may be blocked or cascade-deleted, per its `on delete` action."
|
||||
show_data:
|
||||
what: "Show the rows stored in a table."
|
||||
example: "show data Customers"
|
||||
concept: "This reads the data and never changes it. Add a `where` to show only matching rows."
|
||||
show_table:
|
||||
what: "Show a table's structure — its columns, types, keys, and relationships."
|
||||
example: "show table Customers"
|
||||
concept: "Structure, not data: the column definitions and how this table links to others. Use `show data` to see the rows themselves."
|
||||
show_tables:
|
||||
what: "List all the tables in the project."
|
||||
example: "show tables"
|
||||
show_relationships:
|
||||
what: "List all the relationships between tables."
|
||||
example: "show relationships"
|
||||
concept: "Each relationship is a foreign-key link from a child column to a parent's key, with an `on delete` / `on update` rule."
|
||||
show_indexes:
|
||||
what: "List all the indexes in the project."
|
||||
example: "show indexes"
|
||||
concept: "Indexes speed up lookups; this shows which columns each one covers and whether it enforces uniqueness."
|
||||
seed:
|
||||
what: "Fill a table with generated sample rows, or fill one column on existing rows."
|
||||
example: "seed Customers 50"
|
||||
concept: "Seeding invents realistic-looking data so you have something to query. Pin a value with `set col = …`, choose a generator with `as`, or give a numeric range with `between`."
|
||||
explain:
|
||||
what: "Show how the database will run a query — without running it."
|
||||
example: "explain show data Customers where email = 'a@example.io'"
|
||||
concept: "The plan reveals whether the database scans the whole table or jumps straight to rows through an index — the payoff of `add index`. `explain` never executes, so it's safe even on a delete."
|
||||
replay:
|
||||
what: "Re-run the commands recorded in a history file."
|
||||
example: "replay session.log"
|
||||
concept: "Every successful command is journalled, so replaying re-applies them in order to reproduce a project's state — handy for scripting or redoing a sequence."
|
||||
# Advanced-mode SQL forms (Phase C batch 4). Examples are SQL, the
|
||||
# advanced surface — distinct from their simple-mode siblings.
|
||||
sql_create_table:
|
||||
what: "Create a table using SQL syntax (advanced mode)."
|
||||
example: "create table Customers (id int primary key, name text, email text)"
|
||||
concept: "Advanced mode speaks SQL: constraints go inline (`primary key`, `not null`, `unique`, `check`). This is the raw form of simple mode's `create table … with pk …`."
|
||||
sql_alter_table:
|
||||
what: "Change a table's structure with SQL `alter table` (advanced mode)."
|
||||
example: "alter table Customers add column phone text"
|
||||
concept: "`alter table` adds or drops columns, renames, and adds constraints — the SQL equivalent of simple mode's `add column` / `drop column` / `change column`."
|
||||
sql_create_index:
|
||||
what: "Create an index with SQL (advanced mode)."
|
||||
example: "create index ix_email on Customers (email)"
|
||||
concept: "Add `unique` to also forbid duplicate values. The simple-mode equivalent is `add index`."
|
||||
sql_drop_index:
|
||||
what: "Remove an index with SQL (advanced mode)."
|
||||
example: "drop index ix_email"
|
||||
concept: "Only the lookup shortcut goes; the data is untouched. Add `if exists` to ignore a missing index."
|
||||
sql_drop_table:
|
||||
what: "Remove a table with SQL (advanced mode)."
|
||||
example: "drop table Customers"
|
||||
concept: "Add `if exists` to avoid an error when the table might not be there. Relationships pointing at it may block the drop."
|
||||
sql_insert:
|
||||
what: "Insert rows with SQL (advanced mode)."
|
||||
example: "insert into Customers (name, email) values ('Ann', 'ann@example.io')"
|
||||
concept: "Naming the columns lets you supply them in any order and skip ones that have a default — the SQL form of simple mode's `insert`."
|
||||
sql_update:
|
||||
what: "Update rows with SQL (advanced mode)."
|
||||
example: "update Customers set email = 'new@example.io' where id = 1"
|
||||
concept: "`set` lists the new values; `where` picks which rows change. The SQL form of simple mode's `update`."
|
||||
sql_delete:
|
||||
what: "Delete rows with SQL (advanced mode)."
|
||||
example: "delete from Orders where status = 'cancelled'"
|
||||
concept: "`where` picks the rows to remove; foreign-key rules still apply. The SQL form of simple mode's `delete`."
|
||||
select:
|
||||
what: "Query rows with SQL `select` (advanced mode)."
|
||||
example: "select name, email from Customers where id = 1"
|
||||
concept: "`select` is read-only: choose columns (or `*`), filter with `where`, sort with `order by`, cap with `limit`. This is the heart of SQL — and the reason advanced mode exists."
|
||||
with:
|
||||
what: "Name a sub-query (a CTE) and read from it in a `select` (advanced mode)."
|
||||
example: "with recent as (select * from Orders where id > 100) select * from recent"
|
||||
concept: "A `with` clause (Common Table Expression) names a query so the main `select` can use it like a temporary table — handy for breaking a complex query into readable steps."
|
||||
explain_sql:
|
||||
what: "Show how the database will run a SQL query, without running it (advanced mode)."
|
||||
example: "explain select * from Customers where email = 'a@example.io'"
|
||||
concept: "Like simple mode's `explain`, but wraps a raw SQL statement. It reveals whether an index is used, and never executes."
|
||||
err:
|
||||
# Runtime error classes (Phase C batch 5), keyed by
|
||||
# friendly::error_hint_class. `example` is a fix recipe rather than a
|
||||
# runnable line; `concept` is the relational idea behind the rule.
|
||||
foreign_key:
|
||||
child_side:
|
||||
what: "The value you gave for the child column doesn't match any parent row, so the foreign key has nothing to point at."
|
||||
example: "First insert the parent (insert into Customers …), then the child that references it."
|
||||
concept: "A foreign key is a promise that every child points at a real parent, so the parent must exist first. To allow orphans on delete instead, set the relationship's `on delete` to `set null` or `cascade`."
|
||||
parent_side:
|
||||
what: "You're deleting or changing a row that other rows point at, which would orphan those children."
|
||||
example: "Delete the child rows first, or set the relationship's `on delete` to `cascade` (remove them too) or `set null` (keep them, unlinked)."
|
||||
concept: "A foreign key guarantees every child has a real parent, so the database won't remove a parent out from under its children unless the relationship says what should happen to them."
|
||||
unique:
|
||||
what: "A value you're inserting — or updating to — already exists in a column that must be unique."
|
||||
example: "Pick a different value, or update the existing row instead of inserting a new one."
|
||||
concept: "A unique constraint (and every primary key) forbids duplicates, so each value identifies at most one row."
|
||||
not_null:
|
||||
what: "You left a column empty that is required to have a value."
|
||||
example: "Supply a value for the column, or give it a default so new rows fill it automatically."
|
||||
concept: "A `not null` constraint means every row must have a value there — it's how you mark a fact as mandatory."
|
||||
check:
|
||||
what: "A value broke a `check` rule defined on the column."
|
||||
example: "Use a value the rule allows — for example a positive number, or one of the permitted options."
|
||||
concept: "A `check` constraint is a condition every row must satisfy, so the database enforces business rules like \"price ≥ 0\" for you."
|
||||
type_mismatch:
|
||||
what: "A value doesn't fit the column's type — for instance text where a number is expected."
|
||||
example: "Give a value of the right type: a number for `int`/`real`, a quoted string for `text`, true/false for `bool`."
|
||||
concept: "Every column has a type, and the database rejects values that don't fit, so a column's data stays consistent and comparable."
|
||||
not_found:
|
||||
what: "You named a table or column that doesn't exist."
|
||||
example: "Check the spelling, or run `show tables` (or `show table <name>`) to see what's there."
|
||||
concept: "A command can only refer to tables and columns that already exist — create them first if you need them."
|
||||
already_exists:
|
||||
what: "You tried to create a table, column, relationship, or index whose name is already taken."
|
||||
example: "Pick a different name, or drop the existing one first if you meant to replace it."
|
||||
concept: "Names must be unique within their kind so a command is never ambiguous about what it refers to."
|
||||
generic:
|
||||
what: "The database refused the command for the reason shown above."
|
||||
example: "Read that message for the specifics, adjust the command, and try again."
|
||||
invalid_value:
|
||||
what: "A value or option in the command wasn't valid for where it was used."
|
||||
example: "Check the value against the column's type and the command's accepted options."
|
||||
# Invalid identifier in a schema slot (ADR-0022 stage 8e
|
||||
# + the user's #5). Voice mirrors ADR-0019's "no such
|
||||
# {kind}" wording for consistency with engine errors.
|
||||
@@ -400,6 +655,12 @@ hint:
|
||||
# at `create table T (` so the column-name role is visible
|
||||
# alongside the table-level constraint keywords.
|
||||
create_table_element: "Type a column name, or a table-level constraint: `primary`, `unique`, `check`, `constraint`, `foreign`"
|
||||
# Issue #26: the `seed <table> ▮` position. The optional row count is
|
||||
# a bare number with no Tab candidate, so it (and the `.column`
|
||||
# column-fill form) would be invisible next to the `set` / `--seed`
|
||||
# chips. Names every option so the most common next move (a count) is
|
||||
# discoverable.
|
||||
seed_count: "Optionally a row count, e.g. `50` (default 20); `.column` to fill one column on existing rows; `set` to pin a column; `--seed` to fix the RNG"
|
||||
# Value-literal slot — `insert ... values (`, `update ... set
|
||||
# col=`, `where col=`. Replaces the misleading "null true
|
||||
# false" keyword candidate list with format guidance for all
|
||||
@@ -611,6 +872,7 @@ parse:
|
||||
# description.
|
||||
quit: "quit"
|
||||
help: "help [<command>]"
|
||||
hint: "hint"
|
||||
rebuild: "rebuild"
|
||||
save: "save | save as"
|
||||
new: "new"
|
||||
@@ -640,6 +902,12 @@ diagnostic:
|
||||
unknown_qualifier: "no such table or alias in scope: `{qualifier}`"
|
||||
ambiguous_column: "`{column}` is ambiguous — appears in {qualifiers}"
|
||||
projection_alias_misplaced: "alias `{alias}` cannot be used in {clause} — aliases are not bound until after `SELECT`'s projection list"
|
||||
# Issue #31: a bare table alias / table name used where the grammar
|
||||
# expects a column (e.g. `GROUP BY o`). The name *is* in scope — it
|
||||
# is the alias of a FROM source — so calling it an "unknown column"
|
||||
# misleads. Point the learner at the qualified `alias.column` form.
|
||||
alias_used_as_column: "`{name}` is a table alias — write `{name}.<column>` to reference one of its columns"
|
||||
table_used_as_column: "`{name}` is a table — write `{name}.<column>` to reference one of its columns"
|
||||
cte_arity_mismatch: "CTE `{cte}` declares {declared} columns but its body has {actual}"
|
||||
compound_arity_mismatch: "`{op}` requires both sides to have the same number of columns — left has {left_n}, right has {right_n}"
|
||||
duplicate_cte: "duplicate `WITH` table name: `{name}`"
|
||||
@@ -871,14 +1139,21 @@ panel:
|
||||
relationships_title: "Relationships"
|
||||
relationships_empty: "(none)"
|
||||
hint_empty: "Type a command — press Tab for options, `help` for a list"
|
||||
# Mode-discovery pointer appended to the empty-input hint in SIMPLE
|
||||
# mode (ADR-0051): the `mode advanced` switch left the keybinding
|
||||
# strip, so the hint advertises it. Leading separator continues the
|
||||
# prompt line. Advanced mode shows no pointer — users know how they
|
||||
# got there, and `help` covers the way back.
|
||||
hint_mode_advanced: " · `mode advanced` for SQL"
|
||||
# Panel titles for the output and hint panels (rendered inside
|
||||
# the rounded border, hence the leading/trailing space).
|
||||
output_title: "Output"
|
||||
hint_title: "Hint"
|
||||
|
||||
# ---- Shortcut hints (paired with key names in the bottom bar) -------
|
||||
# The bottom strip is keystrokes-only and state-aware (ADR-0051). Labels
|
||||
# pair with a key name in the renderer (e.g. `Enter` + `run`).
|
||||
shortcut:
|
||||
submit: "submit"
|
||||
confirm: "confirm"
|
||||
cancel: "cancel"
|
||||
yes: "Yes"
|
||||
@@ -887,10 +1162,20 @@ shortcut:
|
||||
select: "select"
|
||||
browse_path: "browse path"
|
||||
back_to_list: "back to list"
|
||||
switch: "switch"
|
||||
advanced_once: "advanced once"
|
||||
cancel_one_shot: "cancel one-shot"
|
||||
quit: "quit"
|
||||
# Status-strip labels (ADR-0051, issue #27).
|
||||
run: "run"
|
||||
nav: "sidebar"
|
||||
next_pane: "next pane"
|
||||
scroll: "scroll"
|
||||
to_input: "input"
|
||||
cycle: "cycle"
|
||||
browse: "browse"
|
||||
clear: "clear"
|
||||
complete: "complete"
|
||||
hint: "hint"
|
||||
history: "history"
|
||||
home_end: "home/end"
|
||||
del_word: "del word"
|
||||
|
||||
# ---- mode / messages banners (app-level commands) -------------------
|
||||
mode:
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1356,6 +1356,93 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_cache() -> crate::completion::SchemaCache {
|
||||
use crate::completion::TableColumn;
|
||||
use crate::dsl::types::Type;
|
||||
let mut cache = crate::completion::SchemaCache::default();
|
||||
cache.tables.push("users".to_string());
|
||||
cache.columns.push("email".to_string());
|
||||
cache
|
||||
.table_columns
|
||||
.insert("users".to_string(), vec![TableColumn::new("email", Type::Text)]);
|
||||
cache
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_count_is_advertised_at_the_optional_position() {
|
||||
// Issue #26: `seed users ▮` is a complete command, so the hint
|
||||
// ladder shows only the `set` / `--seed` continuation chips —
|
||||
// the optional row count (a bare number with no candidate) was
|
||||
// invisible. An IntroProse hint that survives the trailing
|
||||
// optionals now advertises it; Tab still cycles the keywords.
|
||||
let cache = seed_cache();
|
||||
let input = "seed users ";
|
||||
match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple) {
|
||||
Some(AmbientHint::Prose(p)) => {
|
||||
assert!(
|
||||
p.contains("row count") && p.contains("20"),
|
||||
"prose must mention the row count and the default; got: {p:?}",
|
||||
);
|
||||
assert!(
|
||||
p.contains("set") && p.contains("--seed") && p.contains(".column"),
|
||||
"prose should fold in the keyword + column-fill options; got: {p:?}",
|
||||
);
|
||||
}
|
||||
other => panic!("expected a Prose count hint; got: {other:?}"),
|
||||
}
|
||||
// Tab candidates remain available (completion is independent).
|
||||
let comp = crate::completion::candidates_at_cursor_in_mode(
|
||||
input, input.len(), &cache, Mode::Simple,
|
||||
)
|
||||
.expect("completion remains available");
|
||||
let texts: Vec<&str> = comp.candidates.iter().map(|c| c.text.as_str()).collect();
|
||||
assert!(
|
||||
texts.contains(&"set") && texts.contains(&"--seed"),
|
||||
"Tab must still cycle `set` / `--seed`; got {texts:?}",
|
||||
);
|
||||
|
||||
// `seed` runs in both modes (ADR-0048), so the hint must fire in
|
||||
// advanced mode too — not only simple.
|
||||
match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) {
|
||||
Some(AmbientHint::Prose(p)) => assert!(
|
||||
p.contains("row count"),
|
||||
"count hint must also fire in advanced mode; got: {p:?}",
|
||||
),
|
||||
other => panic!("expected the count hint in advanced mode; got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_count_hint_does_not_leak_once_the_count_or_a_clause_is_given() {
|
||||
// Position guard: the hint shows only while the cursor sits at
|
||||
// the count slot. Once the count is supplied — or a later clause
|
||||
// consumes input past it — it must not reappear.
|
||||
let cache = seed_cache();
|
||||
for input in ["seed users 50 ", "seed users set email = 'x' "] {
|
||||
let hint = ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple);
|
||||
let is_count_prose = matches!(
|
||||
&hint,
|
||||
Some(AmbientHint::Prose(p)) if p.contains("row count")
|
||||
);
|
||||
assert!(!is_count_prose, "count hint must not show for {input:?}; got {hint:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_count_hint_also_fires_after_a_column_fill_target() {
|
||||
// The count is valid after `seed users.email` too, so the hint
|
||||
// fires there — `.email` is a real column (no diagnostic).
|
||||
let cache = seed_cache();
|
||||
let input = "seed users.email ";
|
||||
match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Simple) {
|
||||
Some(AmbientHint::Prose(p)) => assert!(
|
||||
p.contains("row count"),
|
||||
"count hint expected after a column-fill target; got: {p:?}",
|
||||
),
|
||||
other => panic!("expected a Prose count hint; got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genuine_column_typo_in_complete_select_still_hints_via_diagnostic() {
|
||||
// Issue #6 trade-off lockdown: dropping the typing-time
|
||||
@@ -1765,6 +1852,65 @@ mod tests {
|
||||
cache
|
||||
}
|
||||
|
||||
fn issue31_join_cache() -> crate::completion::SchemaCache {
|
||||
use crate::completion::{SchemaCache, TableColumn};
|
||||
use crate::dsl::types::Type;
|
||||
let mut cache = SchemaCache::default();
|
||||
let tables: &[(&str, &[(&str, Type)])] = &[
|
||||
("Customers", &[("id", Type::Serial), ("name", Type::Text)]),
|
||||
(
|
||||
"Products",
|
||||
&[("id", Type::Serial), ("name", Type::Text), ("price", Type::Decimal)],
|
||||
),
|
||||
(
|
||||
"OrderLines",
|
||||
&[
|
||||
("id", Type::Serial),
|
||||
("order_id", Type::Int),
|
||||
("product_id", Type::Int),
|
||||
("count", Type::Int),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Orders",
|
||||
&[("id", Type::Serial), ("customer_id", Type::Int), ("date", Type::Date)],
|
||||
),
|
||||
];
|
||||
for (t, cols) in tables {
|
||||
cache.tables.push((*t).to_string());
|
||||
let tc: Vec<TableColumn> =
|
||||
cols.iter().map(|(n, ty)| TableColumn::new(*n, *ty)).collect();
|
||||
for c in &tc {
|
||||
cache.columns.push(c.name.clone());
|
||||
}
|
||||
cache.table_columns.insert((*t).to_string(), tc);
|
||||
}
|
||||
cache
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue31_group_by_partial_alias_shows_alias_hint() {
|
||||
// Issue #31 end-to-end: the manual-testing query ended in
|
||||
// `… group by o`, where `o` aliases `Orders`. The ambient
|
||||
// hint must guide the learner to `o.<column>`, not claim
|
||||
// `o` is an unknown column.
|
||||
let cache = issue31_join_cache();
|
||||
let input = "select c.name as customer_name, o.id as order_id, o.date, sum(ol.count*p.price) as total from Orders o join OrderLines ol on o.id=ol.order_id join Products p on p.id=ol.product_id join Customers c on c.id=o.customer_id group by o";
|
||||
match ambient_hint_in_mode(input, input.len(), None, &cache, Mode::Advanced) {
|
||||
Some(AmbientHint::Prose(p)) => {
|
||||
assert!(
|
||||
p.contains("`o` is a table alias") && p.contains("o.<column>"),
|
||||
"expected the alias hint; got: {p:?}",
|
||||
);
|
||||
assert!(
|
||||
!p.contains("no such column"),
|
||||
"must not show the misleading unknown-column message; got: {p:?}",
|
||||
);
|
||||
}
|
||||
other => panic!("expected a Prose alias hint; got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambient_hint_at_insert_first_value_shows_int_prose() {
|
||||
use crate::dsl::types::Type;
|
||||
|
||||
+34
-63
@@ -71,27 +71,22 @@ pub fn render_data_table(data: &DataResult) -> Vec<String> {
|
||||
render_table(&header_cells, &body, &alignments)
|
||||
}
|
||||
|
||||
/// Render a table-structure listing.
|
||||
/// Render an incidental-DDL structure echo (ADR-0050, issue #28).
|
||||
///
|
||||
/// Produces a header line (`<TableName>`), the schema table
|
||||
/// itself, and — for a structure that has FK relationships
|
||||
/// — `References:` / `Referenced by:` blocks below as plain
|
||||
/// indented text (relationship visualization is its own
|
||||
/// future ADR per §5 OOS-1).
|
||||
/// Display a relationship-endpoint column list (ADR-0043): the bare
|
||||
/// column for a single-column FK, `(a, b)` for a compound one.
|
||||
fn cols_disp(cols: &[String]) -> String {
|
||||
if cols.len() == 1 {
|
||||
cols[0].clone()
|
||||
} else {
|
||||
format!("({})", cols.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
/// Produces a header line (`<TableName>`), the schema table, the
|
||||
/// `Indexes:` section, and the constraint section — **structure only**.
|
||||
/// Relationship information is deliberately omitted: a confirmation
|
||||
/// echo for a structural edit (`create table`, `add`/`drop`/`rename`/
|
||||
/// `change column`, `add`/`drop index`) reports the change just made,
|
||||
/// not the table's relationships, which the user did not touch. The
|
||||
/// relationship-subject surfaces (`show table`, `add`/`drop
|
||||
/// relationship`) render diagrams via [`render_structure_with_diagrams`]
|
||||
/// instead; relationships are one `show table <T>` away. ADR-0050
|
||||
/// supersedes ADR-0044 §1's "incidental DDL keeps prose" and the
|
||||
/// relationship-block half of ADR-0016 §5.
|
||||
#[must_use]
|
||||
pub fn render_structure(desc: &TableDescription) -> Vec<String> {
|
||||
let mut out = structure_box_lines(desc);
|
||||
out.extend(relationship_prose_lines(desc));
|
||||
out.extend(index_lines(desc));
|
||||
out.extend(constraint_lines(desc));
|
||||
out
|
||||
@@ -118,41 +113,6 @@ fn structure_box_lines(desc: &TableDescription) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The `References:` / `Referenced by:` prose blocks (ADR-0016 §5),
|
||||
/// retained for the incidental DDL echoes (ADR-0044 §1).
|
||||
fn relationship_prose_lines(desc: &TableDescription) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
if !desc.outbound_relationships.is_empty() {
|
||||
out.push("References:".to_string());
|
||||
for r in &desc.outbound_relationships {
|
||||
out.push(format!(
|
||||
" {} → {}.{} ({}, on delete {}, on update {})",
|
||||
cols_disp(&r.local_columns),
|
||||
r.other_table,
|
||||
cols_disp(&r.other_columns),
|
||||
r.name,
|
||||
r.on_delete,
|
||||
r.on_update,
|
||||
));
|
||||
}
|
||||
}
|
||||
if !desc.inbound_relationships.is_empty() {
|
||||
out.push("Referenced by:".to_string());
|
||||
for r in &desc.inbound_relationships {
|
||||
out.push(format!(
|
||||
" {}.{} → {} ({}, on delete {}, on update {})",
|
||||
r.other_table,
|
||||
cols_disp(&r.other_columns),
|
||||
cols_disp(&r.local_columns),
|
||||
r.name,
|
||||
r.on_delete,
|
||||
r.on_update,
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Indexes section (ADR-0025), only when the table carries a
|
||||
/// user-created index. A UNIQUE index is marked `[unique]` (ADR-0035
|
||||
/// §4d).
|
||||
@@ -1591,11 +1551,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_structure_with_relationships() {
|
||||
fn render_structure_omits_relationship_prose() {
|
||||
// ADR-0050 (issue #28): the incidental-DDL structure echo never
|
||||
// carries the `References:` / `Referenced by:` block, even when
|
||||
// the description carries both inbound and outbound
|
||||
// relationships. (Relationship-subject surfaces render diagrams
|
||||
// via `render_structure_with_diagrams`, not this function.)
|
||||
let desc = TableDescription {
|
||||
name: "Customers".to_string(),
|
||||
columns: vec![col("id", Type::Serial, true, false)],
|
||||
outbound_relationships: Vec::new(),
|
||||
outbound_relationships: vec![RelationshipEnd {
|
||||
name: "cust_region".to_string(),
|
||||
other_table: "Regions".to_string(),
|
||||
other_columns: vec!["id".to_string()],
|
||||
local_columns: vec!["region_id".to_string()],
|
||||
on_delete: ReferentialAction::NoAction,
|
||||
on_update: ReferentialAction::NoAction,
|
||||
}],
|
||||
inbound_relationships: vec![RelationshipEnd {
|
||||
name: "cust_orders".to_string(),
|
||||
other_table: "Orders".to_string(),
|
||||
@@ -1609,15 +1581,14 @@ mod tests {
|
||||
check_constraints: Vec::new(),
|
||||
};
|
||||
let out = render_structure(&desc).join("\n");
|
||||
assert!(
|
||||
out.contains("Referenced by:"),
|
||||
"expected inbound relationship section:\n{out}",
|
||||
);
|
||||
assert!(
|
||||
out.contains("Orders.cust_id → id"),
|
||||
"expected inbound relationship line:\n{out}",
|
||||
);
|
||||
assert_snapshot!(out);
|
||||
// The structure box still renders.
|
||||
assert!(out.contains("Customers"), "structure header:\n{out}");
|
||||
assert!(out.contains("│ id"), "column row:\n{out}");
|
||||
// No relationship block in either direction.
|
||||
assert!(!out.contains("References:"), "no outbound prose:\n{out}");
|
||||
assert!(!out.contains("Referenced by:"), "no inbound prose:\n{out}");
|
||||
assert!(!out.contains("Orders.cust_id"), "no prose line:\n{out}");
|
||||
assert!(!out.contains("Regions"), "no prose line:\n{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+112
-4
@@ -28,7 +28,35 @@ use super::PersistenceError;
|
||||
pub(super) const STATUS_OK: &str = "ok";
|
||||
pub(super) const STATUS_ERR: &str = "err";
|
||||
|
||||
/// Format a successful-command record. Pure; no I/O.
|
||||
/// The optional status suffix marking an advanced-mode submission
|
||||
/// (ADR-0052, issue #30): `ok:adv` / `err:adv`. Recorded so that
|
||||
/// hydration can reconstruct the `:`-prefixed runnable form of an
|
||||
/// advanced command, making advanced history reusable in simple mode.
|
||||
pub(super) const ADV_SUFFIX: &str = "adv";
|
||||
|
||||
/// Build the status token for a `base` (`ok`/`err`) and submission mode.
|
||||
pub(super) fn status_token(base: &str, advanced: bool) -> String {
|
||||
if advanced {
|
||||
format!("{base}:{ADV_SUFFIX}")
|
||||
} else {
|
||||
base.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a status token into `(is_ok, advanced)` (ADR-0052). The base
|
||||
/// (`ok` ⇒ replayable, anything else ⇒ skip) precedes an optional
|
||||
/// `:adv` mode suffix. An unknown base degrades to `(false, _)`, so
|
||||
/// replay skips it rather than mis-running it.
|
||||
pub(super) fn parse_status(status: &str) -> (bool, bool) {
|
||||
let (base, suffix) = status.split_once(':').unwrap_or((status, ""));
|
||||
(base == STATUS_OK, suffix == ADV_SUFFIX)
|
||||
}
|
||||
|
||||
/// Format a successful-command record. Pure; no I/O. (Simple-mode
|
||||
/// convenience used by tests; production threads the mode through
|
||||
/// [`format_record_with_status`] + [`status_token`], so this is
|
||||
/// test-only since ADR-0052.)
|
||||
#[cfg(test)]
|
||||
pub(super) fn format_record(command_text: &str, timestamp_iso: String) -> String {
|
||||
format_record_with_status(command_text, timestamp_iso, STATUS_OK)
|
||||
}
|
||||
@@ -100,9 +128,20 @@ fn parse_record_source(line: &str) -> Option<String> {
|
||||
// characters) is preserved.
|
||||
let mut parts = line.splitn(3, '|');
|
||||
let _ts = parts.next()?;
|
||||
let _status = parts.next()?;
|
||||
let status = parts.next()?;
|
||||
let source = parts.next()?;
|
||||
Some(unescape_command(source))
|
||||
let (_is_ok, advanced) = parse_status(status);
|
||||
let command = unescape_command(source);
|
||||
// ADR-0052: an advanced record is hydrated in its `:`-prefixed
|
||||
// simple-mode runnable form, so cross-session recall matches the
|
||||
// in-session ring (and recall strips the `:` again in advanced
|
||||
// mode). A simple record hydrates bare. Old `ok`/`err` logs have no
|
||||
// `:adv` suffix → read as simple, unchanged.
|
||||
Some(if advanced {
|
||||
format!(": {command}")
|
||||
} else {
|
||||
command
|
||||
})
|
||||
}
|
||||
|
||||
/// A parsed journal record (ADR-0034 §3). `source` is already
|
||||
@@ -129,8 +168,11 @@ pub(super) fn parse_journal_record(line: &str) -> Option<JournalRecord> {
|
||||
if !looks_like_iso8601(ts) {
|
||||
return None;
|
||||
}
|
||||
// ADR-0052: the status may carry a `:adv` mode suffix; replayability
|
||||
// keys off the base token only (`ok` / `ok:adv` are both ok).
|
||||
let (status_is_ok, _advanced) = parse_status(status);
|
||||
Some(JournalRecord {
|
||||
status_is_ok: status == STATUS_OK,
|
||||
status_is_ok,
|
||||
source: unescape_command(source),
|
||||
})
|
||||
}
|
||||
@@ -436,4 +478,70 @@ mod tests {
|
||||
let body = fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(body, "first|ok|a\nsecond|ok|b\n");
|
||||
}
|
||||
|
||||
// ---- ADR-0052 (issue #30): mode tag in the status field ----
|
||||
|
||||
#[test]
|
||||
fn status_token_builds_and_parses_the_adv_suffix() {
|
||||
assert_eq!(status_token(STATUS_OK, false), "ok");
|
||||
assert_eq!(status_token(STATUS_OK, true), "ok:adv");
|
||||
assert_eq!(status_token(STATUS_ERR, true), "err:adv");
|
||||
assert_eq!(parse_status("ok"), (true, false));
|
||||
assert_eq!(parse_status("ok:adv"), (true, true));
|
||||
assert_eq!(parse_status("err"), (false, false));
|
||||
assert_eq!(parse_status("err:adv"), (false, true));
|
||||
// Unknown base → not ok (replay skips it), simple.
|
||||
assert_eq!(parse_status("frobnicate"), (false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_recent_sources_reconstructs_colon_prefix_for_advanced() {
|
||||
// An advanced record (`ok:adv`) hydrates in its `:`-prefixed
|
||||
// simple-mode runnable form; a simple record stays bare. This is
|
||||
// the cross-session half of the issue #30 fix.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("history.log");
|
||||
let adv = format_record_with_status(
|
||||
"select * from T",
|
||||
"2026-06-13T10:00:00Z".to_string(),
|
||||
&status_token(STATUS_OK, true),
|
||||
);
|
||||
let simple = format_record_with_status(
|
||||
"create table T with pk",
|
||||
"2026-06-13T10:00:01Z".to_string(),
|
||||
&status_token(STATUS_OK, false),
|
||||
);
|
||||
std::fs::write(&path, format!("{adv}{simple}")).unwrap();
|
||||
let got = read_recent_sources(&path, 10).unwrap();
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
": select * from T".to_string(),
|
||||
"create table T with pk".to_string(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_journal_record_treats_ok_adv_as_ok() {
|
||||
// Replay keys off the base token, so `ok:adv` replays like `ok`
|
||||
// (source stays canonical).
|
||||
let rec = parse_journal_record("2026-06-13T10:00:00Z|ok:adv|select * from T")
|
||||
.expect("ok:adv journal record");
|
||||
assert!(rec.status_is_ok);
|
||||
assert_eq!(rec.source, "select * from T");
|
||||
let err = parse_journal_record("2026-06-13T10:00:00Z|err:adv|select bad")
|
||||
.expect("err:adv journal record");
|
||||
assert!(!err.status_is_ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_three_field_log_reads_as_simple() {
|
||||
// Back-compat: a pre-ADR-0052 log (no `:adv`) hydrates bare.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("history.log");
|
||||
std::fs::write(&path, "2026-01-01T00:00:00Z|ok|select 1\n").unwrap();
|
||||
let got = read_recent_sources(&path, 10).unwrap();
|
||||
assert_eq!(got, vec!["select 1".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
+32
-9
@@ -395,11 +395,26 @@ impl Persistence {
|
||||
}
|
||||
}
|
||||
|
||||
/// Append one successful-command record to `history.log`.
|
||||
pub fn append_history(&self, command_text: &str) -> Result<(), PersistenceError> {
|
||||
/// Append one successful-command record to `history.log`. `advanced`
|
||||
/// (ADR-0052) tags the record `ok:adv` when the command was submitted
|
||||
/// in an advanced effective mode, so hydration can reconstruct its
|
||||
/// `:`-prefixed form for reuse in simple mode.
|
||||
pub fn append_history(
|
||||
&self,
|
||||
command_text: &str,
|
||||
advanced: bool,
|
||||
) -> Result<(), PersistenceError> {
|
||||
let path = self.project_path.join(HISTORY_LOG);
|
||||
let line = history::format_record(command_text, history::utc_iso8601_now());
|
||||
debug!(len = command_text.len(), "persist: append ok record to history.log");
|
||||
let status = history::status_token(history::STATUS_OK, advanced);
|
||||
let line = history::format_record_with_status(
|
||||
command_text,
|
||||
history::utc_iso8601_now(),
|
||||
&status,
|
||||
);
|
||||
debug!(
|
||||
len = command_text.len(),
|
||||
advanced, "persist: append ok record to history.log"
|
||||
);
|
||||
history::append(&path, &line)
|
||||
}
|
||||
|
||||
@@ -410,14 +425,22 @@ impl Persistence {
|
||||
/// transactional `ok` journal). Best-effort at the call site:
|
||||
/// a failure to record a failure must never escalate a user
|
||||
/// error into a fatal (ADR-0034 §4).
|
||||
pub fn append_history_failure(&self, command_text: &str) -> Result<(), PersistenceError> {
|
||||
pub fn append_history_failure(
|
||||
&self,
|
||||
command_text: &str,
|
||||
advanced: bool,
|
||||
) -> Result<(), PersistenceError> {
|
||||
let path = self.project_path.join(HISTORY_LOG);
|
||||
let status = history::status_token(history::STATUS_ERR, advanced);
|
||||
let line = history::format_record_with_status(
|
||||
command_text,
|
||||
history::utc_iso8601_now(),
|
||||
history::STATUS_ERR,
|
||||
&status,
|
||||
);
|
||||
debug!(
|
||||
len = command_text.len(),
|
||||
advanced, "persist: append err record to history.log"
|
||||
);
|
||||
debug!(len = command_text.len(), "persist: append err record to history.log");
|
||||
history::append(&path, &line)
|
||||
}
|
||||
|
||||
@@ -577,8 +600,8 @@ mod tests {
|
||||
fn append_history_creates_and_appends() {
|
||||
let dir = tempdir();
|
||||
let p = Persistence::new(dir.path().to_path_buf());
|
||||
p.append_history("create table Foo with pk id(serial)").unwrap();
|
||||
p.append_history("insert into Foo (1)").unwrap();
|
||||
p.append_history("create table Foo with pk id(serial)", false).unwrap();
|
||||
p.append_history("insert into Foo (1)", false).unwrap();
|
||||
let body = fs::read_to_string(dir.path().join(HISTORY_LOG)).unwrap();
|
||||
let lines: Vec<&str> = body.trim_end().lines().collect();
|
||||
assert_eq!(lines.len(), 2);
|
||||
|
||||
+61
-22
@@ -479,17 +479,19 @@ async fn run_loop(
|
||||
command,
|
||||
source,
|
||||
submission_mode,
|
||||
session.project().path().to_path_buf(),
|
||||
);
|
||||
}
|
||||
Action::JournalFailure { source } => {
|
||||
Action::JournalFailure { source, advanced } => {
|
||||
// ADR-0034 §1/§4: record a failed command as an
|
||||
// `err` record. Best-effort — a failure to record
|
||||
// a failure must never escalate a user error into
|
||||
// a fatal, so the result is logged and ignored.
|
||||
// `err` record (ADR-0052: `err:adv` when advanced).
|
||||
// Best-effort — a failure to record a failure must
|
||||
// never escalate a user error into a fatal, so the
|
||||
// result is logged and ignored.
|
||||
if let Err(e) = crate::persistence::Persistence::new(
|
||||
session.project().path().to_path_buf(),
|
||||
)
|
||||
.append_history_failure(&source)
|
||||
.append_history_failure(&source, advanced)
|
||||
{
|
||||
tracing::warn!(error = %e, "failed to journal err record (ignored)");
|
||||
}
|
||||
@@ -971,7 +973,9 @@ async fn perform_switch(
|
||||
// history.log. The worker's persistence is wired but not
|
||||
// directly addressable from here, so we use a fresh
|
||||
// Persistence handle for this single line.
|
||||
let _ = Persistence::new(new_path.clone()).append_history(&source);
|
||||
// App-lifecycle command (save-as/load/new): journalled simple
|
||||
// (ADR-0052 — app commands run in any mode, so no `:` on recall).
|
||||
let _ = Persistence::new(new_path.clone()).append_history(&source, false);
|
||||
|
||||
// Update the resume pointer so the next `--resume` launch
|
||||
// reopens the project we just switched to — unless it is a
|
||||
@@ -1040,7 +1044,9 @@ fn spawn_export(
|
||||
source: String,
|
||||
event_tx: mpsc::Sender<AppEvent>,
|
||||
) {
|
||||
let _ = crate::persistence::Persistence::new(project_path.clone()).append_history(&source);
|
||||
// `export` app command: journalled simple (ADR-0052).
|
||||
let _ = crate::persistence::Persistence::new(project_path.clone())
|
||||
.append_history(&source, false);
|
||||
tokio::spawn(async move {
|
||||
let outcome = tokio::task::spawn_blocking(move || {
|
||||
do_export(&project_path, &project_name, &data_root, target.as_deref())
|
||||
@@ -1184,7 +1190,7 @@ async fn build_schema_cache(database: &Database) -> crate::completion::SchemaCac
|
||||
// miss leaves that table's columns unpopulated and the
|
||||
// walker falls back to the schemaless value-literal list.
|
||||
for name in cache.tables.clone() {
|
||||
if let Ok(desc) = database.describe_table(name.clone(), None).await {
|
||||
if let Ok(desc) = database.describe_table(name.clone()).await {
|
||||
// Per-table indexes for the items panel (S2, ADR-0025).
|
||||
// Carry uniqueness so the panel can mark a UNIQUE index
|
||||
// (ADR-0035 §4d). Captured before `desc.columns` is
|
||||
@@ -1295,11 +1301,20 @@ fn spawn_rebuild(
|
||||
source: String,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let source_for_journal = source.clone();
|
||||
match database
|
||||
.rebuild_from_text(project_path.clone(), Some(source))
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
// ADR-0052: journal `rebuild` at the dispatch layer (the
|
||||
// worker no longer journals); simple (app command),
|
||||
// best-effort.
|
||||
if let Err(e) = crate::persistence::Persistence::new(project_path.clone())
|
||||
.append_history(&source_for_journal, false)
|
||||
{
|
||||
warn!(error = %e, "failed to journal rebuild (ignored)");
|
||||
}
|
||||
let summary = summarize_project(&project_path)
|
||||
.unwrap_or_else(|_| "rebuild complete".to_string());
|
||||
let _ = event_tx
|
||||
@@ -1407,10 +1422,11 @@ fn spawn_dsl_dispatch(
|
||||
command: Command,
|
||||
source: String,
|
||||
submission_mode: crate::app::EffectiveMode,
|
||||
project_path: std::path::PathBuf,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
// Retain the source for `DslFailed` so the App can journal a
|
||||
// rejected command as `err` (ADR-0034 §1/§2).
|
||||
// Retain the source for journaling (ADR-0034 §1/§2; ADR-0052
|
||||
// moved success journaling here, next to the failure path).
|
||||
let source_for_journal = source.clone();
|
||||
// ADR-0038: the DSL → SQL teaching echo fires for a DSL-form
|
||||
// command submitted in an advanced effective mode (ADR-0037).
|
||||
@@ -1431,6 +1447,19 @@ fn spawn_dsl_dispatch(
|
||||
let lookups = collect_echo_lookups(&database, &command, submission_mode).await;
|
||||
let echo = crate::echo::echo_for(&command, submission_mode);
|
||||
let outcome = execute_command_typed(&database, command.clone(), source).await;
|
||||
// ADR-0052 (issue #30): journal a SUCCESSFUL command here, at the
|
||||
// top of the chain — the canonical source + submission mode are
|
||||
// both in scope, so no mode-plumbing into the worker is needed.
|
||||
// Best-effort (ADR-0040 amended): the command is already committed;
|
||||
// a journal-write failure is logged, never fatal. Failures stay on
|
||||
// the `JournalFailure` path (Ok/Err are exclusive — no double
|
||||
// journal). `:adv` tags an advanced submission (ADR-0052).
|
||||
if outcome.is_ok()
|
||||
&& let Err(e) = crate::persistence::Persistence::new(project_path)
|
||||
.append_history(&source_for_journal, submission_mode.is_advanced())
|
||||
{
|
||||
warn!(error = %e, "failed to journal ok record (ignored)");
|
||||
}
|
||||
let event = match outcome {
|
||||
Ok(CommandOutcome::Schema(description)) => {
|
||||
let schema_echo = build_schema_echo(
|
||||
@@ -1569,6 +1598,7 @@ fn spawn_dsl_dispatch(
|
||||
error,
|
||||
facts,
|
||||
source: source_for_journal,
|
||||
advanced: submission_mode.is_advanced(),
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1620,7 +1650,7 @@ async fn build_show_data_echo(
|
||||
limit: Some(_),
|
||||
..
|
||||
} => database
|
||||
.describe_table(name.clone(), None)
|
||||
.describe_table(name.clone())
|
||||
.await
|
||||
.map(|desc| {
|
||||
desc.columns
|
||||
@@ -1702,7 +1732,7 @@ async fn collect_echo_lookups(
|
||||
Command::DropIndex {
|
||||
selector: IndexSelector::Columns { table, columns },
|
||||
} => {
|
||||
if let Ok(desc) = database.describe_table(table.clone(), None).await
|
||||
if let Ok(desc) = database.describe_table(table.clone()).await
|
||||
&& let Some(idx) = desc.indexes.iter().find(|i| i.columns == *columns)
|
||||
{
|
||||
out.drop_index_name = Some(idx.name.clone());
|
||||
@@ -1717,7 +1747,7 @@ async fn collect_echo_lookups(
|
||||
child_column,
|
||||
},
|
||||
} => {
|
||||
if let Ok(desc) = database.describe_table(child_table.clone(), None).await
|
||||
if let Ok(desc) = database.describe_table(child_table.clone()).await
|
||||
&& let Some(rel) = desc.outbound_relationships.iter().find(|r| {
|
||||
// The Endpoints drop selector is single-column
|
||||
// (ADR-0043 keeps DROP by-endpoints single-column;
|
||||
@@ -1741,7 +1771,7 @@ async fn collect_echo_lookups(
|
||||
// resolver API would be the next step if schemas grow.
|
||||
if let Ok(tables) = database.list_tables().await {
|
||||
for table in tables {
|
||||
if let Ok(desc) = database.describe_table(table.clone(), None).await
|
||||
if let Ok(desc) = database.describe_table(table.clone()).await
|
||||
&& desc.outbound_relationships.iter().any(|r| r.name == *name)
|
||||
{
|
||||
out.drop_relationship = Some((name.clone(), table.clone()));
|
||||
@@ -1765,8 +1795,8 @@ async fn collect_echo_lookups(
|
||||
// *before* execution to know which `ADD COLUMN` lines to
|
||||
// emit. The parent columns here are the explicit DSL list,
|
||||
// paired positionally with the child list.
|
||||
let parent_desc = database.describe_table(parent_table.clone(), None).await;
|
||||
let child_desc = database.describe_table(child_table.clone(), None).await;
|
||||
let parent_desc = database.describe_table(parent_table.clone()).await;
|
||||
let child_desc = database.describe_table(child_table.clone()).await;
|
||||
if let (Ok(parent_desc), Ok(child_desc)) = (parent_desc, child_desc) {
|
||||
let mut new_columns: Vec<(String, crate::dsl::types::Type)> = Vec::new();
|
||||
for (child_col, parent_col) in child_columns.iter().zip(parent_columns) {
|
||||
@@ -2034,7 +2064,7 @@ async fn enrich_check_violation(
|
||||
.await
|
||||
.map(|v| v.to_string());
|
||||
// The rule itself — the column's compiled CHECK expression.
|
||||
if let Ok(desc) = database.describe_table(table.to_string(), None).await
|
||||
if let Ok(desc) = database.describe_table(table.to_string()).await
|
||||
&& let Some(col) = desc.columns.iter().find(|c| c.name == column)
|
||||
{
|
||||
facts.check_rule.clone_from(&col.check);
|
||||
@@ -2242,7 +2272,7 @@ async fn user_value_for_column_with_schema(
|
||||
} = command
|
||||
{
|
||||
let desc = database
|
||||
.describe_table(table.to_string(), None)
|
||||
.describe_table(table.to_string())
|
||||
.await
|
||||
.ok()?;
|
||||
// Build the natural-order column list the same way
|
||||
@@ -2281,7 +2311,7 @@ async fn user_value_for_column_with_schema(
|
||||
&& literal_rows.len() == 1
|
||||
{
|
||||
let desc = database
|
||||
.describe_table(table.to_string(), None)
|
||||
.describe_table(table.to_string())
|
||||
.await
|
||||
.ok()?;
|
||||
let idx = desc.columns.iter().position(|c| c.name == column)?;
|
||||
@@ -2540,6 +2570,15 @@ pub async fn run_replay(
|
||||
execute_command_typed(database, command, command_text.clone()).await;
|
||||
match outcome {
|
||||
Ok(_) => {
|
||||
// ADR-0052: journal the replayed line at the dispatch
|
||||
// layer (the worker no longer journals). Replay is
|
||||
// mode-agnostic, so the re-written record is tagged
|
||||
// simple; best-effort, like the interactive path.
|
||||
if let Err(e) = crate::persistence::Persistence::new(project_root.to_path_buf())
|
||||
.append_history(&command_text, false)
|
||||
{
|
||||
warn!(error = %e, "failed to journal replayed line (ignored)");
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
Err(DbError::PersistenceFatal {
|
||||
@@ -2891,7 +2930,7 @@ async fn execute_command_typed(
|
||||
.await
|
||||
.map(|d| CommandOutcome::Schema(Some(d))),
|
||||
Command::ShowTable { name } => database
|
||||
.describe_table(name, src)
|
||||
.describe_table(name)
|
||||
.await
|
||||
.map(|d| CommandOutcome::Schema(Some(d))),
|
||||
// ADR-0044: a named relationship renders as a diagram (App-side),
|
||||
@@ -2944,14 +2983,14 @@ async fn execute_command_typed(
|
||||
filter,
|
||||
limit,
|
||||
} => database
|
||||
.query_data(name, filter, limit, src)
|
||||
.query_data(name, filter, limit)
|
||||
.await
|
||||
.map(CommandOutcome::Query),
|
||||
// A SQL `SELECT` (advanced mode; ADR-0030 §6, ADR-0031).
|
||||
// The grammar walker has already validated `sql` is in
|
||||
// the supported subset; the worker runs it as text.
|
||||
Command::Select { sql } => database
|
||||
.run_select(sql, src)
|
||||
.run_select(sql)
|
||||
.await
|
||||
.map(CommandOutcome::Query),
|
||||
// A SQL `INSERT` (advanced mode; ADR-0033 §1). Grammar-as-
|
||||
|
||||
@@ -31,6 +31,16 @@ const RECENT_WINDOW_DAYS: i64 = 3 * 365;
|
||||
const ADULT_MIN_DAYS: i64 = 18 * 365;
|
||||
const ADULT_MAX_DAYS: i64 = 80 * 365;
|
||||
|
||||
/// Year windows for the `int`-typed year heuristics (issue #33),
|
||||
/// expressed relative to [`REF_YEAR`] so they advance with releases —
|
||||
/// the year siblings of the `DateRecent` / `DateAdult` windows above.
|
||||
/// `YearRecent` spans ~75 years (1950–2025 at REF_YEAR=2025), wide
|
||||
/// enough for `published` / `founded` / `release_year`; `YearBirth`
|
||||
/// mirrors the adult birth window (1945–2007).
|
||||
const YEAR_RECENT_SPAN: i32 = 75;
|
||||
const YEAR_BIRTH_MIN_AGE: i32 = 18;
|
||||
const YEAR_BIRTH_MAX_AGE: i32 = 80;
|
||||
|
||||
/// Produce one value for `generator` against destination type `ty`.
|
||||
#[must_use]
|
||||
pub fn generate_value(generator: &Generator, ty: Type, rng: &mut SeedRng) -> Value {
|
||||
@@ -71,6 +81,13 @@ pub fn generate_value(generator: &Generator, ty: Type, rng: &mut SeedRng) -> Val
|
||||
Generator::CurrencyAmount => currency_amount(ty, rng),
|
||||
Generator::Age => Value::Number(rng.random_range(18..=80).to_string()),
|
||||
Generator::SmallInt => Value::Number(rng.random_range(1..=100).to_string()),
|
||||
Generator::YearRecent => {
|
||||
Value::Number(rng.random_range((REF_YEAR - YEAR_RECENT_SPAN)..=REF_YEAR).to_string())
|
||||
}
|
||||
Generator::YearBirth => Value::Number(
|
||||
rng.random_range((REF_YEAR - YEAR_BIRTH_MAX_AGE)..=(REF_YEAR - YEAR_BIRTH_MIN_AGE))
|
||||
.to_string(),
|
||||
),
|
||||
Generator::DateRecent => Value::Text(format_date(random_past_date(rng, 0, RECENT_WINDOW_DAYS))),
|
||||
Generator::DateAdult => {
|
||||
Value::Text(format_date(random_past_date(rng, ADULT_MIN_DAYS, ADULT_MAX_DAYS)))
|
||||
@@ -489,6 +506,41 @@ mod tests {
|
||||
assert!(matches!(v, Value::Number(_)), "numeric pick should be a Number: {v:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn year_generators_stay_within_their_bounded_windows() {
|
||||
// Issue #33: both year generators emit a plain `int` inside a
|
||||
// bounded, plausible window — never the unbounded-int nonsense.
|
||||
let mut rng = make_rng(Some(7));
|
||||
for _ in 0..300 {
|
||||
let Value::Number(s) = generate_value(&Generator::YearRecent, Type::Int, &mut rng)
|
||||
else {
|
||||
panic!("YearRecent must be a Number")
|
||||
};
|
||||
let n: i32 = s.parse().unwrap();
|
||||
assert!((1950..=2025).contains(&n), "YearRecent {n} out of [1950,2025]");
|
||||
}
|
||||
for _ in 0..300 {
|
||||
let Value::Number(s) = generate_value(&Generator::YearBirth, Type::Int, &mut rng)
|
||||
else {
|
||||
panic!("YearBirth must be a Number")
|
||||
};
|
||||
let n: i32 = s.parse().unwrap();
|
||||
assert!((1945..=2007).contains(&n), "YearBirth {n} out of [1945,2007]");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn year_generators_are_deterministic_for_a_fixed_seed() {
|
||||
assert_eq!(
|
||||
gen_once(&Generator::YearRecent, Type::Int, 42),
|
||||
gen_once(&Generator::YearRecent, Type::Int, 42),
|
||||
);
|
||||
assert_eq!(
|
||||
gen_once(&Generator::YearBirth, Type::Int, 42),
|
||||
gen_once(&Generator::YearBirth, Type::Int, 42),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn int_range_stays_within_inclusive_bounds() {
|
||||
let g = Generator::Range { low: "10".into(), high: "20".into() };
|
||||
|
||||
+128
-2
@@ -57,9 +57,14 @@ fn choose_generator_inner(table: &str, col: &ColumnSpec) -> Generator {
|
||||
/// the post-seed advisory; such columns still receive generic text.
|
||||
#[must_use]
|
||||
pub fn is_enum_ish(name: &str) -> bool {
|
||||
// `priority` is intentionally absent: issue #34 gave it a built-in
|
||||
// value set (low/medium/high · 1/2/3), so it is no longer "filled
|
||||
// generically" and must not trigger the D13 advisory. `severity` /
|
||||
// `rating` / `stars` were never here. `status` stays — it is
|
||||
// deliberately left to the advisory (no built-in set).
|
||||
const ENUM_TOKENS: &[&str] = &[
|
||||
"role", "status", "state", "type", "kind", "category", "level",
|
||||
"tier", "stage", "priority", "gender",
|
||||
"tier", "stage", "gender",
|
||||
];
|
||||
let toks = tokens(name);
|
||||
toks.iter().any(|t| ENUM_TOKENS.contains(&t.as_str()))
|
||||
@@ -150,6 +155,49 @@ fn match_name_generator(table: &str, toks: &[String], ty: Type) -> Option<Genera
|
||||
if numeric && has_any(toks, &["quantity", "qty", "stock", "count"]) {
|
||||
return Some(Generator::SmallInt);
|
||||
}
|
||||
// — Year-as-int (issue #33) — bounded plausible years so the `int`
|
||||
// type fallback (D8) can't emit nonsense like `9419`. `int`-gated
|
||||
// (years are whole numbers) and placed *after* the quantity rule so
|
||||
// `year_count` (a count of years) stays a `SmallInt`. `birth`/`born`/
|
||||
// `dob` + year picks the birth window — the int sibling of the
|
||||
// `dob → DateAdult` rule above — otherwise a recent window covers
|
||||
// `year` / `*_year` / `published` / `founded`.
|
||||
if matches!(ty, Type::Int)
|
||||
&& (has_token(toks, "year") || has_any(toks, &["published", "founded"]))
|
||||
{
|
||||
return Some(if has_any(toks, &["birth", "born", "dob"]) {
|
||||
Generator::YearBirth
|
||||
} else {
|
||||
Generator::YearRecent
|
||||
});
|
||||
}
|
||||
|
||||
// — Conventional choice sets (issue #34) — a few enum-ish names have
|
||||
// a near-canonical small value set that reads far better than lorem
|
||||
// text. Type-gated; reuses `PickFrom`. Names *without* a canonical
|
||||
// set (`status`, `role`, `type`, …) stay unmatched → generic text +
|
||||
// the D12/D13 advisory. `status` is deliberately excluded: its real
|
||||
// values are too domain-specific (user-confirmed, issue #34). A
|
||||
// user-declared `IN`-CHECK still wins — it is resolved before this.
|
||||
if has_any(toks, &["priority", "prio"]) {
|
||||
if text {
|
||||
return Some(pick_from(&["low", "medium", "high"]));
|
||||
}
|
||||
if matches!(ty, Type::Int) {
|
||||
return Some(pick_from(&["1", "2", "3"]));
|
||||
}
|
||||
}
|
||||
if has_token(toks, "severity") {
|
||||
if text {
|
||||
return Some(pick_from(&["low", "medium", "high", "critical"]));
|
||||
}
|
||||
if matches!(ty, Type::Int) {
|
||||
return Some(pick_from(&["1", "2", "3", "4"]));
|
||||
}
|
||||
}
|
||||
if matches!(ty, Type::Int) && has_any(toks, &["rating", "stars"]) {
|
||||
return Some(pick_from(&["1", "2", "3", "4", "5"]));
|
||||
}
|
||||
|
||||
// — Temporal (bounded, D8) —
|
||||
if matches!(ty, Type::Date) && has_any(toks, &["dob", "birthday", "birthdate"]) {
|
||||
@@ -267,6 +315,14 @@ fn tokens(name: &str) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// A `PickFrom` generator from string-literal values (issue #34's
|
||||
/// conventional choice sets). `literal_to_value` interprets each entry
|
||||
/// by the destination type at generation time (an `int` column turns
|
||||
/// `"1"` into a number).
|
||||
fn pick_from(values: &[&str]) -> Generator {
|
||||
Generator::PickFrom(values.iter().map(|s| (*s).to_string()).collect())
|
||||
}
|
||||
|
||||
fn has_token(toks: &[String], t: &str) -> bool {
|
||||
toks.iter().any(|x| x == t)
|
||||
}
|
||||
@@ -412,11 +468,81 @@ mod tests {
|
||||
assert!(is_enum_ish("status"));
|
||||
assert!(is_enum_ish("role"));
|
||||
assert!(is_enum_ish("order_state"));
|
||||
assert!(is_enum_ish("priority"));
|
||||
// Issue #34: `priority` gained a built-in value set, so it is no
|
||||
// longer advised (it is no longer "filled generically").
|
||||
assert!(!is_enum_ish("priority"));
|
||||
assert!(!is_enum_ish("severity"));
|
||||
assert!(!is_enum_ish("rating"));
|
||||
assert!(!is_enum_ish("email"));
|
||||
assert!(!is_enum_ish("first_name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn year_like_int_columns_map_to_bounded_years() {
|
||||
// Issue #33: `int`-gated year heuristics. `birth`/`born`/`dob`
|
||||
// years pick the birth window; the rest a recent window.
|
||||
assert_eq!(choose("authors", "birth_year", Type::Int), Generator::YearBirth);
|
||||
assert_eq!(choose("authors", "birthYear", Type::Int), Generator::YearBirth);
|
||||
assert_eq!(choose("u", "year_born", Type::Int), Generator::YearBirth);
|
||||
assert_eq!(choose("books", "year", Type::Int), Generator::YearRecent);
|
||||
assert_eq!(choose("films", "release_year", Type::Int), Generator::YearRecent);
|
||||
assert_eq!(choose("books", "published", Type::Int), Generator::YearRecent);
|
||||
assert_eq!(choose("companies", "founded", Type::Int), Generator::YearRecent);
|
||||
// Type-gated: a text `year` is not a bounded-year int.
|
||||
assert_eq!(choose("books", "year", Type::Text), Generator::Generic);
|
||||
// `year_count` is a count, not a year — the quantity rule wins.
|
||||
assert_eq!(choose("t", "year_count", Type::Int), Generator::SmallInt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conventional_choice_sets_map_to_pick_from() {
|
||||
// Issue #34: type-gated built-in value sets.
|
||||
assert_eq!(
|
||||
choose("tickets", "priority", Type::Text),
|
||||
Generator::PickFrom(vec!["low".into(), "medium".into(), "high".into()]),
|
||||
);
|
||||
assert_eq!(
|
||||
choose("tickets", "prio", Type::Int),
|
||||
Generator::PickFrom(vec!["1".into(), "2".into(), "3".into()]),
|
||||
);
|
||||
assert_eq!(
|
||||
choose("bugs", "severity", Type::Text),
|
||||
Generator::PickFrom(vec!["low".into(), "medium".into(), "high".into(), "critical".into()]),
|
||||
);
|
||||
assert_eq!(
|
||||
choose("bugs", "severity", Type::Int),
|
||||
Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into()]),
|
||||
);
|
||||
assert_eq!(
|
||||
choose("reviews", "rating", Type::Int),
|
||||
Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into(), "5".into()]),
|
||||
);
|
||||
assert_eq!(
|
||||
choose("reviews", "stars", Type::Int),
|
||||
Generator::PickFrom(vec!["1".into(), "2".into(), "3".into(), "4".into(), "5".into()]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_is_left_to_the_advisory_not_given_a_set() {
|
||||
// User-confirmed (issue #34): `status` keeps the D12 "don't
|
||||
// guess" stance — generic text + the advisory, no built-in set.
|
||||
assert_eq!(choose("orders", "status", Type::Text), Generator::Generic);
|
||||
assert!(is_enum_ish("status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_declared_in_check_still_wins_over_a_built_in_set() {
|
||||
// The CHECK is the user's explicit intent; it precedes the
|
||||
// issue-#34 default set for the same name.
|
||||
let mut spec = ColumnSpec::plain("priority", Type::Text);
|
||||
spec.check_in_values = Some(vec!["p1".into(), "p2".into()]);
|
||||
assert_eq!(
|
||||
choose_generator("tickets", &spec),
|
||||
Generator::PickFrom(vec!["p1".into(), "p2".into()]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_ish_columns_fall_through_to_generic() {
|
||||
// No special generator — generic text + the advisory flags them.
|
||||
|
||||
@@ -149,6 +149,13 @@ pub enum Generator {
|
||||
Age,
|
||||
/// A small positive integer (quantities, counts).
|
||||
SmallInt,
|
||||
/// A plausible recent year as a plain `int` — `year` / `*_year` /
|
||||
/// `published` / `founded` columns (issue #33). Bounded window so the
|
||||
/// type-based `int` fallback can't emit nonsense like `9419`.
|
||||
YearRecent,
|
||||
/// A plausible birth year as a plain `int` — `birth_year` and kin
|
||||
/// (issue #33), the year-typed sibling of [`Self::DateAdult`].
|
||||
YearBirth,
|
||||
// — Temporal (bounded windows, D8) —
|
||||
/// A date within the last few years.
|
||||
DateRecent,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: src/app.rs
|
||||
assertion_line: 5844
|
||||
expression: block
|
||||
---
|
||||
Hint
|
||||
What: Add one or more rows to a table.
|
||||
Example: insert into Customers values ('Ann', 'ann@example.io')
|
||||
Concept: A row is one record; each value lines up with a column, in order. Columns typed `serial`/`shortid` fill themselves — leave them out.
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
---
|
||||
source: src/output_render.rs
|
||||
expression: out
|
||||
---
|
||||
Customers
|
||||
┌──────┬────────┬─────────────┐
|
||||
│ Name │ Type │ Constraints │
|
||||
├──────┼────────┼─────────────┤
|
||||
│ id │ serial │ PK │
|
||||
└──────┴────────┴─────────────┘
|
||||
Referenced by:
|
||||
Orders.cust_id → id (cust_orders, on delete cascade, on update no action)
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2326
|
||||
assertion_line: 2839
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
@@ -26,4 +26,4 @@ expression: snapshot
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · mode simple switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2309
|
||||
assertion_line: 2822
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
@@ -22,8 +22,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│ │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` │
|
||||
│for SQL │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2317
|
||||
assertion_line: 2830
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
@@ -22,8 +22,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│ │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` │
|
||||
│for SQL │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
|
||||
|
||||
+3
-2
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 3445
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -23,8 +24,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 3391
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -23,8 +24,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 3381
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -23,8 +24,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 3434
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -23,8 +24,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 3460
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ────────────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -23,8 +24,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` for SQL │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2369
|
||||
assertion_line: 2882
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
@@ -26,4 +26,4 @@ expression: snapshot
|
||||
│insert into <Table> [(<col>[, ...])] [values] (<value>[, ...]) │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
F1 hint · Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2967
|
||||
assertion_line: 3347
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Tables ───────────────────────────────────╮ ─────────────────────────────────╮
|
||||
@@ -22,8 +22,8 @@ expression: snapshot
|
||||
╰───────────────────────────────────────────╯ │
|
||||
╭ Relationships ────────────────────────────╮ ─────────────────────────────────╯
|
||||
│Customers_Orders │ ─────────────────────────────────╮
|
||||
│ Customers.id -> │ ` for a list │
|
||||
│ Customers.id -> │ ` for a list · `mode advanced` │
|
||||
│ Orders.customer_id │ │
|
||||
╰───────────────────────────────────────────╯ ─────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2385
|
||||
assertion_line: 2898
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
@@ -26,4 +26,4 @@ expression: snapshot
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · Backspace cancel one-shot · Ctrl-C quit
|
||||
F1 hint · Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2679
|
||||
assertion_line: 3102
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -22,8 +22,8 @@ expression: snapshot
|
||||
╰──────────────────────────╯│ │
|
||||
╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
│(none) │╭ Hint ──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ││Type a command — press Tab for options, `help` for a list │
|
||||
│ ││ │
|
||||
│ ││Type a command — press Tab for options, `help` for a list · `mode advanced` │
|
||||
│ ││for SQL │
|
||||
╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2399
|
||||
assertion_line: 2912
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────────────────────────╮
|
||||
@@ -22,8 +22,8 @@ expression: snapshot
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭ Hint ────────────────────────────────────────────────────────────────────────╮
|
||||
│Type a command — press Tab for options, `help` for a list │
|
||||
│ │
|
||||
│Type a command — press Tab for options, `help` for a list · `mode advanced` │
|
||||
│for SQL │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2789
|
||||
assertion_line: 3212
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮
|
||||
@@ -22,8 +22,8 @@ expression: snapshot
|
||||
╰──────────────────────────╯│ │
|
||||
╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
│Customers_Orders │╭ Hint ──────────────────────────────────────────────────────────────────────────╮
|
||||
│ Customers.id -> ││Type a command — press Tab for options, `help` for a list │
|
||||
│ Orders.customer_id ││ │
|
||||
│ Customers.id -> ││Type a command — press Tab for options, `help` for a list · `mode advanced` │
|
||||
│ Orders.customer_id ││for SQL │
|
||||
╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||
Ctrl-O sidebar · Tab complete · ↑ history · F1 hint · Enter run
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
source: src/ui.rs
|
||||
assertion_line: 2265
|
||||
assertion_line: 2616
|
||||
expression: snapshot
|
||||
---
|
||||
╭ Output ──────────────────────────────────────────────────╮
|
||||
@@ -46,4 +46,4 @@ expression: snapshot
|
||||
│with `mode advanced`, or prefix the line with `:` to run… │
|
||||
╰──────────────────────────────────────────────────────────╯
|
||||
Project: Term Planner
|
||||
Enter submit · : advanced once · mode advanced switch ·
|
||||
F1 hint · Esc clear · Ctrl-A/E home/end · Ctrl-W del w
|
||||
|
||||
@@ -275,13 +275,15 @@ fn render_nav_sidebar_overlay(app: &mut App, theme: &Theme, frame: &mut Frame<'_
|
||||
render_relationships_panel(app, theme, frame, parts[1]);
|
||||
}
|
||||
|
||||
/// Border style for a sidebar panel: an accented, bold border when it
|
||||
/// holds navigation focus (ADR-0046 DC3), the muted border otherwise.
|
||||
/// Border style for a sidebar panel: a non-bold **accent colour**
|
||||
/// border when it holds navigation focus (ADR-0046 DC3, refined by
|
||||
/// Amendment 1 / issue #25), the muted border otherwise. The focus
|
||||
/// cue is the accent hue, NOT `Modifier::BOLD` — bold box-drawing
|
||||
/// glyphs render as broken/gapped line-art in the asciinema player
|
||||
/// and are fragile in some terminals.
|
||||
fn panel_border_style(theme: &Theme, focused: bool) -> Style {
|
||||
if focused {
|
||||
Style::default()
|
||||
.fg(theme.fg)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
Style::default().fg(theme.mode_simple)
|
||||
} else {
|
||||
Style::default().fg(theme.border)
|
||||
}
|
||||
@@ -1692,7 +1694,19 @@ fn resolve_hint_lines(
|
||||
(None, Some(crate::input_render::AmbientHint::Candidates { items, selected })) => {
|
||||
vec![render_candidate_line(&items, selected, inner, theme)]
|
||||
}
|
||||
(None, None) => prose(&crate::t!("panel.hint_empty")),
|
||||
// Empty input: the base prompt, plus — in simple mode only — a
|
||||
// pointer to advanced mode (ADR-0051, issue #27), since the
|
||||
// `mode advanced` switch left the keybinding strip. Advanced
|
||||
// mode shows no pointer: users know how they reached it, and
|
||||
// `help` covers the way back. (One-shot never reaches here — its
|
||||
// `:` makes the input non-empty → ambient path.)
|
||||
(None, None) => {
|
||||
let mut text = crate::t!("panel.hint_empty");
|
||||
if matches!(app.effective_mode(), EffectiveMode::Simple) {
|
||||
text.push_str(&crate::t!("panel.hint_mode_advanced"));
|
||||
}
|
||||
prose(&text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1843,6 +1857,66 @@ fn render_candidate_line(
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
/// The keybinding strip is keystrokes-only and **state-selected**
|
||||
/// (ADR-0051, issue #27): it advertises the keys for the user's *current*
|
||||
/// interaction, chosen by priority — first matching state wins.
|
||||
///
|
||||
/// Returns `(key, label)` pairs (label localised via `t!`); the renderer
|
||||
/// is a thin span builder over this list, so the binding sets are
|
||||
/// unit-testable without a `Frame`. Mode-switch / `:` advertisements
|
||||
/// deliberately leave the strip — they are typed commands, not
|
||||
/// keystrokes — and move to the empty-input hint (`resolve_hint_lines`).
|
||||
fn status_bar_bindings(app: &App) -> Vec<(&'static str, String)> {
|
||||
// 1. Sidebar focus (Ctrl-O): the input is occluded by the overlay,
|
||||
// so the panel-scroll keys win outright (ADR-0046).
|
||||
if app.nav_focus.in_sidebar() {
|
||||
return vec![
|
||||
("Ctrl-O", crate::t!("shortcut.next_pane")),
|
||||
("↑↓/PgUp/PgDn", crate::t!("shortcut.scroll")),
|
||||
("Esc", crate::t!("shortcut.to_input")),
|
||||
];
|
||||
}
|
||||
// 2. A live Tab-completion memo (ADR-0022): cycling keys. Pressing
|
||||
// Up clears the memo, so this never co-occurs with state 3.
|
||||
if app.last_completion.is_some() {
|
||||
return vec![
|
||||
("Tab/Shift-Tab", crate::t!("shortcut.cycle")),
|
||||
("Esc", crate::t!("shortcut.cancel")),
|
||||
("Enter", crate::t!("shortcut.run")),
|
||||
];
|
||||
}
|
||||
// 3. Browsing recalled history (unedited): browse keys. Editing the
|
||||
// recalled line ends navigation, dropping to state 4.
|
||||
if app.is_browsing_history() {
|
||||
return vec![
|
||||
("↑↓", crate::t!("shortcut.browse")),
|
||||
("Esc", crate::t!("shortcut.clear")),
|
||||
("Enter", crate::t!("shortcut.run")),
|
||||
];
|
||||
}
|
||||
// 4. Editing — the input has text: F1 (the contextual hint for what
|
||||
// you're typing, ADR-0053) leads, then the readline edit keys
|
||||
// (ADR-0049). Ctrl-K/U remain unadvertised muscle memory.
|
||||
if !app.input.is_empty() {
|
||||
return vec![
|
||||
("F1", crate::t!("shortcut.hint")),
|
||||
("Esc", crate::t!("shortcut.clear")),
|
||||
("Ctrl-A/E", crate::t!("shortcut.home_end")),
|
||||
("Ctrl-W", crate::t!("shortcut.del_word")),
|
||||
("Enter", crate::t!("shortcut.run")),
|
||||
];
|
||||
}
|
||||
// 5. Default — empty input, Input focus. F1 here expands on the most
|
||||
// recent error, or points the user at getting started (ADR-0053).
|
||||
vec![
|
||||
("Ctrl-O", crate::t!("shortcut.nav")),
|
||||
("Tab", crate::t!("shortcut.complete")),
|
||||
("↑", crate::t!("shortcut.history")),
|
||||
("F1", crate::t!("shortcut.hint")),
|
||||
("Enter", crate::t!("shortcut.run")),
|
||||
]
|
||||
}
|
||||
|
||||
fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||||
let key_style = Style::default()
|
||||
.fg(theme.fg)
|
||||
@@ -1853,35 +1927,14 @@ fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect
|
||||
|
||||
let separator = Span::styled(" · ", sep_style);
|
||||
let mut spans: Vec<Span<'_>> = Vec::new();
|
||||
|
||||
let push_shortcut = |spans: &mut Vec<Span<'_>>, key: &'static str, label: &str| {
|
||||
for (key, label) in status_bar_bindings(app) {
|
||||
if !spans.is_empty() {
|
||||
spans.push(separator.clone());
|
||||
}
|
||||
spans.push(Span::styled(key, key_style));
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(label.to_string(), label_style));
|
||||
};
|
||||
|
||||
let submit = crate::t!("shortcut.submit");
|
||||
push_shortcut(&mut spans, "Enter", &submit);
|
||||
let switch = crate::t!("shortcut.switch");
|
||||
let advanced_once = crate::t!("shortcut.advanced_once");
|
||||
let cancel_one_shot = crate::t!("shortcut.cancel_one_shot");
|
||||
let quit = crate::t!("shortcut.quit");
|
||||
match app.effective_mode() {
|
||||
EffectiveMode::Simple => {
|
||||
push_shortcut(&mut spans, ":", &advanced_once);
|
||||
push_shortcut(&mut spans, "mode advanced", &switch);
|
||||
}
|
||||
EffectiveMode::AdvancedPersistent => {
|
||||
push_shortcut(&mut spans, "mode simple", &switch);
|
||||
}
|
||||
EffectiveMode::AdvancedOneShot => {
|
||||
push_shortcut(&mut spans, "Backspace", &cancel_one_shot);
|
||||
}
|
||||
spans.push(Span::styled(label, label_style));
|
||||
}
|
||||
push_shortcut(&mut spans, "Ctrl-C", &quit);
|
||||
|
||||
let paragraph = Paragraph::new(Line::from(spans)).style(bar_style);
|
||||
frame.render_widget(paragraph, area);
|
||||
@@ -2580,6 +2633,168 @@ mod tests {
|
||||
.expect("hint bottom border present")
|
||||
}
|
||||
|
||||
// ---- ADR-0051 (issue #27): context- and state-aware strip ----
|
||||
|
||||
fn key_event(code: crossterm::event::KeyCode) -> crate::event::AppEvent {
|
||||
crate::event::AppEvent::Key(crossterm::event::KeyEvent::new(
|
||||
code,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
))
|
||||
}
|
||||
|
||||
/// The `key` column of the strip's bindings, in order.
|
||||
fn strip_keys(app: &App) -> Vec<&'static str> {
|
||||
status_bar_bindings(app).into_iter().map(|(k, _)| k).collect()
|
||||
}
|
||||
|
||||
/// The full rendered strip text (keys + labels + separators).
|
||||
fn strip_text(app: &App) -> String {
|
||||
status_bar_bindings(app)
|
||||
.iter()
|
||||
.map(|(k, l)| format!("{k} {l}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ")
|
||||
}
|
||||
|
||||
fn hint_text(lines: &[Line<'_>]) -> String {
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| l.spans.iter().map(|s| s.content.clone()).collect::<String>())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_default_state_is_nav_complete_history_run() {
|
||||
let app = App::new();
|
||||
assert_eq!(strip_keys(&app), vec!["Ctrl-O", "Tab", "↑", "F1", "Enter"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_editing_state_surfaces_readline_keys() {
|
||||
// Input has text (no completion/history transient) → the #29
|
||||
// editing keys (ADR-0049).
|
||||
let mut app = App::new();
|
||||
app.input.push_str("create ta");
|
||||
assert_eq!(
|
||||
strip_keys(&app),
|
||||
vec!["F1", "Esc", "Ctrl-A/E", "Ctrl-W", "Enter"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_sidebar_focus_state_is_pane_scroll_input() {
|
||||
let mut app = App::new();
|
||||
app.nav_focus = NavFocus::SidebarTables;
|
||||
assert_eq!(
|
||||
strip_keys(&app),
|
||||
vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"],
|
||||
);
|
||||
// ...and the relationships sidebar is the same state.
|
||||
app.nav_focus = NavFocus::SidebarRelationships;
|
||||
assert_eq!(strip_keys(&app), vec!["Ctrl-O", "↑↓/PgUp/PgDn", "Esc"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_completion_memo_state_is_cycle_cancel_run() {
|
||||
// Drive the real flow: `show ` + Tab leaves a multi-candidate
|
||||
// memo (ADR-0022). The strip must win over the editing state.
|
||||
let mut app = App::new();
|
||||
for c in "show ".chars() {
|
||||
app.update(key_event(crossterm::event::KeyCode::Char(c)));
|
||||
}
|
||||
app.update(key_event(crossterm::event::KeyCode::Tab));
|
||||
assert!(app.last_completion.is_some(), "memo set by Tab");
|
||||
assert!(!app.input.is_empty(), "input non-empty — would be editing");
|
||||
assert_eq!(
|
||||
strip_keys(&app),
|
||||
vec!["Tab/Shift-Tab", "Esc", "Enter"],
|
||||
"completion state wins over editing",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_history_navigation_state_is_browse_clear_run() {
|
||||
// Submit a command, then Up to recall it — `history_cursor` is
|
||||
// set, input is the (non-empty) recalled line, no memo.
|
||||
let mut app = App::new();
|
||||
for c in "drop table T".chars() {
|
||||
app.update(key_event(crossterm::event::KeyCode::Char(c)));
|
||||
}
|
||||
app.update(key_event(crossterm::event::KeyCode::Enter)); // submit
|
||||
app.update(key_event(crossterm::event::KeyCode::Up)); // recall
|
||||
assert!(app.is_browsing_history(), "browsing recalled history");
|
||||
assert!(app.last_completion.is_none(), "no completion memo");
|
||||
assert_eq!(
|
||||
strip_keys(&app),
|
||||
vec!["↑↓", "Esc", "Enter"],
|
||||
"history state wins over editing",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_strip_state_fits_the_eighty_column_budget() {
|
||||
// ADR-0051 §3: the strips are kept lean by construction — the
|
||||
// longest must fit an 80-col status line, so no graceful-drop
|
||||
// machinery is needed. A future over-long strip fails here.
|
||||
let sidebar = {
|
||||
let mut a = App::new();
|
||||
a.nav_focus = NavFocus::SidebarTables;
|
||||
a
|
||||
};
|
||||
let editing = {
|
||||
let mut a = App::new();
|
||||
a.input.push('x');
|
||||
a
|
||||
};
|
||||
for app in [&App::new(), &sidebar, &editing] {
|
||||
let text = strip_text(app);
|
||||
assert!(
|
||||
text.chars().count() <= 80,
|
||||
"strip {} cols > 80: {text:?}",
|
||||
text.chars().count(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_hint_advertises_advanced_mode_in_simple() {
|
||||
let app = App::new();
|
||||
// Wide width so the pointer never wrap-splits.
|
||||
let text = hint_text(&resolve_hint_lines(&app, &Theme::dark(), 200, 3));
|
||||
assert!(
|
||||
text.contains("`mode advanced` for SQL"),
|
||||
"simple empty hint carries the advanced pointer:\n{text}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_mode_empty_hint_has_no_mode_pointer() {
|
||||
// ADR-0051: advanced mode shows no mode pointer (users know how
|
||||
// they got there; `help` covers the way back).
|
||||
let mut app = App::new();
|
||||
app.mode = Mode::Advanced;
|
||||
let text = hint_text(&resolve_hint_lines(&app, &Theme::dark(), 200, 3));
|
||||
assert!(
|
||||
!text.contains("mode simple") && !text.contains("mode advanced"),
|
||||
"advanced empty hint carries no mode pointer:\n{text}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_replaces_the_empty_hint_mode_pointer() {
|
||||
// Non-empty input → ambient hint path, not the empty-hint
|
||||
// mode pointer.
|
||||
let mut app = App::new();
|
||||
app.input.push_str("create table");
|
||||
app.input_cursor = app.input.len();
|
||||
let text = hint_text(&resolve_hint_lines(&app, &Theme::dark(), 200, 3));
|
||||
assert!(
|
||||
!text.contains("for SQL"),
|
||||
"no mode pointer once typing:\n{text}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_wrapped_truncates_with_ellipsis_past_max() {
|
||||
// ≤ max rows: untouched.
|
||||
@@ -3027,16 +3242,76 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn focused_panel_gets_an_accent_border() {
|
||||
// ADR-0046 DC3: the focused sidebar panel is accent-bordered.
|
||||
// ADR-0046 DC3 (Amendment 1, issue #25): the focused sidebar
|
||||
// panel is marked by a non-bold accent COLOUR, not bold. Bold
|
||||
// box-drawing glyphs render as broken/gapped line-art in the
|
||||
// asciinema player (and are fragile in some terminals), so the
|
||||
// focus cue is the accent hue against the muted unfocused
|
||||
// border — never a `Modifier::BOLD` on the border.
|
||||
let theme = Theme::dark();
|
||||
let focused = panel_border_style(&theme, true);
|
||||
let normal = panel_border_style(&theme, false);
|
||||
assert_eq!(focused.fg, Some(theme.fg));
|
||||
assert!(focused.add_modifier.contains(Modifier::BOLD));
|
||||
assert_eq!(focused.fg, Some(theme.mode_simple));
|
||||
assert!(
|
||||
!focused.add_modifier.contains(Modifier::BOLD),
|
||||
"the focused border must NOT be bold (issue #25)",
|
||||
);
|
||||
assert_eq!(normal.fg, Some(theme.border));
|
||||
assert!(!normal.add_modifier.contains(Modifier::BOLD));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focused_panel_border_cells_are_accent_colour_not_bold() {
|
||||
// Full-stack guard for issue #25: the accent colour (and the
|
||||
// absence of bold) must reach the actual rendered border cells,
|
||||
// not just `panel_border_style` in isolation. With the Tables
|
||||
// panel focused, its box-drawing border cells carry
|
||||
// `theme.mode_simple` and never `Modifier::BOLD`; with no panel
|
||||
// focused, no border cell wears the accent colour.
|
||||
const BOX_DRAWING: &[char] = &['╭', '╮', '╰', '╯', '─', '│'];
|
||||
let is_border = |sym: &str| sym.chars().all(|c| BOX_DRAWING.contains(&c));
|
||||
let theme = Theme::dark();
|
||||
|
||||
let mut app = App::new();
|
||||
app.tables = vec!["Customers".to_string(), "Orders".to_string()];
|
||||
app.nav_focus = NavFocus::SidebarTables;
|
||||
let buf = render_to_buffer(&mut app, &theme, 110, 24);
|
||||
let mut accent_border_cells = 0;
|
||||
for y in 0..buf.area.height {
|
||||
for x in 0..buf.area.width {
|
||||
let cell = &buf[(x, y)];
|
||||
if is_border(cell.symbol()) && cell.fg == theme.mode_simple {
|
||||
accent_border_cells += 1;
|
||||
assert!(
|
||||
!cell.modifier.contains(Modifier::BOLD),
|
||||
"focused border cell at ({x},{y}) must not be bold (issue #25)",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
accent_border_cells > 0,
|
||||
"the focused Tables panel must render accent-coloured border cells",
|
||||
);
|
||||
|
||||
// With nothing focused (Input), no border cell wears the accent.
|
||||
let mut app2 = App::new();
|
||||
app2.tables = vec!["Customers".to_string()];
|
||||
app2.nav_focus = NavFocus::Input;
|
||||
let buf2 = render_to_buffer(&mut app2, &theme, 110, 24);
|
||||
for y in 0..buf2.area.height {
|
||||
for x in 0..buf2.area.width {
|
||||
let cell = &buf2[(x, y)];
|
||||
if is_border(cell.symbol()) {
|
||||
assert_ne!(
|
||||
cell.fg, theme.mode_simple,
|
||||
"no border cell may wear the focus accent when nothing is focused (at {x},{y})",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focused_tables_panel_scrolls_and_clamps() {
|
||||
// ADR-0046 DC3: more tables than fit → a large offset reveals the
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,7 +15,7 @@ use rdbms_playground::db::Database;
|
||||
use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, RowFilter, Type, Value};
|
||||
use rdbms_playground::persistence::Persistence;
|
||||
use rdbms_playground::project::{
|
||||
self, DATA_DIR, HISTORY_LOG, PROJECT_YAML,
|
||||
self, DATA_DIR, PROJECT_YAML,
|
||||
};
|
||||
|
||||
fn tempdir() -> tempfile::TempDir {
|
||||
@@ -44,11 +44,6 @@ fn open_project(
|
||||
(project, db, path)
|
||||
}
|
||||
|
||||
fn read_history(project_path: &Path) -> Vec<String> {
|
||||
let body = fs::read_to_string(project_path.join(HISTORY_LOG)).unwrap_or_default();
|
||||
body.lines().map(str::to_string).collect()
|
||||
}
|
||||
|
||||
fn read_yaml(project_path: &Path) -> String {
|
||||
fs::read_to_string(project_path.join(PROJECT_YAML)).expect("project.yaml")
|
||||
}
|
||||
@@ -82,9 +77,9 @@ fn create_table_writes_yaml_and_history() {
|
||||
assert!(yaml.contains("type: serial"), "yaml: {yaml}");
|
||||
assert!(yaml.contains("type: text"), "yaml: {yaml}");
|
||||
|
||||
let history = read_history(&path);
|
||||
assert_eq!(history.len(), 1, "expected one history line; got {history:?}");
|
||||
assert!(history[0].ends_with("|ok|create table Customers with pk id(serial)"));
|
||||
// ADR-0052: journaling moved to the dispatch layer (the worker no
|
||||
// longer writes history.log); this test verifies only the yaml state.
|
||||
// Journaling is covered by the history.rs/app.rs/replay tests.
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -119,11 +114,8 @@ fn insert_writes_csv_and_history() {
|
||||
assert_eq!(lines[0], "id,Name");
|
||||
assert_eq!(lines[1], "1,Alice");
|
||||
|
||||
let history = read_history(&path);
|
||||
assert!(
|
||||
history.iter().any(|l| l.ends_with("|ok|insert into Customers ('Alice')")),
|
||||
"history missing insert: {history:?}",
|
||||
);
|
||||
// ADR-0052: journaling moved off the worker; this test verifies the
|
||||
// csv state only (journaling covered elsewhere).
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -322,39 +314,6 @@ fn delete_all_rows_removes_csv() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_table_appends_history_only() {
|
||||
let data = tempdir();
|
||||
let (_p, db, path) = open_project(&data);
|
||||
|
||||
rt().block_on(async {
|
||||
db.create_table(
|
||||
"Customers".to_string(),
|
||||
vec![ColumnSpec::new("id".to_string(), Type::Serial)],
|
||||
vec!["id".to_string()],
|
||||
Some("create table Customers with pk id(serial)".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let yaml_before = read_yaml(&path);
|
||||
db.describe_table(
|
||||
"Customers".to_string(),
|
||||
Some("show table Customers".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let yaml_after = read_yaml(&path);
|
||||
// YAML body did not change for a read-only command.
|
||||
assert_eq!(yaml_before, yaml_after);
|
||||
});
|
||||
|
||||
let history = read_history(&path);
|
||||
assert!(
|
||||
history.iter().any(|l| l.ends_with("|ok|show table Customers")),
|
||||
"history missing show entry: {history:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_command_does_not_append_history_or_change_yaml() {
|
||||
let data = tempdir();
|
||||
@@ -387,13 +346,8 @@ fn failed_command_does_not_append_history_or_change_yaml() {
|
||||
assert_eq!(yaml_before, yaml_after, "failed cmd must not change yaml");
|
||||
});
|
||||
|
||||
let history = read_history(&path);
|
||||
// Only the first (successful) create_table should have logged.
|
||||
let create_count = history
|
||||
.iter()
|
||||
.filter(|l| l.contains("|ok|create table Customers"))
|
||||
.count();
|
||||
assert_eq!(create_count, 1, "expected exactly one logged create; got: {history:?}");
|
||||
// ADR-0052: journaling moved off the worker; this test now verifies
|
||||
// only that a failed command does not change the yaml state.
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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,15 +173,12 @@ fn rebuild_against_populated_db_wipes_and_reloads() {
|
||||
.expect("rebuild");
|
||||
});
|
||||
let rows = rt()
|
||||
.block_on(async { db.query_data("Customers".to_string(), None, None, None).await })
|
||||
.block_on(async { db.query_data("Customers".to_string(), None, None).await })
|
||||
.unwrap();
|
||||
assert_eq!(rows.rows.len(), 1);
|
||||
assert_eq!(rows.rows[0][1].as_deref(), Some("Edna"));
|
||||
|
||||
// history.log should contain the rebuild entry.
|
||||
let history = fs::read_to_string(project_path.join("history.log")).unwrap();
|
||||
assert!(
|
||||
history.lines().any(|l| l.ends_with("|ok|rebuild")),
|
||||
"history.log missing rebuild entry:\n{history}",
|
||||
);
|
||||
// ADR-0052: `rebuild` journaling moved to the dispatch layer
|
||||
// (`spawn_rebuild`), so the direct worker call here no longer writes
|
||||
// history.log; this test verifies the wipe/reload behaviour only.
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -141,9 +141,9 @@ fn read_recent_history_returns_appended_entries_in_order() {
|
||||
let tmp = tempdir();
|
||||
let project = Project::create_temp(tmp.path()).unwrap();
|
||||
let p = Persistence::new(project.path().to_path_buf());
|
||||
p.append_history("create table A with pk").unwrap();
|
||||
p.append_history("create table B with pk").unwrap();
|
||||
p.append_history("create table C with pk").unwrap();
|
||||
p.append_history("create table A with pk", false).unwrap();
|
||||
p.append_history("create table B with pk", false).unwrap();
|
||||
p.append_history("create table C with pk", false).unwrap();
|
||||
let entries = p.read_recent_history(10).unwrap();
|
||||
assert_eq!(
|
||||
entries,
|
||||
@@ -165,9 +165,9 @@ fn hydration_reads_both_ok_and_err_records() {
|
||||
let tmp = tempdir();
|
||||
let project = Project::create_temp(tmp.path()).unwrap();
|
||||
let p = Persistence::new(project.path().to_path_buf());
|
||||
p.append_history("create table A with pk").unwrap();
|
||||
p.append_history_failure("insert into A (1, 2, 3)").unwrap();
|
||||
p.append_history("show data A").unwrap();
|
||||
p.append_history("create table A with pk", false).unwrap();
|
||||
p.append_history_failure("insert into A (1, 2, 3)", false).unwrap();
|
||||
p.append_history("show data A", false).unwrap();
|
||||
let entries = p.read_recent_history(10).unwrap();
|
||||
assert_eq!(
|
||||
entries,
|
||||
@@ -194,6 +194,32 @@ fn seed_history_replaces_in_memory_history() {
|
||||
assert_eq!(app.history, vec!["x".to_string(), "y".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_command_journalled_then_hydrated_recalls_with_colon_in_simple() {
|
||||
// ADR-0052 (issue #30) — the headline cross-session regression: an
|
||||
// advanced command journalled `ok:adv`, then hydrated on a fresh
|
||||
// session, recalls WITH its `:` so it re-runs in simple mode. (Before
|
||||
// the fix, the `:` was lost on disk and the command came back bare.)
|
||||
let tmp = tempdir();
|
||||
let project = Project::create_temp(tmp.path()).unwrap();
|
||||
let p = Persistence::new(project.path().to_path_buf());
|
||||
// The dispatch layer journals the canonical source + advanced flag.
|
||||
p.append_history("select * from T", true).unwrap();
|
||||
p.append_history("create table T with pk", false).unwrap();
|
||||
|
||||
// Fresh session: hydrate the ring from disk.
|
||||
let entries = p.read_recent_history(10).unwrap();
|
||||
let mut app = App::new();
|
||||
app.seed_history(entries);
|
||||
|
||||
// In simple mode the simple command recalls bare, the advanced one
|
||||
// recalls `:`-prefixed (runnable via the one-shot escape).
|
||||
app.update(key(KeyCode::Up));
|
||||
assert_eq!(app.input, "create table T with pk");
|
||||
app.update(key(KeyCode::Up));
|
||||
assert_eq!(app.input, ": select * from T");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_history_preserves_chronological_order_for_navigation() {
|
||||
let mut app = App::new();
|
||||
|
||||
+6
-6
@@ -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)");
|
||||
}
|
||||
|
||||
|
||||
+117
-18
@@ -281,6 +281,123 @@ fn seed_populates_a_table_and_persists_rows() {
|
||||
assert!(csv.contains('@'), "seeded emails should appear in the CSV:\n{csv}");
|
||||
}
|
||||
|
||||
/// Parse a seeded table's CSV into per-column value lists (simple
|
||||
/// comma-split — the values under test carry no commas/quotes).
|
||||
fn csv_columns(csv: &str) -> (Vec<String>, Vec<Vec<String>>) {
|
||||
let mut lines = csv.lines().filter(|l| !l.trim().is_empty());
|
||||
let header: Vec<String> = lines.next().unwrap().split(',').map(str::to_string).collect();
|
||||
let rows: Vec<Vec<String>> =
|
||||
lines.map(|l| l.split(',').map(str::to_string).collect()).collect();
|
||||
(header, rows)
|
||||
}
|
||||
|
||||
fn column_values(csv: &str, col: &str) -> Vec<String> {
|
||||
let (header, rows) = csv_columns(csv);
|
||||
let idx = header.iter().position(|h| h == col).expect("column present");
|
||||
rows.iter().map(|r| r[idx].clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_year_and_choice_set_heuristics() {
|
||||
// Issues #33 (year-like int columns) + #34 (conventional choice
|
||||
// sets). A fixed `--seed` makes the values deterministic; we assert
|
||||
// membership in the bounded windows / value sets rather than exact
|
||||
// strings (robust to RNG-internals changes, still proves the
|
||||
// heuristic fired — the type fallback would produce 9419 / lorem).
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
rt.block_on(db.create_table(
|
||||
"Records".to_string(),
|
||||
vec![
|
||||
ColumnSpec::new("id", Type::Serial),
|
||||
ColumnSpec::new("birth_year", Type::Int),
|
||||
ColumnSpec::new("published", Type::Int),
|
||||
ColumnSpec::new("priority", Type::Text),
|
||||
ColumnSpec::new("severity", Type::Text),
|
||||
ColumnSpec::new("rating", Type::Int),
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
))
|
||||
.expect("create Records");
|
||||
|
||||
rt.block_on(db.seed("Records".into(), None, Some(30), Vec::new(), Some(99), Some("seed Records 30".into())))
|
||||
.expect("seed succeeds");
|
||||
let csv = read_csv(&project, "Records").expect("Records CSV exists");
|
||||
|
||||
for y in column_values(&csv, "birth_year") {
|
||||
let n: i32 = y.parse().expect("birth_year is an int");
|
||||
assert!((1945..=2007).contains(&n), "birth_year {n} must be a plausible birth year");
|
||||
}
|
||||
for y in column_values(&csv, "published") {
|
||||
let n: i32 = y.parse().expect("published is an int");
|
||||
assert!((1950..=2025).contains(&n), "published {n} must be a plausible recent year");
|
||||
}
|
||||
for p in column_values(&csv, "priority") {
|
||||
assert!(["low", "medium", "high"].contains(&p.as_str()), "priority `{p}` must be low/medium/high");
|
||||
}
|
||||
for s in column_values(&csv, "severity") {
|
||||
assert!(
|
||||
["low", "medium", "high", "critical"].contains(&s.as_str()),
|
||||
"severity `{s}` must be low/medium/high/critical",
|
||||
);
|
||||
}
|
||||
for r in column_values(&csv, "rating") {
|
||||
let n: i32 = r.parse().expect("rating is an int");
|
||||
assert!((1..=5).contains(&n), "rating {n} must be 1–5");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_column_fill_uses_choice_set_heuristic() {
|
||||
// The `seed <table>.<column>` column-fill path (an UPDATE over
|
||||
// existing rows) shares `choose_generator`, so issue #34's value
|
||||
// sets apply there too. Insert rows with `priority` left NULL, then
|
||||
// fill just that column and confirm it collapses to the set.
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
rt.block_on(db.create_table(
|
||||
"Tasks".to_string(),
|
||||
vec![
|
||||
ColumnSpec::new("id", Type::Serial),
|
||||
ColumnSpec::new("title", Type::Text),
|
||||
ColumnSpec::new("priority", Type::Text),
|
||||
],
|
||||
vec!["id".to_string()],
|
||||
None,
|
||||
))
|
||||
.expect("create Tasks");
|
||||
for t in ["a", "b", "c", "d"] {
|
||||
rt.block_on(db.insert(
|
||||
"Tasks".to_string(),
|
||||
Some(vec!["title".to_string()]),
|
||||
vec![Value::Text(t.to_string())],
|
||||
None,
|
||||
))
|
||||
.expect("insert row");
|
||||
}
|
||||
|
||||
rt.block_on(db.seed(
|
||||
"Tasks".into(),
|
||||
Some("priority".into()),
|
||||
None,
|
||||
Vec::new(),
|
||||
Some(5),
|
||||
Some("seed Tasks.priority".into()),
|
||||
))
|
||||
.expect("column-fill priority");
|
||||
|
||||
let csv = read_csv(&project, "Tasks").expect("Tasks CSV");
|
||||
let priorities = column_values(&csv, "priority");
|
||||
assert_eq!(priorities.len(), 4, "every existing row is filled:\n{csv}");
|
||||
for p in priorities {
|
||||
assert!(
|
||||
["low", "medium", "high"].contains(&p.as_str()),
|
||||
"column-fill priority `{p}` must be low/medium/high",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_count_defaults_to_twenty() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
@@ -313,24 +430,6 @@ fn seed_is_reproducible_with_a_fixed_seed() {
|
||||
assert_eq!(csv1, csv2, "the same --seed must reproduce identical data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_writes_exactly_one_history_line() {
|
||||
let (project, db, _dir) = open_project_db();
|
||||
let rt = rt();
|
||||
create_people(&db, &rt);
|
||||
|
||||
rt.block_on(db.seed("People".into(), None, Some(5), Vec::new(), Some(1), Some("seed People 5".into())))
|
||||
.expect("seed succeeds");
|
||||
|
||||
let history = std::fs::read_to_string(project.path().join("history.log"))
|
||||
.expect("history.log exists");
|
||||
let seed_lines = history.lines().filter(|l| l.contains("seed People 5")).count();
|
||||
assert_eq!(
|
||||
seed_lines, 1,
|
||||
"a seed of 5 rows must write exactly one history line:\n{history}"
|
||||
);
|
||||
}
|
||||
|
||||
// — FK sampling, empty-parent error, block guard (ADR-0048 D14 / D1) —
|
||||
|
||||
/// `Users(id serial pk, name text)` + `Orders(id serial pk, user_id
|
||||
|
||||
@@ -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()
|
||||
@@ -141,7 +141,7 @@ fn create_unique_index_on_duplicate_data_is_refused() {
|
||||
|
||||
#[test]
|
||||
fn if_not_exists_on_an_existing_name_is_a_noop_and_journalled() {
|
||||
let (p, db, _d) = open(false);
|
||||
let (_p, db, _d) = open(false);
|
||||
let r = rt();
|
||||
make_t(&db, &r);
|
||||
r.block_on(db.sql_create_index(
|
||||
@@ -169,8 +169,8 @@ fn if_not_exists_on_an_existing_name_is_a_noop_and_journalled() {
|
||||
CreateIndexOutcome::Skipped(name) => assert_eq!(name, "ix"),
|
||||
CreateIndexOutcome::Created(_) => panic!("expected Skipped, got Created"),
|
||||
}
|
||||
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log");
|
||||
assert!(log.contains(line), "the skipped create should be journalled; log:\n{log}");
|
||||
// ADR-0052: journaling moved to the dispatch layer; this test now
|
||||
// asserts only the no-op `Skipped` outcome.
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user