44 Commits

Author SHA1 Message Date
claude@clouddev1 8ae0eedd44 Merge branch 'ci'
ci / gate (push) Successful in 3m7s
release / test (push) Successful in 2m43s
release / build (aarch64-pc-windows-gnullvm) (push) Successful in 4m17s
release / build (aarch64-unknown-linux-musl) (push) Successful in 4m15s
release / build (x86_64-pc-windows-gnu) (push) Successful in 4m48s
release / build (x86_64-unknown-linux-musl) (push) Successful in 4m10s
2026-06-15 16:57:18 +00:00
claude@clouddev1 5f28de8ac3 docs(ci): move CI handoff into docs/ci/handoff (avoid main collision)
main independently wrote its own docs/handoff/20260615-handoff-70.md the
same day, so my global-sequence handoff-70 was an add/add conflict waiting
to merge. Relocate it to docs/ci/handoff/20260615-handoff-ci-01.md (its own
namespace, like docs/ci/adr) + a README index. main's handoff-70 is
untouched; the merge becomes conflict-free.
2026-06-15 16:56:50 +00:00
claude@clouddev1 888be16090 docs: handoff 70 — ADR-0052 follow-up + H2 hint shipped (ADR-0053)
ADR-0052 vestigial-source unwind; H2 contextual hint (F1 keybinding +
`hint` command) fully implemented Phases A–D, closing A1 + H2. 4 issues
filed (#35–#38, incl. 3 hint/help deferrals). CI branch merged into main
mid-session (D1 release work now on main). 2499 pass / 1 ignored, clippy
clean.
2026-06-15 16:47:18 +00:00
claude@clouddev1 329adfc935 fix(hint): labelled tier-3 block format + snapshot (ADR-0053 /runda)
Final /runda found the rendered block was three bare unlabelled lines,
deviating from the approved exemplar format. Fix:
- emit_tier3_block now renders a `Hint` heading + aligned `What:` /
  `Example:` / `Concept:` lines (hint.block.* labels); concept stays muted
- lock the format with an insta snapshot (hint_block_insert)
- amend ADR-0053 D2/D4 + exemplars: drop the `Next:` line (tier-2 ambient
  already owns live position-awareness — user-confirmed), align exemplars
  to the shipped format

2499 pass / 1 ignored, clippy clean.
2026-06-15 16:45:47 +00:00
claude@clouddev1 447112b17f feat(hint): H2 Phase D — coverage gate, F1 strip, status flips (ADR-0053)
Completes H2:
- comprehensiveness coverage tests: every REGISTRY command form has a
  hint_id resolving to a hint.cmd.* block, and every runtime error class
  resolves to a hint.err.* block (enforces ADR-0053 D6)
- ADR-0051 keybinding strip advertises F1 in the editing (leads) and
  default states; +shortcut.hint label; 12 full-panel snapshots
  re-accepted (status-bar line only)
- flip ADR-0053 -> implemented, requirements H2 + A1 -> [x], README

2498 pass / 1 ignored, clippy clean.
2026-06-15 16:34:10 +00:00
claude@clouddev1 984bc30256 docs: record CI branch work — D1/D2 done, TT5 partial, handoff 70
requirements.md: D1 (all six cross-platform binaries) and D2 (no-runtime-
deps, per-platform) done; D3 noted (binaries shipped, package managers
pending); TT5 partial (gate + macOS test live; Windows build-only; Tier-4
unwired). CLAUDE.md: add the CI/release decision (-> docs/ci/adr) + update
the deferred list. Adds handoff 70 summarising the pipeline + follow-ups
(incl. the versioning gap).
2026-06-15 16:31:58 +00:00
claude@clouddev1 417cbc8df9 docs(hint): defer pre-submit-diagnostic route + diagnostic.* blocks (ADR-0053)
Phase C scope decision: Diagnostic carries no class key, so the F1
diagnostic route would need a class field threaded through every
diagnostic site (broad change) for marginal value — tier-2 already
surfaces diagnostics and many duplicate the runtime error classes. Defer
the route + the ~33 diagnostic.* tier-3 blocks to issue #38. v1 ships
command-form hints + 9 runtime error-class hints (comprehensive for
those). Updates ADR-0053 D2/D6/Status/OOS + README.
2026-06-15 16:28:54 +00:00
claude@clouddev1 b6b98ad30f feat(hint): H2 Phase C batch 5 — runtime error-class tier-3 hints (ADR-0053)
what/example(fix recipe)/concept for the 9 runtime error classes:
foreign_key parent_side (child_side was the exemplar), unique, not_null,
check, type_mismatch, not_found, already_exists, generic, invalid_value.
Keyed by friendly::error_hint_class; catalogue + keys.rs registered.
+1 spot test; 2496 pass / 1 ignored, clippy clean.
2026-06-15 16:16:49 +00:00
claude@clouddev1 97970f2a2c feat(hint): H2 Phase C batch 4 — advanced-mode SQL tier-3 hints (ADR-0053)
Distinct SQL-syntax hints for the 11 advanced-mode forms: sql create
table / alter table / create index / drop index / drop table / insert /
update / delete, select, with, explain. hint_ids wired on all 11 nodes.

Hardened hint_key_for_input_in_mode for shared entry words: a bare
multi-form entry word defers to tier-2; when the second token isn't a
form word (insert into / update … set), it falls back to the
mode-primary key — so advanced mode resolves to the SQL form and simple
mode to the DSL form. catalogue + keys.rs registered. +2 spot tests +
grammar mode-disambiguation asserts; 2495 pass / 1 ignored, clippy clean.
2026-06-15 16:14:23 +00:00
claude@clouddev1 9c4d520d5c feat(hint): H2 Phase C batch 3 — DML tier-3 hints (ADR-0053)
Per-form hints for querying/changing data: update, delete, show
data/table/tables/relationships/indexes, seed, explain, replay
(insert was the Phase-B exemplar). hint_ids wired on UPDATE/DELETE/
SHOW/SEED/EXPLAIN/REPLAY; catalogue + keys.rs registered. +2 spot
tests (incl. multi-form SHOW disambiguation); 2493 pass / 1 ignored,
clippy clean.
2026-06-15 16:08:57 +00:00
claude@clouddev1 47a08166a4 merging ci branch
build-ci-image / build (push) Successful in 10m58s
ci / gate (push) Successful in 3m8s
2026-06-15 16:07:10 +00:00
claude@clouddev1 6429b56443 feat(hint): H2 Phase C batch 2 — DDL tier-3 hints (ADR-0053)
Per-form hints for the schema-shaping commands: create table, create
m:n, add column/index/constraint, drop table/column/relationship/
index/constraint, rename column, change column (add_relationship was
the Phase-B exemplar). Examples verified against the canonical usage
templates. hint_ids wired on CREATE/CREATE_M2N/DROP/RENAME/CHANGE;
catalogue + keys.rs registered. +2 spot tests (incl. multi-form DROP
disambiguation); 2491 pass / 1 ignored, clippy clean.
2026-06-15 16:05:41 +00:00
claude@clouddev1 4bdfce6250 feat(hint): H2 Phase C batch 1 — app-command tier-3 hints (ADR-0053)
Per-form hints for the 14 app-lifecycle commands (quit/help/hint/
rebuild/save/new/load/export/import/mode/messages/undo/redo/copy),
reference-leaning what/example with concept where it teaches (rebuild,
mode, messages, undo, export, help). hint_ids wired, catalogue + keys.rs
registered. +1 spot test; 2489 pass / 1 ignored, clippy clean.
2026-06-15 16:01:39 +00:00
claude@clouddev1 138e766817 Merge branch 'main' into ci
ci / gate (push) Successful in 3m5s
Bring main's latest (ADRs 0049-0053 + their features) onto the CI branch so
the gate runs against current main before CI lands on main. Clean merge —
ci and main touched disjoint files.
2026-06-15 16:01:09 +00:00
claude@clouddev1 aeb92f56a7 docs(ci): record macOS implementation in ADR-ci-003 (D1 complete)
ci / gate (push) Successful in 3m23s
macOS is no longer deferred — built natively on a Tart (Apple-Silicon)
runner (real hardware → licensed SDK, no grey area). Amendment documents
release-macos.yaml (dispatch-only, needs main), the libiconv de-nix +
ad-hoc re-sign, the runner-label `:host` backend nuance, generation-based
cache pruning, and D2-on-macOS (system libs only). All six D1 targets now
produce artifacts. Updates the deferred list + index entry.
2026-06-15 15:56:38 +00:00
claude@clouddev1 4a5fd1b5c1 feat(hint): H2 Phase B — per-form keying + the three exemplars (ADR-0053)
The first exemplar (`add 1:n relationship`) showed per-node keying is
too coarse for multi-form commands, so revise the mechanism to per-form.

- CommandNode `hint_id: Option<&str>` -> `hint_ids: &[&str]` (mirrors
  usage_ids); hint_key_for_input_in_mode reuses a factored-out
  pick_form_key (shared digit/m:n/suffix form disambiguation with
  usage_key_for_input_in_mode)
- wire INSERT + ADD (all four forms) with hint_ids
- author the three approved exemplars: hint.cmd.insert,
  hint.cmd.add_relationship, hint.err.foreign_key.child_side
  (what/example/concept) + keys.rs registration
- revise ADR-0053 D3 to per-form; record clause-concept hints as a
  deferred extension (issue #37); update README + plan
- +5 tests; 2488 pass / 1 ignored, clippy clean
2026-06-15 12:18:41 +00:00
claude@clouddev1 050b36391e feat(hint): H2 Phase A — hint command + F1 keybinding skeleton (ADR-0053)
The mechanism for the contextual hint, with tier-2 fallback; the
tier-3 corpus lands in later phases.

- new CommandNode `hint_id` field (all None for now)
- AppCommand::Hint + HINT grammar node + REGISTRY + dispatch
- F1 read-only overlay in handle_key (buffer/cursor/memo untouched)
- note_hint* renderers; hint_id_for_input_in_mode (shared selection
  helper refactored out of usage_keys_for_input_in_mode)
- last_error_hint_key + friendly::error_hint_class classifier
- catalogue: help.app.hint / parse.usage.hint / hint.getting_started
- +12 tests; 2483 pass / 1 ignored, clippy clean
2026-06-15 10:36:51 +00:00
claude@clouddev1 9868442889 docs(plan): H2 contextual hint implementation plan (ADR-0053)
Phased build plan: mechanism skeleton with tier-2 fallback first
(hint_id field, AppCommand::Hint, F1 read-only overlay, last_error_hint_key,
note_hint* renderer), then catalogue + the three approved exemplars,
then comprehensive content in batches, then polish. Reuses the existing
command_for_entry_word / usage_keys_for_input_in_mode lookups for
command identification. Test spine includes the comprehensiveness
coverage test that gates "comprehensive for v1".
2026-06-14 22:18:59 +00:00
claude@clouddev1 309d2e0b3f ci: release-macos workflow (dispatch); retire macOS smoke-test
The macOS release leg: workflow_dispatch (tag input) on the Tart runner —
test → build both *-apple-darwin targets → rewrite nix libiconv to /usr/lib
+ ad-hoc re-sign → upload binary + .sha256 to the tagged release (idempotent
create-or-get) → prune the nix store by generation. Composed entirely of
parts the smoke-test proved green, so the smoke-test is removed.

Dispatch-only fits the intermittent runner and keeps the 4-target Linux/
Windows release independent. Becomes triggerable once CI is on the default
branch (workflow_dispatch is default-branch-only in Gitea).
2026-06-14 22:18:02 +00:00
claude@clouddev1 e16ad50aa7 docs(adr): ADR-0053 — contextual hint command + F1 keybinding (H2)
Settles the `hint` slot ADR-0003 left pending; closes the last open
piece of A1. Two surfaces (F1 → live-input hint; `hint` command →
last-error expansion), no topic arg, and a new tier-3 teaching corpus
keyed on a new CommandNode `hint_id` so advanced-SQL forms get distinct
mode-correct content. Comprehensive content for v1, authored
exemplars-first. Refines ADR-0003; references ADR-0019/0021/0022/0049/
0051. Files #36 for the parallel help-side gap.
2026-06-14 22:14:11 +00:00
claude@clouddev1 60dbb903cc ci: macOS smoke-test — run tests + nix-store generation pruning
macos-build-test / build (push) Successful in 2m59s
ci / gate (push) Successful in 3m6s
- Add `cargo test` before the darwin builds (gate is Linux-only; the macOS
  leg is test-then-build) — a full dry-run of release-macos bar the upload.
- Add an `if: always()` prune step. The runner wipes the workspace each run,
  so cargo target/ never accumulates (no sweep). The persistent cache is the
  nix store: record the current toolchain in a persistent profile, keep the
  2 newest generations (nix-env --delete-generations +2), reclaim the rest
  (nix-collect-garbage). Pairs with min-free/max-free in the runner nix.conf.
2026-06-14 22:07:48 +00:00
claude@clouddev1 9a126782f1 ci: de-nix macOS binary libiconv via install_name_tool + re-sign
macos-build-test / build (push) Successful in 2m11s
build-ci-image / build (push) Successful in 9m49s
ci / gate (push) Successful in 2m50s
libiconv is the only /nix/store dep the darwin stdenv bakes in (everything
else is system frameworks + libSystem/libobjc). The smoke-test now rewrites
that load path to /usr/lib/libiconv.2.dylib (ABI-compatible, present on
every Mac), re-signs ad-hoc (install_name_tool breaks the sig; arm64
requires a valid one), then verifies no /nix/store paths remain, the
signature is valid, and the native binary launches. Flake comment updated
to reflect the propagated-libiconv reality.
2026-06-14 21:43:01 +00:00
claude@clouddev1 4d004f5847 ci: drop nix libiconv from darwin build (portable macOS binary)
macos-build-test / build (push) Failing after 1m36s
build-ci-image / build (push) Successful in 9m45s
ci / gate (push) Successful in 2m59s
The smoke-test caught the aarch64 binary linking a /nix/store libiconv.dylib
— non-portable (won't exist on a user's Mac). The Apple SDK already provides
a system libiconv stub, so removing pkgs.libiconv makes the linker resolve
-liconv to /usr/lib instead. The smoke-test now fails if any /nix/store dylib
is linked.
2026-06-14 21:36:08 +00:00
claude@clouddev1 d5fb47bcc8 ci: macOS build smoke-test + flake darwin support
macos-build-test / build (push) Successful in 3m52s
build-ci-image / build (push) Successful in 10m3s
ci / gate (push) Successful in 2m52s
Add the two *-apple-darwin targets to rust-toolchain.toml and apple-sdk +
libiconv to the flake devShell (darwin only) so the nix toolchain links
AppKit; make cargo-zigbuild/zig Linux-only (macOS builds natively). Repoint
the throwaway macOS workflow to actually build both darwin targets through
the flake on the Tart runner — the first real check of the macOS leg, which
can't be verified locally. Delete once release-macos lands.
2026-06-14 21:28:41 +00:00
claude@clouddev1 0878c6df19 ci: target the macOS runner by label name macos (not macos:host)
macos-probe / probe (push) Successful in 57s
ci / gate (push) Successful in 2m46s
In act_runner a label is `<name>:<backend>`; `:host` is the execution-
backend schema (run on host, no container), not part of the label. The
runner registered as `macos:host` therefore has the label `macos`, which
is what runs-on must reference.
2026-06-14 21:21:13 +00:00
claude@clouddev1 52815f1a76 ci: trigger macos-probe on push (workflow_dispatch needs default branch)
ci / gate (push) Successful in 2m48s
macos-probe / probe (push) Has been cancelled
Gitea only exposes workflow_dispatch for workflows on the default branch
(main); our CI is on `ci`, so the manual-run button/API isn't available.
Add a push trigger (filtered to the probe file) so we can drive the macOS
runner test from the ci branch. workflow_dispatch kept for post-merge.
2026-06-14 21:18:25 +00:00
claude@clouddev1 2721bd8d04 ci: macOS (Tart) runner probe — throwaway diagnostic
ci / gate (push) Successful in 3m1s
Manual-dispatch probe on runs-on macos:host to confirm the runner picks up
jobs and report arch / macOS version / Xcode SDK / toolchains (nix, rustup,
cargo) / git+node, before wiring the macOS release leg. Delete once done.
2026-06-14 21:11:28 +00:00
claude@clouddev1 e8fa859ab9 refactor(db): unwind vestigial worker source plumbing (ADR-0052 follow-up)
ADR-0052 moved success journaling out of the worker to the dispatch
layer, leaving the `source` that handlers threaded purely for the
worker's old history.log write dead. Remove it:

- drop `_source` from finalize_persistence and do_rebuild_from_text
- inline + delete the three read-only *_request wrappers
- drop the now-unused `source` param from the ~30 forwarding worker
  handlers (leaf + composite), compiler-guided
- remove the `source` field from the DescribeTable/QueryData/RunSelect
  requests and their DatabaseHandle methods (call sites updated)

The only worker `source` left is the snapshot/undo label
(snapshot_then / stage_pre_mutation / begin_batch). Purely mechanical,
no behaviour change. 2471 pass / 0 fail / 1 ignored, clippy clean.
2026-06-14 13:47:49 +00:00
claude@clouddev1 ae73a4be85 docs: handoff 69 — four issues closed (#27/#28/#29/#30) + ADRs 0049–0052; tracker empty 2026-06-14 12:08:24 +00:00
claude@clouddev1 4aeea55984 feat(history): mode-tagged history + top-of-chain journaling (#30)
Record the submission mode per history entry so advanced commands are
reusable in simple mode, and fix the bug where a ':'-one-shot command
lost its ':' across sessions (ADR-0052, closing #30).

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

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

Amends ADR-0034 (status tag + journaling location), ADR-0015 §6
(history.log out of the worker tx), ADR-0040 (journal-write best-effort).
15 worker-level journaling tests retired, re-covered at the new layer
(history.rs format, app.rs recall matrix, iteration6 cross-session
regression, replay). 2471 pass / 0 fail / 0 skip, clippy clean.
2026-06-14 11:20:55 +00:00
claude@clouddev1 5869eec4f4 docs(ci): ADR-ci-003 — cross-platform release builds (D1 matrix)
Record the multi-platform build strategy as its own decision: cargo-zigbuild
for the four non-macOS targets, the static/standalone posture per platform,
the Windows synchronization stub, the test->build matrix workflow, and the
macOS deferral with its licensing rationale (the public CI image can't carry
the SDK). Shrinks the ci-001 amendment to a pointer; updates the index.

Runtime-verified by the user: Linux x86_64 + Windows aarch64 run correctly.
2026-06-13 19:11:29 +00:00
claude@clouddev1 eceedc19b7 feat(ui): context- and state-aware bottom keybinding strip (#27)
Per ADR-0051 (closing issue #27): the bottom status line is now a
keystrokes-only, state-selected strip. A pure status_bar_bindings()
picks the binding set by priority (first match wins):

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

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

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

Forks user-chosen: editing state shows #29 keys; quit omitted; no
width-drop machinery (longest strip ~65 cols fits; a width-budget test
keeps it lean). Modal-aware strip is OOS (pre-existing). Tests: 9
Tier-1 unit (per-state key sets, width budget, mode pointer), 1 Tier-3
rewritten, 15 full-panel snapshots re-accepted (reviewed). 2467 pass /
0 fail / 0 skip, clippy clean.
2026-06-13 12:18:37 +00:00
claude@clouddev1 298475b326 ci: D1 release matrix over the four non-macOS targets
build-ci-image / build (push) Successful in 9m56s
ci / gate (push) Successful in 2m47s
release / test (push) Successful in 2m18s
release / build (aarch64-pc-windows-gnullvm) (push) Successful in 3m31s
release / build (aarch64-unknown-linux-musl) (push) Successful in 3m52s
release / build (x86_64-pc-windows-gnu) (push) Successful in 4m14s
release / build (x86_64-unknown-linux-musl) (push) Successful in 3m25s
release.yaml becomes test (once, host) -> build (matrix) over the four
cargo-zigbuild targets; each matrix job uploads its binary + .sha256 to
the shared release (idempotent create-or-get). Records the expansion in
ADR-ci-001 (2026-06-13 amendment); macOS stays deferred.
2026-06-13 12:14:49 +00:00
claude@clouddev1 04ebd83f08 build: D1 cross-compile via cargo-zigbuild (4 non-macOS targets)
Replace the single-target musl cc with cargo-zigbuild + zig in the flake
devShell — one universal cross cc/linker (incl. rusqlite's bundled SQLite
C) for all four non-macOS D1 targets, added to rust-toolchain.toml:
  x86_64/aarch64-unknown-linux-musl              (static, D2)
  x86_64-pc-windows-gnu, aarch64-pc-windows-gnullvm  (standalone .exe)

Windows links -lsynchronization (std WaitOnAddress), which rust-overlay's
toolchain and zig's mingw don't ship; the symbols are forwarded by
kernel32, so an empty stub libsynchronization.a (ci/winstub/, wired via
.cargo/config.toml for the windows targets only) satisfies the linker.
Verified: all four build; linux static; windows valid PE32+.
2026-06-13 12:14:49 +00:00
claude@clouddev1 18d08642d7 ci: skip the gate for docs-only changes
ci / gate (push) Successful in 2m31s
Add paths-ignore (docs/**, **/*.md) to the gate's push + pull_request
triggers so markdown/docs-only changes don't run a full clippy+test that
can't change the outcome. Mixed code+docs pushes still gate (not all
files are ignored); flake/toolchain changes are deliberately not ignored.
Also refresh a stale ADR-0049 -> ADR-ci-002 comment reference.
2026-06-12 22:42:50 +00:00
claude@clouddev1 da8bfebc36 docs(ci): establish docs/ci/adr namespace (ci-001 pipeline, ci-002 flake)
ci / gate (push) Successful in 2m33s
Records the CI/release pipeline as ADR-ci-001 and relocates the nix-flake
ADR from main's ADR-0049 to ADR-ci-002 (content unchanged, history note
added). Both live in docs/ci/adr/ with a README index — a dated,
ci-segmented namespace disjoint from main's integer ADR sequence, the
same split the website subproject uses to avoid cross-branch number
collisions. Drops the ADR-0049 entry from docs/adr/README.

ci-001 covers the runner model, the baked nix CI image, the clippy+test
gate, the static-musl release on tag, trigger hygiene, auth, and scope.
2026-06-12 22:38:34 +00:00
claude@clouddev1 89b9392c25 ci: release job — test before publish, pin bash, fix diagnostic
release / release (push) Successful in 3m59s
build-ci-image / build (push) Successful in 7m27s
ci / gate (push) Successful in 2m27s
- Run cargo test before the build so a tag never publishes untested code.
- Pin shell: bash on the scripted steps; the runner defaults to dash,
  which rejected `set -o pipefail` and failed run 22's package step.
- Swap `file` (absent in the slim image) for `ls -l`.
2026-06-12 22:11:24 +00:00
claude@clouddev1 bba24120f1 ci: scope gate + image-build to branch pushes (skip tags)
Tag pushes ignore paths: filters, so a release tag spuriously rebuilt
the unchanged CI image and re-ran the gate on a commit the branch push
already gated. Add branches: ['**'] to both push triggers — tag pushes
no longer fire them (release.yaml owns tags). Pushing commits + a tag
together still gates the commits via the branch push.
2026-06-12 22:11:24 +00:00
claude@clouddev1 88145225cc ci: release workflow — static binary to Gitea releases on tag
build-ci-image / build (push) Has been cancelled
ci / gate (push) Successful in 2m32s
release / release (push) Failing after 3m2s
On a v* tag, builds the x86_64-unknown-linux-musl binary in the CI image
and publishes it (+ .sha256) to a Gitea release via the API and the
auto GITEA_TOKEN. x86_64 Linux only for now; rest of the D1 matrix and
D3 packaging layer on later. Correctness comes from the branch gate.
2026-06-12 21:43:23 +00:00
claude@clouddev1 8e3208528e build: static musl release build capability
rust-toolchain.toml gains the x86_64-unknown-linux-musl target; the
flake devShell gains a musl cc (pkgsCross.musl64) + CC/linker env so a
`cargo build --target …-musl` compiles rusqlite's bundled SQLite C and
links fully static (D2: single static binary, no runtime deps). Cargo
release profile strips symbols (13MB -> 10MB). Verified locally: the
musl binary is static-pie, statically linked, stripped, runs standalone.
2026-06-12 21:43:23 +00:00
claude@clouddev1 9d8161218a ci: gate workflow + CI-image build/push, drop probe
build-ci-image / build (push) Successful in 6m18s
ci / gate (push) Successful in 3m19s
- build-ci-image.yaml: builds .gitea/ci-image/Dockerfile via DinD and
  pushes git.lazyeval.net/oli/rdbms-playground-ci:latest (REGISTRY_*
  secrets); triggers on image-input changes + manual dispatch.
- ci.yaml: the gate — runs inside that image, clippy -D warnings +
  cargo test, on push/PR. fmt intentionally not gated (ADR-0049).

Removes ci-probe.yaml; it answered the runner questions (jobs run in
containers, host nix unreachable, custom container: works).
2026-06-12 21:08:04 +00:00
claude@clouddev1 dc63ed66f1 ci: nix CI toolchain image (node-slim base + warmed flake)
Job-container image the gate runs in. node:22-bookworm-slim satisfies
the act_runner contract (sleep/bash/node) far more cheaply than the
catthehacker images; single-user nix installed on top (pre-create /nix
+ empty build-users-group so it installs as root in a container) with
the flake's devShell pre-warmed — CI enters a ready 1.95.0 toolchain in
~1.4s. Verified by local build. ~5.5GB (rust toolchain closure); dep/
target caching is a noted follow-up.
2026-06-12 21:08:04 +00:00
claude@clouddev1 c7ac0c9877 ci: add throwaway runner-probe workflow
ci-probe / host (push) Failing after 54s
ci-probe / nix-container (push) Failing after 1m12s
Diagnostic to determine how the ci-public runner executes jobs and
where the nix toolchain is reachable (host vs default container vs a
custom container:), so the real gate is built on facts. Delete once
the gate lands.
2026-06-12 20:35:39 +00:00
claude@clouddev1 9189740028 build(nix): reproducible dev + build env via a flake (ADR-0049)
Root flake with two outputs: devShells.default (pinned 1.95.0
toolchain via rust-toolchain.toml + rust-overlay, plus cargo-sweep)
and packages.default (rustPlatform.buildRustPackage from the committed
Cargo.lock; doCheck=false). flake.lock pins nixpkgs nixos-26.05 /
rust-overlay / flake-utils. .envrc (use flake) for direnv parity.

Single source of toolchain for dev and the upcoming CI, so they can't
drift. Verified through the flake: nix build yields a working binary,
clippy clean, 2424 tests pass / 0 fail / 1 intentional ignored doctest.
First step toward requirements.md TT5 + D1/D2/D3.
2026-06-12 20:35:39 +00:00
91 changed files with 5364 additions and 783 deletions
+17
View File
@@ -0,0 +1,17 @@
# Windows cross-link fix for the D1 release matrix (cargo-zigbuild).
#
# Rust's std links `-lsynchronization` on Windows (WaitOnAddress-based thread
# parking). Rust normally satisfies this from the `self-contained` mingw libs
# of its `rust-mingw` component — which rust-overlay does NOT ship — and Zig's
# bundled mingw (used by `cargo zigbuild`) doesn't provide `libsynchronization.a`
# either. The actual symbols are *forwarded by kernel32* (already linked), so an
# empty stub import lib is enough to satisfy the linker. See `ci/winstub/`.
#
# These sections apply ONLY when building for the Windows targets, so host
# builds (the gate's `cargo test`/`clippy`) and the Linux release targets are
# unaffected.
[target.x86_64-pc-windows-gnu]
rustflags = ["-L", "native=ci/winstub"]
[target.aarch64-pc-windows-gnullvm]
rustflags = ["-L", "native=ci/winstub"]
+1
View File
@@ -0,0 +1 @@
use flake
+65
View File
@@ -0,0 +1,65 @@
# CI toolchain image for rdbms-playground.
#
# Purpose: a SMALL job-container image that
# (a) satisfies the Gitea act_runner job-container contract — /bin/sleep (the
# keep-alive entrypoint), bash (run: steps), node (JS actions such as
# actions/checkout); a bare nixos/nix image has none of these and won't
# even start (verified by the ci-probe run: "/bin/sleep: no such file"); and
# (b) carries the project's pinned nix toolchain with the flake's devShell
# pre-warmed, so CI runs `nix develop -c cargo ...` against a warm store.
#
# Base: node:22-bookworm-slim. Debian slim already provides bash + coreutils
# (sleep); the node tag adds the actions runtime. Far smaller than the
# catthehacker runner images (which bundle a whole GitHub-runner emulation we
# don't need).
FROM node:22-bookworm-slim
# nix install + flake eval needs these. git because flakes prefer a VCS context
# and tools shell out to it. Drop apt lists to keep the layer small.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl xz-utils ca-certificates git \
&& rm -rf /var/lib/apt/lists/*
# Single-user nix (--no-daemon): store at /nix owned by root, no daemon/systemd
# needed — the correct mode for a container. The official installer refuses root
# and shells out to `sudo` purely to create /nix; pre-creating it ourselves (we
# ARE root) sidesteps both. Enable flakes globally so every nix invocation (and
# the runner's steps) get nix-command + flakes without flags.
# nix.conf is written FIRST so the installer's own `nix-env` profile step reads
# it: `build-users-group =` (empty) makes single-user nix build as the calling
# user (root) instead of demanding the nixbld group/users a daemon install would
# create; flakes are enabled globally in the same file.
RUN mkdir -m 0755 /nix && chown root:root /nix \
&& mkdir -p /etc/nix \
&& printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf \
&& curl --proto '=https' --tlsv1.2 -sSf -L https://nixos.org/nix/install -o /tmp/nix-install.sh \
&& sh /tmp/nix-install.sh --no-daemon \
&& rm /tmp/nix-install.sh
ENV PATH=/root/.nix-profile/bin:/nix/var/nix/profiles/default/bin:$PATH
# We set PATH directly instead of sourcing the profile, so also point nix at the
# Debian CA bundle (already installed) for substituter HTTPS — otherwise the
# profile-provided NIX_SSL_CERT_FILE is missing and store downloads fail.
ENV NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
# Warm the flake's devShell into the store: realizes nixpkgs + the pinned Rust
# toolchain (rustc/cargo/clippy/rustfmt) + cargo-sweep. Only the inputs that
# determine the shell are copied, so this expensive layer is cached and only
# re-runs when the flake or the toolchain pin changes — not on every source edit.
# (devShell eval is lazy: packages.default — and thus Cargo.toml/Cargo.lock — is
# never forced here, so it needn't be present.)
WORKDIR /warm
COPY flake.nix flake.lock rust-toolchain.toml ./
RUN nix develop -c rustc --version \
&& nix develop -c cargo --version \
&& nix develop -c cargo clippy --version \
&& nix develop -c cargo fmt --version \
&& nix develop -c cargo sweep --version
WORKDIR /
RUN rm -rf /warm
# FOLLOW-UP optimisation (intentionally NOT done here, see CI notes): cargo
# dependency + target caching. Each CI run still compiles the ~296-crate graph
# from scratch and pulls crate sources from crates.io. A later pass can bake
# `cargo fetch` (offline crate sources) and/or a warmed target dir, or wire
# sccache, to cut run time. Correctness/first-green first; speed next.
+51
View File
@@ -0,0 +1,51 @@
# Builds the nix CI toolchain image (.gitea/ci-image/Dockerfile) and pushes it
# to the Gitea registry. The gate (ci.yaml) runs *inside* this image, so this
# workflow is the gate's prerequisite. It only needs to run when the image's
# inputs change — the Dockerfile, the flake, or the toolchain pin — plus on
# manual dispatch.
#
# DinD pattern: plain docker:27-dind (one of the tested ci-test samples). No
# registry proxy here — the runner's containers have direct internet egress
# (the ci-probe run cloned github.com and pulled docker.io with no proxy), and
# this image's RUN steps fetch from apt + nixos.org, which the proxy isn't
# guaranteed to forward. The dind-cached:local + REGISTRY_PROXY_HOST variant is
# a later speed optimisation for base-image pull caching, not needed for green.
name: build-ci-image
on:
push:
# Branch pushes only. Tag pushes ignore `paths:` filters and would rebuild
# the (unchanged) image on every release tag — `branches: ['**']` excludes
# tags, so this runs only when a branch push actually changes an image input.
branches: ['**']
paths:
- '.gitea/ci-image/Dockerfile'
- 'flake.nix'
- 'flake.lock'
- 'rust-toolchain.toml'
- '.gitea/workflows/build-ci-image.yaml'
workflow_dispatch:
jobs:
build:
runs-on: ci-public
services:
docker:
image: docker:27-dind
options: --privileged
env:
DOCKER_TLS_CERTDIR: ""
env:
DOCKER_HOST: tcp://docker:2375
IMAGE: git.lazyeval.net/oli/rdbms-playground-ci
steps:
- uses: actions/checkout@v4
- name: wait for docker
run: until docker version >/dev/null 2>&1; do sleep 1; done
- name: registry login
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" \
| docker login git.lazyeval.net -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
- name: build
run: docker build -f .gitea/ci-image/Dockerfile -t "$IMAGE:latest" .
- name: push
run: docker push "$IMAGE:latest"
+39
View File
@@ -0,0 +1,39 @@
# The CI gate. Runs inside the prebuilt nix toolchain image (built + pushed by
# build-ci-image.yaml), so the pinned 1.95.0 toolchain is already warm — steps
# just enter the flake devShell and run cargo.
#
# Gate = clippy + test. fmt is deliberately NOT gated yet (ADR-ci-002: the tree
# isn't clean under stock rustfmt; revisit on main). The release job (static
# binary for D2) and the platform matrix layer on later, step by step.
name: ci
on:
push:
# Branch pushes only — a tag push hits the same commit the branch push
# already gated, so `branches: ['**']` drops the redundant tag-triggered
# run (the release workflow owns tags). Pushing commits + a tag together
# still gates the commits via the branch push.
branches: ['**']
# Skip the gate for docs-only changes — markdown can't affect clippy/test.
# A push touching code *and* docs still runs (not all files are ignored).
# Note: flake/toolchain changes are NOT ignored — they can shift the
# toolchain and thus lint/test outcomes.
paths-ignore:
- 'docs/**'
- '**/*.md'
pull_request:
paths-ignore:
- 'docs/**'
- '**/*.md'
jobs:
gate:
runs-on: ci-public
# Public package → anonymous pull, no credentials needed.
container:
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
steps:
- uses: actions/checkout@v4
- name: clippy (warnings denied)
run: nix develop -c cargo clippy --all-targets -- -D warnings
- name: test
run: nix develop -c cargo test --no-fail-fast
+95
View File
@@ -0,0 +1,95 @@
# macOS release leg — the two *-apple-darwin binaries, built natively on the
# Tart (Apple-Silicon) runner and attached to an existing Gitea release.
#
# Manual dispatch only: the Mac runner is intermittent, so this is triggered by
# hand (with the Mac up) for a given release tag. The 4-target Linux/Windows
# release (release.yaml) runs on the tag itself and never waits on the Mac, so a
# release always has those four; the macOS two are added by dispatching this.
#
# NOTE: Gitea exposes workflow_dispatch only for workflows on the DEFAULT branch,
# so this becomes triggerable once the CI work is merged to `main`.
name: release-macos
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build the macOS binaries for and attach to (e.g. v0.1.0)'
required: true
jobs:
release-macos:
runs-on: macos
env:
NIX_CONFIG: "experimental-features = nix-command flakes"
TAG: ${{ inputs.tag }}
# Auto-provided by Gitea Actions; has repo write (release) scope.
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1
REPO: ${{ github.repository }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: test
run: nix develop -c cargo test --no-fail-fast
- name: build, de-nix, sign, package + publish
run: |
set -e
mkdir -p dist
for t in aarch64-apple-darwin x86_64-apple-darwin; do
echo "==================== $t ===================="
nix develop -c cargo build --release --target "$t"
f="target/$t/release/rdbms-playground"
# Rewrite the nix-store libiconv load path to the system one, then
# re-sign ad-hoc (install_name_tool invalidates the signature; arm64
# requires a valid one). Guard against any remaining /nix/store dep.
for l in $(otool -L "$f" | awk '/\/nix\/store.*libiconv.*dylib/ {print $1}'); do
install_name_tool -change "$l" /usr/lib/libiconv.2.dylib "$f"
done
codesign --force --sign - "$f"
if otool -L "$f" | grep -q /nix/store; then
echo "ERROR: $t binary links a /nix/store dylib"; exit 1
fi
out="rdbms-playground-$TAG-$t"
cp "$f" "dist/$out"
( cd dist && shasum -a 256 "$out" > "$out.sha256" ) # macOS: shasum, not sha256sum
done
ls -l dist
# Idempotent create-or-get the release (release.yaml likely created it
# already from the tag), then upload the two macOS binaries + checksums.
created=$(curl -sS -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Automated release for $TAG.\"}")
id=$(printf '%s' "$created" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const o=JSON.parse(s);process.stdout.write(String(o.id||""))}catch(e){}})')
if [ -z "$id" ]; then
id=$(curl -sS "$API/repos/$REPO/releases/tags/$TAG" \
-H "Authorization: token $TOKEN" \
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{process.stdout.write(String(JSON.parse(s).id))})')
fi
echo "release id: $id"
for fa in dist/*; do
name=$(basename "$fa")
echo "uploading $name"
curl -sS -X POST "$API/repos/$REPO/releases/$id/assets?name=$name" \
-H "Authorization: token $TOKEN" -F "attachment=@$fa" > /dev/null
done
echo "published macOS assets for $TAG"
- name: prune nix store — keep the last 2 toolchain generations
# The runner wipes the workspace each run, so cargo target/ never
# accumulates. Bound the persistent nix store by generation: record the
# current devShell as a generation of a persistent profile (in $HOME),
# keep the 2 newest, reclaim what older ones referenced.
if: always()
run: |
echo "--- disk before ---"; df -h / | tail -1
P="$HOME/.cache/rdbms-ci/toolchain"
nix develop --profile "$P" -c true || true
nix-env -p "$P" --delete-generations +2 || true
nix-collect-garbage || true
echo "--- disk after ---"; df -h / | tail -1
+92
View File
@@ -0,0 +1,92 @@
# Release: on a version tag, build the cross-platform binaries and publish them
# to a Gitea release with checksums. Runs in the prebuilt CI image, so the
# pinned toolchain + the release targets + cargo-zigbuild/zig are already warm.
#
# Matrix (D1, cross-built from Linux x86_64 via cargo-zigbuild):
# x86_64-unknown-linux-musl aarch64-unknown-linux-musl (static, D2)
# x86_64-pc-windows-gnu aarch64-pc-windows-gnullvm (standalone .exe)
# macOS is deferred — its arboard/AppKit link needs Apple's SDK (see ADR-ci-001).
# D3 package-manager manifests layer on later.
#
# Tests run once (host) before the matrix, so a tag can never publish untested
# code, even one pointing at a commit that was never gated on a branch.
name: release
on:
push:
tags:
- 'v*'
jobs:
test:
runs-on: ci-public
container:
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
steps:
- uses: actions/checkout@v4
- name: test
run: nix develop -c cargo test --no-fail-fast
build:
needs: test
runs-on: ci-public
container:
image: git.lazyeval.net/oli/rdbms-playground-ci:latest
strategy:
fail-fast: false
matrix:
target:
- x86_64-unknown-linux-musl
- aarch64-unknown-linux-musl
- x86_64-pc-windows-gnu
- aarch64-pc-windows-gnullvm
steps:
- uses: actions/checkout@v4
- name: build
run: nix develop -c cargo zigbuild --release --target ${{ matrix.target }}
- name: package + publish
# Pin bash: the runner defaults scripted steps to dash, which rejects
# `set -o pipefail`. bash is in the CI image.
shell: bash
env:
TARGET: ${{ matrix.target }}
# GITEA_TOKEN is auto-provided with repo write (release) scope.
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# Windows targets produce a .exe; the rest a bare binary.
case "$TARGET" in *windows*) EXT=.exe ;; *) EXT= ;; esac
BIN="target/$TARGET/release/rdbms-playground$EXT"
OUT="rdbms-playground-$TAG-$TARGET$EXT"
mkdir -p dist
cp "$BIN" "dist/$OUT"
( cd dist && sha256sum "$OUT" > "$OUT.sha256" )
ls -l dist
# Create the release for this tag; if a sibling matrix job already
# created it, look it up instead (idempotent + race-tolerant).
created=$(curl -sS -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$TAG\",\"name\":\"$TAG\",\"body\":\"Automated release for $TAG.\"}")
id=$(printf '%s' "$created" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const o=JSON.parse(s);process.stdout.write(String(o.id||""))}catch(e){}})')
if [ -z "$id" ]; then
id=$(curl -sS "$API/repos/$REPO/releases/tags/$TAG" \
-H "Authorization: token $TOKEN" \
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{process.stdout.write(String(JSON.parse(s).id))})')
fi
echo "release id: $id"
for f in dist/*; do
name=$(basename "$f")
echo "uploading $name"
curl -sS -X POST "$API/repos/$REPO/releases/$id/assets?name=$name" \
-H "Authorization: token $TOKEN" \
-F "attachment=@$f" > /dev/null
done
echo "published $TARGET assets for $TAG"
+6
View File
@@ -2,6 +2,12 @@
/target /target
**/*.rs.bk **/*.rs.bk
# Nix
# `nix build` output symlinks (`result`, `result-<name>`), direnv's cached env
/result
/result-*
.direnv/
# Snapshot test review files # Snapshot test review files
*.snap.new *.snap.new
*.pending-snap *.pending-snap
+25 -2
View File
@@ -108,6 +108,23 @@ Current decisions at a glance (each backed by an ADR):
SQL `select` / `with` / `insert` / `update` / `delete` SQL `select` / `with` / `insert` / `update` / `delete`
(ADR-0039). `EXPLAIN QUERY PLAN` never executes, so (ADR-0039). `EXPLAIN QUERY PLAN` never executes, so
explaining a destructive command is safe. 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 ## Repository layout
@@ -344,8 +361,14 @@ not yet implemented:
Ctrl-Enter submits. Ctrl-Enter submits.
- **Tab completion** (I3), **syntax highlighting** (I4). - **Tab completion** (I3), **syntax highlighting** (I4).
- **ER diagram export** (V3). - **ER diagram export** (V3).
- **CI** (TT5): test infrastructure exists; CI workflow not - **Full TT5** (CI): the pipeline is live (see the CI decision
yet configured. 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 ## Handoff notes
+6
View File
@@ -68,6 +68,12 @@ tempfile = "3.27.0"
incremental = false incremental = false
debug = "line-tables-only" 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] [lints.rust]
unsafe_code = "forbid" unsafe_code = "forbid"
unreachable_pub = "warn" unreachable_pub = "warn"
+30
View File
@@ -0,0 +1,30 @@
# `ci/winstub/` — empty Windows import-lib stub
`libsynchronization.a` here is an **empty `ar` archive** (8 bytes: `!<arch>\n`),
referenced by `.cargo/config.toml` via `-L native=ci/winstub` for the Windows
release targets.
## Why
The D1 release matrix cross-compiles Windows binaries from Linux with
`cargo zigbuild` (see `docs/ci/adr/`). Rust's `std` links `-lsynchronization`
for its `WaitOnAddress`-based thread parking. That import library is normally
provided by Rust's `rust-mingw` "self-contained" component — which `rust-overlay`
does not ship — and Zig's bundled mingw doesn't carry it either, so the link
fails with:
```
error: unable to find dynamic system library 'synchronization'
```
The functions it would import (`WaitOnAddress`, `WakeByAddressSingle`,
`WakeByAddressAll`) are **forwarded by `kernel32.dll`**, which is already linked,
so they resolve at link and run time without a real `synchronization` import
library. An **empty** stub is therefore sufficient: it satisfies the `-l`
lookup and contributes no symbols.
## Regenerating
```
zig ar rcs ci/winstub/libsynchronization.a
```
+1
View File
@@ -0,0 +1 @@
!<arch>
+8
View File
@@ -213,6 +213,14 @@ working copy.
### 6. Persistence ordering ### 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: A successful user command produces effects in four targets:
the SQLite database, `project.yaml`, the relevant the SQLite database, `project.yaml`, the relevant
`data/<table>.csv` file(s), and `history.log`. INV-2 from the `data/<table>.csv` file(s), and `history.log`. INV-2 from the
@@ -2,7 +2,13 @@
## Status ## 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 ## Context
@@ -5,7 +5,11 @@
**Accepted** — 2026-05-30 (issue #9). Amends the output conventions of **Accepted** — 2026-05-30 (issue #9). Amends the output conventions of
ADR-0014 (data operations), ADR-0028 (query plans / `explain`), and ADR-0014 (data operations), ADR-0028 (query plans / `explain`), and
ADR-0019 (failure rendering); builds on ADR-0037's mode-tagged echo 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 ## Context
@@ -0,0 +1,147 @@
# ADR-0051: Bottom keybinding strip — context- and state-aware
## Status
**Accepted 2026-06-13 (issue #27).** Closes Gitea **#27**. All forks
below were escalated to the user and user-chosen before any code was
written; to be implemented test-first. Builds on ADR-0046 (nav focus),
ADR-0003 (input modes), ADR-0049 (the #29 readline keys this strip now
advertises), and ADR-0022 (the Tab-completion memo).
## Context
The bottom status line (`render_status_bar`, `ui.rs`) mixed keystrokes
with typed-command words: `Enter submit · : advanced once · mode
advanced switch · Ctrl-C quit`. That is redundant — the hint panel
already teaches `help` and `Enter` when the input is empty — and it is
static apart from a three-way mode branch, so it never reflects what the
user can actually do *right now* (navigating the sidebar, cycling a
completion, browsing history, editing a line).
Issue #27: repurpose the line as a **keybindings-only** strip that is
**context-sensitive to nav focus** and **state-aware of the current
transient interaction**, and move mode discovery into the empty-input
hint.
## Decision
### 1. The strip is keybindings-only and state-selected
A single pure function `status_bar_bindings(app) -> Vec<Binding>`
computes the strip from app state; `render_status_bar` is a thin
renderer over it (so the binding sets are unit-testable without a
Frame). `history_cursor` is private to `App`, so a small
`pub fn is_browsing_history(&self) -> bool` accessor exposes the
history-navigation predicate; `mode` / `nav_focus` / `last_completion`
are already `pub` and `effective_mode()` is a `pub` method. The state is
chosen by **priority — first match wins**:
| Priority | State (predicate) | Strip |
|---|---|---|
| 1 | **Sidebar focus** (`nav_focus` in a sidebar) | `Ctrl-O next pane · ↑↓/PgUp/PgDn scroll · Esc input` |
| 2 | **Completion memo live** (`last_completion.is_some()`) | `Tab/Shift-Tab cycle · Esc cancel · Enter run` |
| 3 | **History navigation** (`history_cursor.is_some()`) | `↑↓ browse · Esc clear · Enter run` |
| 4 | **Editing** (Input focus, input non-empty) | `Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run` |
| 5 | **Default** (Input focus, input empty) | `Ctrl-O sidebar · Tab complete · ↑ history · Enter run` |
Priority order matters: a completion memo or history navigation is a
non-empty-input situation, so states 2 and 3 must precede state 4. The
sidebar overlay occludes the input entirely (ADR-0046), so state 1 wins
outright.
### 2. Mode discovery moves off the strip, into the empty-input hint
The typed-command advertisements (`mode advanced` / `mode simple`
switch, the `:` one-shot) leave the strip — they are not keystrokes.
Mode discovery moves to the **empty-input hint** (`resolve_hint_lines`'s
`(None, None)` arm), in **simple mode only**:
- **Simple:** `… · \`mode advanced\` for SQL`
- **Advanced (persistent):** no pointer.
The pointer omits the verb "type" — the surrounding prompt already
implies it (we don't say "type `help`" either). Advanced mode shows
**no** pointer (user decision, post-trial): a user who switched into
advanced mode knows how they got there, and `help` covers the way back —
a "switch back" pointer only reads naturally in the moment right after
switching, so it earns its space poorly.
The one-shot advanced state's old `Backspace cancel one-shot` label is
**subsumed** by the editing state (the input is non-empty in one-shot;
Esc-clear and Backspace both cancel it). No behaviour is lost — only the
dedicated label.
### 3. Width: no drop machinery; a budget test instead
The longest strip (state 4, editing) is ≈ **65 display columns**, which
fits every supported width (90-col screencasts, 80-col terminals) with
margin — so the priority-drop / abbreviation machinery considered would
never trigger and is not built (user-confirmed). Ratatui's existing
**clip-at-edge** is the trivial fallback for pathologically narrow
(< 65-col) terminals. Instead, a **width-budget unit test** pins the
longest rendered strip within an 80-col budget, keeping the strip lean
*by construction* — a future over-long strip fails the test rather than
silently clipping in a cast.
## Forks (all user-chosen)
- **Editing state — yes:** when the input has text, surface the #29
readline keys (Esc-clear, Ctrl-A/E, Ctrl-W); the strip stays lean
(nav/complete/history) when empty. (vs not advertising the #29 keys.)
- **`Ctrl-C quit` — omitted** from the strip (vs always shown): quit is
a near-universal convention; omitting it keeps the strips lean and
matches the issue's sketch.
- **Width — budget test, no drop logic** (vs graceful priority-drop /
abbreviation): the strips fit at supported widths, so the machinery
would be dead weight (user's own observation).
## Consequences
- The strip now teaches the keys for the *current* situation; learners
see `Tab/Shift-Tab cycle` exactly while cycling, the editing keys
exactly while editing, etc.
- The #29 readline keys (ADR-0049) gain their on-screen advertisement,
closing that ADR's deferred item.
- 15 existing full-panel insta snapshots churn (the bottom line — and,
on empty-input views, the hint pointer — changes in every one,
including the rebuild-confirm modal view, whose modal box is itself
unchanged); each diff was reviewed, not blind-accepted.
- `requirements.md` is unaffected (an ADR-tracked UI refinement); the
change is cross-referenced from the commit + this ADR.
## Tests
- **Tier-1 (`ui.rs` unit):** `status_bar_bindings` returns the expected
key set for each of the five states (sidebar, completion-live,
history-nav, editing, default) — the completion/history states driven
through real key events (`update`) so the predicate transitions are
exercised, the others by setting `App` fields; plus the width-budget
assertion across states. (Per-state coverage is these unit tests, not
snapshots — a one-line strip is asserted more precisely by its exact
key list than by a full-panel snapshot.)
- **Tier-1:** the empty-input hint appends the correct mode pointer in
Simple vs Advanced, and does **not** append it when an ambient hint is
showing (non-empty input).
- **Tier-3 (`walking_skeleton`):** the old `status_bar_lists_quit_and_
submit_in_all_modes` (which asserted the pre-ADR strip) is rewritten +
renamed to assert the keystroke-only, state-aware strip end-to-end
through the real render path (default → editing transition).
- **Tier-2 (insta):** the 15 full-panel snapshots re-accepted (each diff
reviewed — strip line and/or hint pointer only).
## Out of scope
- **Modal-aware strip.** While a modal is open (load picker, rebuild /
undo confirm) it owns the keyboard and carries its own in-box key
hints; the bottom strip under a modal computes from input state
exactly as it does today (modals render *over* the status bar). This
issue does not redesign the modal case — pre-existing behaviour,
unchanged and not worsened.
- A persistent/togglable help overlay listing *all* keys (the strip is a
contextual subset, not a cheatsheet).
- Per-key colour theming beyond the existing key/label/separator styles.
- Localisation of the new label strings beyond adding catalog entries.
- The remaining I1b kill keys' (Ctrl-K/Ctrl-U) advertisement — the
editing strip shows the highest-value subset (Esc/Ctrl-A/E/Ctrl-W) to
stay within the width budget; Ctrl-K/U remain unadvertised muscle
memory.
@@ -0,0 +1,250 @@
# ADR-0052: Mode-tagged history for cross-mode recall
## Status
**Accepted + implemented 2026-06-13 (issue #30).** Closes Gitea **#30** —
both the feature ("reuse advanced history commands in simple mode by
prepending `:`") and the bug reported in its comment (the `:` one-shot
prefix lost across sessions). All forks user-chosen before any code.
**Amends ADR-0034** (journal status field gains a `:adv` tag; *journaling
moves from the worker to the dispatch layer*), **ADR-0015 §5/§6**
(history.log leaves the worker transaction — `commit-db-last` now scopes
yaml/csv/db only), and **ADR-0040** (a success-path journal-write failure
is best-effort, no longer fatal); references ADR-0003 (the `:` one-shot
sigil). Plan: `docs/plans/20260613-issue-30-top-of-chain-journaling.md`
(pre-build `/runda`, then a second `/runda` that drove the journaling
relocation + the app-command exclusion). **2471 tests pass / 0 fail / 0
skip (1 ignored), clippy clean.**
> **Why journaling moved (the key architectural turn).** The first draft
> kept journaling in the worker and threaded the mode down to it (~30-site
> plumbing). On review the user asked the right question: why is the
> journal written deep in the worker at all, when the failure path already
> journals at the top of the chain where command + mode + outcome are all
> in scope? It shouldn't — `history.log` is a *journal of typed commands*,
> not *state*. So success journaling moved up next to the failure path
> (`spawn_dsl_dispatch` / `run_replay` / the app-command sites), the
> mode-plumbing dilemma dissolved, and the worker's `finalize_persistence`
> now writes only the state sources (yaml/csv). Consequence: the journal
> write is best-effort (the command is already committed), consistent with
> the failure path — see §5.
## Context
The input-history ring and `history.log` carry **no mode information**,
which causes two coupled problems:
1. **Feature gap.** A command typed in advanced mode (`select * from T`)
is stored bare. Recalled in simple mode it is not valid DSL → it just
errors. There is no way to know it was an advanced (SQL) command and
offer it back in a runnable form.
2. **Bug (issue #30 comment).** A `:`-one-shot advanced command in simple
mode recalls correctly **in-session** (the in-memory ring stores the
raw `:select 1`), but after quit+resume it comes back **without** the
`:` and is unusable. Root cause: the ring stores the raw input
(`:select 1`), but the worker journals the **stripped** `effective_input`
(`select 1`) — submission strips the `:` before dispatch (ADR-0003) —
so the on-disk `source` never carried the `:`, and hydration loses it.
Both reduce to: **history does not record the submission mode**, and the
in-memory and on-disk representations disagree about the `:`.
## Decision
Record the **submission mode** per history entry, keep the on-disk
`source` **canonical** (stripped — replay is unaffected), and have
**recall reconstruct the runnable line** for the current mode.
### 1. In-memory ring stores the `:`-prefixed runnable form
`App.history` stays `Vec<String>` — no type change, so the public ring,
the `ProjectSwitched` payload, and `seed_history` are untouched. An
**advanced** entry is stored in its **simple-mode runnable form**, the
`: `-prefixed string (e.g. `: select * from T`); a **simple** entry is
stored bare. This is exactly what the in-session one-shot ring already
does (`:select 1` recalls as typed) — generalised to *persistent*-advanced
commands too, and made reconstructable on hydration. Because a simple
DSL command can never begin with `:` (the sole sigil, ADR-0003), a
leading `:` unambiguously marks an advanced entry.
`submit` builds the stored line from the submission: advanced →
`": " + effective_input` (the `: ` matches the auto-space the typed
one-shot inserts), simple → `effective_input`. This is computed **after**
`effective_input` (today `push_history` runs on the raw `trimmed` before
stripping; the reorder also drops a bare `:`, which never executed). The
draft (`history_draft`) stays a plain `String`. `push_history` itself is
unchanged — it still takes one `&str`.
### 2. Recall strips the `:` for advanced mode
`history_back` / `history_forward` set `self.input` from the stored
string, then strip a leading `:` **iff the current persistent mode is
Advanced**:
```
if self.mode == Mode::Advanced && stored.starts_with(':') { stored[1..].trim_start() } else { stored }
```
So an advanced entry recalls as `: select * from T` in **simple** mode
(runs via the one-shot escape — the feature, and the cross-session bug
fix) and bare `select * from T` in **advanced** mode (runs as SQL). A
simple entry recalls bare in either mode (simple DSL already runs in
advanced mode — issue #30). In-session and cross-session paths share the
same stored form, so they finally agree.
### 3. On-disk: a mode tag in the status field
The record stays three pipe-separated fields `<ts>|<status>|<source>`
(so `source` remains the last, pipe-tolerant, canonical field — replay
reads it unchanged). The **status token** gains an optional `:adv`
suffix:
| Submission | Success | Failure |
|---|---|---|
| Simple | `ok` | `err` |
| Advanced (persistent or one-shot) | `ok:adv` | `err:adv` |
ADR-0034 §1 already reserved the status field for "additional values …
a non-breaking future extension"; this is that extension. The status
parser splits the token on `:`: the base (`ok`/`err`) gives replayability
(`status_is_ok` ⇔ base == `ok`), the `adv` suffix gives the mode — so an
unknown future token degrades to "not ok, simple" rather than mis-parsing.
### Journaling location: the dispatch layer, not the worker
Both tags are written **at the dispatch layer**, where command + mode +
outcome are all in scope — so the mode needs no plumbing into the worker:
- **Success:** `spawn_dsl_dispatch`, immediately after
`execute_command_typed` returns `Ok`, calls
`append_history(source, submission_mode.is_advanced())` (best-effort).
`run_replay` does the same per replayed line (tagged simple — replay is
mode-agnostic), and the app-command sites (`perform_switch` /
`spawn_export` / `spawn_rebuild`) journal **simple** (`advanced = false`
— app commands run in any mode, so no `:` on recall; this also avoids a
redundant `: undo`).
- **Failure:** unchanged location (the App→`JournalFailure`→runtime path,
already at the top), now carrying the mode — `JournalFailure` gains
`advanced`, and `DslFailed` gains `submission_mode` for the
worker-rejection sub-path (the parse-failure sub-path has it in
`dispatch_dsl`). `Ok`/`Err` are exclusive, so success-in-spawn and
failure-in-App-path never double-journal.
The worker's `finalize_persistence` and the four no-op-skip / three
read-only sites **no longer journal** — they leave the state writes
(yaml/csv) in the worker transaction and let the dispatch layer journal
the `Ok` outcome.
### 4. Hydration reconstructs the `:`-prefixed form
`read_recent_sources` parses each record's status tag and, for an
advanced record, **reconstructs** the `: `-prefixed string from the
canonical `source` (`format!(": {source}")`); simple records pass through
bare. It still returns `Vec<String>`, so `read_history_seed`,
`seed_history`, and the `ProjectSwitched` payload are **unchanged**. A
hydrated entry is therefore byte-identical to its in-session form, and
recall behaves identically.
### Back-compatibility
Old `history.log` files have only `ok` / `err` tokens → parsed as
`advanced = false` (simple). Their advanced commands stay un-`:`-able on
recall — the pre-existing behaviour, not a regression; nothing migrates.
`status_is_ok` keys off the base token, so `ok:adv` records replay
exactly as `ok` does today (source is canonical either way).
### Journal write is best-effort (amends ADR-0040)
Because the journal is now written *after* the worker replies (i.e. after
`tx.commit`), a journal-write failure can no longer roll the command back.
It is **best-effort** — logged and ignored, exactly like the failure path
already is (ADR-0034 §4) — so the two journal paths are finally
consistent. State integrity is unchanged: yaml/csv/db still commit
atomically in the worker (a *state*-write failure still rolls back and is
fatal). The only property given up: on a rare journal-write failure (disk
full) a committed command may be missing from `history.log` — not
recallable/replayable next session, but the state is correct. User-chosen
over keeping journaling coupled in the worker (which would have needed the
~30-site mode plumbing). See the plan's §2 for the full trade-off.
## Forks (user-chosen)
- **Format = mode tag in the status field** (`ok:adv`/`err:adv`), over a
new 4th field (ambiguous with unescaped pipes in old `source`s without
a version bump) or a `:`-prefix in `source` (would make `source`
non-canonical and force replay to strip it).
- **Scope = unified** (bug + feature) over bug-only: one mechanism does
both, and keeping `source` canonical for replay needs the mode tag
regardless, so bug-only is barely smaller and leaves the main ask open.
- **Journaling location = dispatch layer, best-effort** over keeping it
worker-coupled-and-fatal (which needed the ~30-site mode plumbing). The
user's architectural call (§Status).
## Consequences
- Advanced history is reusable in simple mode; the `:` one-shot survives
resume. The in-memory and on-disk representations agree.
- **Journaling left the worker.** `finalize_persistence` and the
no-op-skip / read-only sites no longer journal; success is journalled at
the dispatch layer (`spawn_dsl_dispatch` / `run_replay` / app-command
sites). The ring stays `Vec<String>`; `seed_history` / `ProjectSwitched`
are untouched. The vestigial worker `source` plumbing has since been
**fully unwound** (2026-06-14 follow-up): `_source` removed from
`finalize_persistence` / `do_rebuild_from_text`; the three read-only
`*_request` wrappers inlined and deleted; and — because the cascade ran
deeper than first estimated — the now-dead `source` param dropped from
the ~30 worker handlers (leaf + composite) that only forwarded it, plus
the `source` field removed from the `DescribeTable` / `QueryData` /
`RunSelect` requests and the matching `DatabaseHandle` method parameters
(the ~164 call-site churn was mostly tests). The only `source` left in
the worker is the snapshot/undo label (`snapshot_then` /
`stage_pre_mutation` / `begin_batch`), passed at the match-arm level.
Purely mechanical, compiler-guided, no behaviour change.
- **App commands recall bare.** Because they are dispatched outside the
`ExecuteDsl`/spawn path, app commands journal **simple** (`advanced =
false`) at their own sites, and `submit` excludes them from the ring's
`advanced` flag (`!is_app_command`) — so `mode advanced` / `undo` recall
bare and run fine in simple mode, with no redundant `:`.
- **Journaling is now uniform (user-confirmed).** The spawn journals on
`outcome.is_ok()`, so **every** successful command is recorded — closing
a pre-existing gap where `show table` / `show data` / `select` journalled
but `show tables`/`show relationships`/`show indexes`, `show relationship
<name>`, and `explain` did **not** (their worker arms carried no
`source` / no journal call). The new behaviour matches ADR-0034 §1
("record every submitted command"); those reads are now recallable and
are re-run harmlessly on replay (`explain` never executes; shows produce
output, no state change). A DA finding, accepted as the more-correct
behaviour over re-adding command-outcome gating to preserve the old
inconsistency.
- **Replay re-journaling.** When `replay` re-dispatches a line, the
re-written record is tagged from how replay dispatched (mode-agnostic →
`ok`), so a replayed advanced command may be re-journalled without
`:adv`. Replay correctness of execution is unchanged (it already parses
mode-agnostically); this only affects the *tag* of the re-written line.
Noted; not addressed here (replay's own mode-fidelity is out of scope).
## Tests
- **Tier-1 (`app.rs`):** an advanced one-shot / persistent-advanced
submission is stored `: `-prefixed; it recalls as `: …` in simple mode
and bare in advanced mode; a simple entry recalls bare in both; a bare
`:` is not stored; a parse-failure is still recallable; dedup/cap hold.
- **Tier-1 (`history.rs`):** the writer emits `ok:adv`/`err:adv`;
`read_recent_sources` reconstructs the `: `-prefix for `:adv` records
and leaves `ok`/`err` records bare (so old logs read as simple);
`status_is_ok` is true for `ok` and `ok:adv`.
- **Tier-3 (`iteration6_resume_history` / it):** the headline
**regression** — type a `:`-one-shot advanced command, journal +
hydrate, and assert it recalls **with** `:` in simple mode (fails on
current code); plus a persistent-advanced command round-tripping to a
`: …` recall.
## Out of scope
- Replay re-journaling mode-fidelity (above).
- Special-casing app commands to avoid the redundant recall `: `.
- Distinguishing one-shot from persistent advanced on recall (both are
simply "advanced" — the `:` is what simple mode needs either way).
- A format version marker / pipe-escaping in `source` (unneeded — the
status-tag approach keeps `source` last and canonical).
@@ -0,0 +1,428 @@
# ADR-0053: Contextual `hint` — F1 live-input keybinding + `hint` command, with a tier-3 teaching corpus (H2)
## Status
Accepted — **implemented 2026-06-15** (plan:
`docs/plans/20260614-adr-0053-contextual-hint-H2.md`; the F1 keybinding +
`hint` command, the `hint_ids` per-form keying + `hint_key_for_input_in_mode`,
`last_error_hint_key` + `friendly::error_hint_class`, the `note_hint*`
renderers, and the `hint.cmd.*`/`hint.err.*` corpus for every command form
+ the 9 runtime error classes, with the comprehensiveness coverage test
and the ADR-0051 strip advertising F1). Closes **A1** + requirements
**H2**. Deferred: the pre-submit-diagnostic route + `diagnostic.*` blocks
(#38), clause-concept hints (#37). Revised after a `/runda` review
(2026-06-14): corrected the verbosity-default fact; re-keyed tier-3
content off `help_id`; split the pre-submit-diagnostic and runtime-error
paths; added a comprehensiveness coverage test. Revised again during
Phase B implementation (2026-06-15): the first exemplar showed per-*node*
keying is too coarse for multi-form commands (`add`/`drop`/`show`/
`create`), so D3 now keys tier-3 content **per form** via a
`hint_ids: &[&str]` array mirroring `usage_ids` — and **clause-concept
hints** are recorded as a deferred extension (issue #37). During Phase C
the **pre-submit-diagnostic route + the ~33 `diagnostic.*` blocks** were
**deferred** (issue #38) — `Diagnostic` doesn't carry its class key, so
the route needs a broad change for marginal value (D6). v1 therefore
ships command-form hints + the 9 runtime error-class hints. The parallel
question of whether the in-app `help` command should likewise distinguish
advanced-SQL forms is tracked **separately** as Gitea issue #36.
Decided in conversation 2026-06-14. Closes the last open piece of **A1**
(the canonical app-command set, ADR-0003): every app command is
implemented except `hint`, which ADR-0003's command table listed as
*"Request a hint for the current input (ADR pending)."* This ADR is that
pending decision. Tracked as **H2** in `docs/requirements.md`.
References ADR-0003 (app-command set + the `:` escape), ADR-0019 (the
friendly error layer / H1), ADR-0021 (per-command usage templates / H1a),
ADR-0022 (ambient typing assistance — colour + hint panel + completion),
ADR-0027 (input validity indicator), ADR-0046 (sidebar navigation +
responsive input hint), ADR-0049 (input-field readline keymap), and
ADR-0051 (context/state-aware keybinding strip).
## Context
`hint` is the only unbuilt app command. The naive reading — "show a hint" —
hides a real subtlety, and a real cost.
**The subtlety: a submitted `hint` command cannot see live input.** App
commands are submitted with Enter, which empties the input buffer. By the
time `hint` dispatches, the partial command it was meant to help with is
gone. So "a hint for the current input" cannot be served by a submitted
command alone — it needs a *keybinding* that acts on the live buffer
without submitting. ADR-0003 said "current input"; `requirements.md`
broadened it to "current input **or the most recent error**." Both are
wanted; they map to two different trigger surfaces.
**The cost: the value of `hint` is content, not plumbing.** The app
already carries two tiers of contextual text:
- **Tier 1** — terse, always-on: syntax colour (ADR-0022); the error
*headline* alone (ADR-0019, when `messages_verbosity: Short`).
- **Tier 2** — short contextual lines: the ambient typing prose /
`expected` set, shown live while typing (ADR-0022, catalogue
`hint.ambient_*` / `hint.value_slot_*`); and the error `hint:` field —
which, because `Verbosity::Verbose` is the **default**
(`src/friendly/translate.rs:46`), is shown **by default** beneath every
error headline (`messages short` is the opt-*out*, not `messages
verbose` the opt-in).
So the verbose error hint is **already on screen by default**. If `hint`
merely re-showed it, it would duplicate what the user can already see (and
the ambient panel). To justify itself, `hint` must add a **tier 3**: a
genuinely deeper, *teaching*-grade explanation — what the command/error
means, a worked example, and the underlying relational concept. That
corpus does not exist yet, and
authoring it (to the standard of a teaching tool, where "pedagogy wins
ties") is the bulk of the work.
The mechanism is small and reuses everything already present: the command
REGISTRY (`src/dsl/grammar/mod.rs`), the `AppCommand` enum
(`src/dsl/command.rs`), key dispatch (`App::handle_key`,
`src/app.rs:1155`), the `note_help`/`note_help_topic` renderers
(`src/app.rs:2982`/`3021`), the parser/walker expected-set
(`ParseError.expected`, `WalkResult.tail_expected`), the friendly
catalogue + `t!` macro + `keys.rs` validation, and the output styling
vocabulary (`OutputStyleClass::Hint`).
## Decision
### D1 — Two surfaces, no topic argument
`hint` is delivered through **two complementary surfaces**:
1. **F1 keybinding → live input.** Pressing **F1** while typing renders a
tier-3 hint for the command currently in the buffer, into the output
panel, **without submitting or altering the buffer**. This is the
primary, most-valuable path (it serves the literal "current input").
2. **`hint` command → most recent error.** Submitting `hint` renders the
tier-3 expansion of the most recent error. This is why the command
exists despite the empty-buffer problem: the thing it helps with is
the *last thing you tried*, not the now-empty buffer.
`hint` takes **no topic argument**. Explicit per-command reference is
already `help <topic>` (H3); `hint` is purely *contextual*, which keeps
the two cleanly distinct (`hint` = "help me with what I'm doing right
now"; `help insert` = "show me the insert reference").
F1 is a **read-only overlay**: it never alters the input buffer, the
cursor, or the live completion memo (ADR-0022) — it only emits a block
into the output journal. (It must therefore be handled in `handle_key`
*before* the "any other key clears the memo" fall-through.)
### D2 — Trigger matrix
| Trigger | Buffer / state | Result |
|---|---|---|
| **F1** | non-empty input | tier-3 hint for the command being typed. (No "expected next" line — the always-on tier-2 ambient panel already shows it live; tier-2 owns position-awareness.) |
| **F1** | empty input, a recent error exists | tier-3 expansion of that error |
| **F1** | empty input, no recent error | a short "getting started" pointer (press F1 while typing a command; `help` for the full list) |
| **`hint`** (submitted) | a recent error exists | tier-3 expansion of that error (primary use) |
| **`hint`** (submitted) | no recent error | the same "getting started" pointer |
F1 is inert behind a modal and while a sidebar panel holds navigation
focus (consistent with the existing `handle_key` gates, ADR-0046); it is
active in the input context in both Simple and Advanced mode.
**Error routes.** **Runtime errors** (the 9 `translate_error` classes)
occur *after* submit; the **`hint` command / empty-input F1** path reads
them via the stored `last_error_hint_key` (D5) and renders their
`hint.err.<class>` block. (A second route for **pre-submit diagnostics**
on the F1 live-input path was specified but is **deferred** — D6 / issue
#38; with a diagnostic present, F1 shows the command block and tier-2
shows the diagnostic.) **`:`-prefix handling:**
on the simple-mode one-shot escape (`: SELECT …`), command
identification for the F1 path strips the leading `:` first, so the
advanced form is matched.
### D3 — The tier-3 content model
Tier-3 blocks live in the friendly catalogue under the existing `hint:`
top-level namespace (where tier-2 ambient strings already live), in two
new sub-namespaces:
- **`hint.cmd.<hint_id>`** — one per command **form**, keyed by a **new
`hint_ids: &'static [&'static str]`** field on `CommandNode`
(`src/dsl/grammar/mod.rs:512`), **mirroring the existing `usage_ids`**.
The F1 live-input path resolves the current input to its form's hint key
via `hint_key_for_input_in_mode`, which reuses the same form-word
disambiguation as `usage_key_for_input_in_mode`.
**Why an array mirroring `usage_ids`, not a per-node `hint_id`**
*(`/runda`/implementation revision, 2026-06-15)*: a single per-node key
is too coarse. Several entry words are **one node spanning many forms**
`add` (column/relationship/index/constraint), `drop` (table/column/
relationship/index), `show` (data/table/tables/relationships/indexes),
`create` (table/index). A live-input hint for `add 1:n relationship` is
only useful if it is *specific to relationships*, so the content must be
**per form**, not per node. The project already solved exactly this for
usage templates (`usage_ids` is a per-form array, disambiguated by the
form word), so `hint_ids` mirrors it. Single-form nodes carry one entry;
multi-form nodes carry one per form. This also covers the advanced-SQL
forms whose `usage_ids` are empty (`SQL_INSERT/UPDATE/DELETE`,
`EXPLAIN_SQL`) — they get their own `hint_ids` directly, independent of
usage, with mode-correct SQL examples. (The `help`-list collapse of
advanced-SQL forms is a separate gap — issue #36.)
**Deferred extension — clause-concept hints** (issue #37): per-form is
the right granularity for tier-3 *teaching* (position-awareness within a
form is owned by tier-2 ambient + the live `Next:` line, D4). But some
**concepts live inside a clause**, not a form — `… on delete ⟨cascade|
set null|restrict⟩` (referential actions), the `create table` constraint
slots (`primary`/`unique`/`check`/`foreign`), `with pk`, `1:n`/`m:n`
cardinality. A learner parked in such a clause may want teaching deeper
than tier-2's candidate list but narrower than the whole-form block. v1
does **not** build this (it would multiply content for points whose value
we can't yet measure, and we don't expect to accumulate usage statistics
to drive it empirically — it will be tackled as a deliberate follow-up
job). The keying does not lock it out: a later `hint.concept.<topic>`
namespace can be surfaced when the cursor sits in a recognized clause,
layered on top of the per-form block.
- **`hint.err.<class>`** — one per error/diagnostic class, keyed by the
friendly error/diagnostic key (e.g. `hint.err.foreign_key.child_side`,
`hint.err.type_mismatch`, `hint.err.insert_arity_mismatch`). Used by
both error routes (D2).
Each tier-3 block is a **structured entry with three labelled parts**, so
the voice stays consistent and the renderer can style them uniformly:
```yaml
hint.cmd.dsl.insert:
what: "Add one or more rows to a table."
example: "insert into Customers values ('Ann', 'ann@x.io')"
concept: "A row is one record; each value lines up with a column, in
order. Columns typed `serial`/`shortid` fill themselves — leave them out."
```
- **`what`** — one or two plain sentences: what this command does / what
this error means.
- **`example`** — a single concrete, copyable line (rendered neutral, not
muted, so it stands out as runnable).
- **`concept`** — the underlying relational idea, in teaching voice; the
part that makes this tier-3 rather than tier-2.
`concept` is optional where there is genuinely no concept beyond the
mechanics (e.g. `quit`); `what` + `example` are always present.
### D4 — Rendering
Both surfaces render through the `App::note_hint*` family (sibling of
`note_help`/`note_help_topic`, `src/app.rs`) via `emit_tier3_block`,
emitting into the `output` buffer as `OutputKind::System`: a **`Hint`
heading** followed by aligned **`What:` / `Example:` / `Concept:`** lines
(labels + heading from `hint.block.*`). The `concept` line is muted
(`OutputStyleClass::Hint`); the rest are plain. The block is
**persistent** (scrolls in the journal), unlike the transient ambient
panel — pressing F1 is an explicit request to *keep* the deeper guidance
on screen. Its rendered shape is locked by an `insta` snapshot
(`hint_block_insert`). The bottom keybinding strip (ADR-0051) advertises
F1 in the editing (leading) and default states.
### D5 — "Most recent (runtime) error" state
The **runtime-error route** (submitted `hint`, and empty-input F1) needs
to map the last runtime error back to its `hint.err.<class>` key. Runtime
errors today live only as rendered text in the `output` buffer. We add a
single small piece of `App` state — **`last_error_hint_key:
Option<String>`** — set at the `translate_error` call sites
(`runtime.rs:2615`, `app.rs:2424`) when a friendly error is rendered,
cleared when a later command succeeds. Absent → the "getting started"
pointer.
The **pre-submit-diagnostic route** (the F1 live-input path reading the
under-cursor diagnostic) is **deferred** — see the scope note in D6.
### D6 — Content scope for v1
v1 ships tier-3 content for the **command forms and runtime error
classes** — comprehensive for those (the graceful tier-2 fallback below
is a safety net, not the plan):
- **~37 command forms** — every distinct node in `REGISTRY` gets its own
`hint.cmd.<hint_id>` block (app + DSL + DDL + advanced-mode SQL forms),
each with a **mode-correct example** (the advanced-SQL forms show SQL
syntax, their simple siblings show DSL — no sharing).
- **9 runtime error classes** — `unique`, `foreign_key` (child/parent
side), `not_null`, `check`, `type_mismatch`, `not_found`,
`already_exists`, `generic`, `invalid_value` — each gets a
`hint.err.*` block.
**Deferred — the ~33 `diagnostic.*` pre-submit classes and the F1
diagnostic route** *(Phase C scope decision, 2026-06-15; issue #38)*. The
original "comprehensive" scope included them, but implementation revealed
`Diagnostic` (`walker/outcome.rs`) carries only its rendered `message`,
not its class key — so a live diagnostic can't be mapped to
`hint.err.<class>` without adding a `class` field threaded through every
diagnostic-creation site (a broad change). Weighed against the value, it
isn't worth it for v1: pre-submit diagnostics are already surfaced by
tier-2 (ambient message + validity indicator, ADR-0027); F1 still shows
the useful command block when a diagnostic is present; and many
diagnostic classes duplicate runtime classes already covered
(`type_mismatch`, `unknown_table``not_found`, arity↔`invalid_value`).
Deferred to issue #38, additively (the keying doesn't lock it out).
The full enumerated checklist is the implementation plan's tracking
artifact (see *Content inventory*, below).
**Fallback (safety net):** if a tier-3 key is ever missing at runtime,
the surface degrades to tier 2 — the ambient prose for the command path,
or the verbose error `hint:` for the error path — never to a blank or an
error. The `keys.rs` build-time validation keeps the corpus honest, so a
missing key is caught in tests, not in front of a student.
### D7 — Authoring process: exemplars-first
Because the corpus is large and its *voice* is a pedagogical decision the
maintainer owns, content is produced in two stages:
1. This ADR carries **23 worked exemplars** (below) as the canonical
style reference. The `/runda` review of this ADR is where the voice and
depth are approved.
2. Once approved, the remaining blocks are authored to that template in
**reviewable batches** (grouped by area: DDL, DML, app commands,
error classes), not one monolithic drop.
### Exemplars (the style reference; shipped as the rendered format)
**Command (F1 live-input), `insert`** (the rendered shape, locked by the
`hint_block_insert` snapshot — a `Hint` heading + aligned labels, no
`Next:` line since tier-2 owns position-awareness):
```
Hint
What: Add one or more rows to a table.
Example: insert into Customers values ('Ann', 'ann@example.io')
Concept: A row is one record; each value lines up with a column, in
order. Columns typed serial/shortid fill themselves — leave
them out.
```
**Error (`hint` command), foreign-key child-side violation:**
```
Hint
What: The value you gave for the child column doesn't match any
parent row, so the foreign key has nothing to point at.
Example: First insert the parent (insert into Customers …), then the
child that references it.
Concept: A foreign key is a promise that every child points at a real
parent, so the parent must exist first. To allow orphans on
delete instead, set the relationship's `on delete` to
`set null` or `cascade`.
```
**Command (F1 live-input), `add 1:n relationship`:**
```
Hint
What: Link two tables so a parent row can own many child rows.
Example: add 1:n relationship from Customers.id to Orders.customer_id
Concept: The "1:n" means one parent, many children. The child column
holds the foreign key; `--create-fk` adds it for you if it
doesn't exist yet.
```
## Forks (all user-chosen, 2026-06-14)
- **Trigger model:** both a keybinding (live input) and a submitted
command (last error), rather than command-only or keybinding-only — the
live-input path is the most useful, but the command completes the A1
slot and serves the error case.
- **Keybinding = F1:** the universal help convention; the key is
genuinely free (no `KeyCode::F(1)` binding exists today — the `"F1"`
strings in `input_render.rs`/tests are scenario labels, not the key, and
ADR-0022 uses no `F1` requirement label). No collision with the ADR-0049
readline keys, `Ctrl-O` (ADR-0046), `Esc`-clear, or the reserved
`Ctrl-C` cancel (I5). Rejected: `?` (a typeable character — fiddly
position-dependent handling) and a Ctrl/Alt chord (less discoverable, no
advantage).
- **No topic argument:** contextual only; `help <topic>` already owns
explicit reference lookup.
- **Comprehensive content for v1:** the full inventory, not a starter
subset.
- **Exemplars-first authoring:** lock the voice on a few blocks, then
mass-author to template.
## Consequences
- **A1 closes.** With `hint` registered and built, all 15 canonical
app-level commands exist in both modes.
- **A third contextual tier exists.** Students get on-demand, teaching-
grade guidance that is deeper than the always-on colour, the headline,
the ambient one-liner, and the verbose error hint — without cluttering
those terse defaults.
- **One new keybinding (F1)** joins the keymap and the ADR-0051 strip.
- **A new `hint_ids: &[&str]` field on `CommandNode`** (mirroring
`usage_ids`) + a `hint_key_for_input_in_mode` lookup (reusing the
`usage_key_for_input_in_mode` form-disambiguation), one new field of
`App` state (`last_error_hint_key`), and one new renderer family
(`note_hint*`); the `AppCommand` enum gains `Hint`, the grammar a `HINT`
node, the REGISTRY one entry.
- **A durable content corpus** (~37 command blocks + 10 runtime
error-class blocks) enters the catalogue under `hint.cmd.*` /
`hint.err.*`, validated by `keys.rs`. This is ongoing surface area: new
commands/error classes should ship with their tier-3 hint (a checklist
item for future feature ADRs). (Diagnostic-class blocks deferred — #38.)
- **Testing:** Tier-1 unit tests for the trigger matrix (F1 with
empty/non-empty input; `hint` with/without a recent error;
`last_error_hint_key` set on the `translate_error` sites and cleared on
success; the mode-aware form resolution; the `:` strip), the
command-identification logic, and the tier-2 fallback; Tier-2 `insta`
snapshots for a representative rendered hint block; Tier-3 integration
tests for the end-to-end flows (type a partial command → F1 → block
appears, **buffer and completion memo untouched**; run a failing
command → `hint` → error expansion). **A comprehensiveness coverage
test** (enforces D6): iterate the REGISTRY and assert every node with a
`hint_ids` entry resolves to a `hint.cmd.*` block, and every runtime
error class resolves to a `hint.err.*` block — `keys.rs` only checks
that *referenced* keys resolve, not that every command/error *has* one,
so this test is what makes the scope enforceable rather than
aspirational. (Diagnostic classes are out of this scope — D6 / #38.)
## Out of scope
- **Per-topic `hint <topic>`** — OOS (rejected): `help <topic>` already
serves explicit lookup; a topic arg would overlap it and double the
content-authoring surface.
- **Re-showing tier-3 inline as the always-on ambient hint** — OOS
(rejected): the ambient panel stays terse by design (ADR-0022); tier-3
is on-demand. Promoting it would defeat the tiering.
- **Localised tier-3 content beyond `en-US`** — OOS (deferred): the
catalogue is structured for i18n (ADR-0019), but additional locales
follow the project's English-only-for-v1 stance (requirements X2).
- **`hint` for a *successful* command's deeper teaching** (e.g. "you just
created a table — here's what an index would add") — OOS (deferred): a
plausible future tier-3 use, but v1 scopes the command path to errors
and the F1 path to in-progress input.
- **Clause-concept hints** (`… on delete ⟨action⟩`, constraint slots,
`with pk`, cardinality) — OOS (deferred, issue #37): a
`hint.concept.<topic>` layer surfaced when the cursor sits in a
recognized clause, deeper than tier-2's candidate list but narrower than
the per-form block. Per-form keying (D3) does not lock it out. To be
tackled as a deliberate follow-up job, not gated on usage statistics.
- **Pre-submit-diagnostic route + `diagnostic.*` tier-3 blocks** — OOS
(deferred, issue #38): needs a class field on `Diagnostic` threaded
through every creation site (broad change) for marginal value, since
tier-2 already surfaces diagnostics and many duplicate runtime classes
(D6).
## Content inventory (implementation tracking)
The implementation plan enumerates and checks off every block:
- **`hint.cmd.<hint_id>`** — one per distinct `REGISTRY` node (~37), each
with its own `hint_id` and a mode-correct example: app (`save`, `save
as`, `load`, `new`, `rebuild`, `export`, `import`, `replay`, `undo`,
`redo`, `mode`, `messages`, `copy`, `help`, `hint`, `quit`); DDL
(`create table`, `create m:n`, `add column`/`relationship`/`index`,
`drop`, `rename`, `change column`); DML (`insert`, `update`, `delete`,
`show`, `seed`, `explain`, `select`/`with`). The **7 advanced-mode SQL
forms** (`SQL CREATE TABLE`, `ALTER TABLE`, `CREATE/DROP INDEX`, `DROP
TABLE`, `SQL INSERT/UPDATE/DELETE`, `EXPLAIN SQL`, raw `SELECT`/`WITH`)
each get their **own** block with SQL syntax — they do **not** reuse
their simple sibling's (this is the `/runda` correction; the parallel
`help`-side gap is issue #36).
- **`hint.err.*`** — one per runtime error class (`unique`,
`foreign_key.{child,parent}_side`, `not_null`, `check`,
`type_mismatch`, `not_found`, `already_exists`, `generic`,
`invalid_value`). The `diagnostic.*` pre-submit classes are **deferred**
(D6 / issue #38).
File diff suppressed because one or more lines are too long
+195
View File
@@ -0,0 +1,195 @@
# ADR-ci-001: CI + release pipeline on Gitea Actions
## Status
**Accepted (2026-06-12); implemented the same day on the `ci` branch.** Every
fork below was settled with the user as the pipeline was built, and each stage
was verified live before acceptance:
- a throwaway probe workflow established how the runner executes jobs;
- the CI image was built and checked locally (runner contract, warm devShell);
- the gate ran green (**clippy clean; 2424 tests pass / 0 fail / 1 intentional
ignored doctest**);
- the release was exercised end-to-end — tag `v0.0.0-citest2` published a Gitea
release carrying the static binary (~10 MB) and its `.sha256`.
This ADR records the **CI/release pipeline**. The **dev/build environment it
runs on** — the nix flake (devShell + reproducible build, pinned Rust 1.95.0)
— is **ADR-ci-002** (relocated here from main's ADR-0049); this ADR builds on
it rather than restating it.
> **Namespacing.** Kept in `docs/ci/adr/` (id `ADR-ci-001`), disjoint from
> `main`'s integer ADR sequence, mirroring the website subproject's
> `docs/website/adr/`. This avoids the cross-branch number collisions that
> previously forced website ADRs to be renumbered (see that namespace's
> history note and ADR-0000 "Numbering discipline").
## Amendment — 2026-06-13: D1 matrix (non-macOS)
§3 (Release) below describes the original **single-target** (x86_64 Linux) job.
The release is now a **`test``build` matrix** over the four non-macOS D1
targets (Linux + Windows × x86_64/aarch64), cross-built with `cargo-zigbuild`.
The full decision — tooling, targets, the Windows `synchronization` stub, the
matrix shape, and the macOS deferral with its licensing rationale — is recorded
in its own record: **[ADR-ci-003](20260613-adr-ci-003.md)**.
## Context
The project is near feature-complete and needs CI (`requirements.md` **TT5**;
the **CI** item in the deferred list) and a release path for its distributed
binaries (**D1**/**D2**/**D3**). The self-hosted Gitea instance
(`git.lazyeval.net`) has its Actions runner freshly set up — a first-time
in-anger use — with a DinD-capable setup and a reusable `docker-build`
template, exercised by a handful of sample workflows.
The starting constraints, and what the probe found:
- The runner label is **`ci-public`**. A throwaway probe
(`ci-probe.yaml`, since removed) established that **jobs run *inside* a
container** — `ghcr.io/catthehacker/ubuntu:act-22.04` by default, as **root**
— and therefore the runner *host's* nix is **not** on the steps' PATH
(`nix NOT on PATH`, `no /nix`). A custom job `container:` *can* be pulled
(it pulled `nixos/nix:latest`), but the runner keeps job containers alive
with `entrypoint: /bin/sleep` and runs JS actions (e.g. `actions/checkout`)
with `node`, so the container must provide **`sleep` + `bash` + `node`** —
a bare `nixos/nix` image has none and fails to start.
- The reusable template only does `docker build`; it neither runs a Rust gate
nor pushes images nor uploads release assets — so a Rust pipeline can't just
call it.
- The whole motivation (per the user) is for CI to use the project's **nix
flake** for its tools rather than relying on whatever the build machine has
— i.e. **one toolchain definition shared by dev and CI**.
## Decision
### 1. Toolchain delivery — a baked nix CI image
CI gets its toolchain from a **purpose-built job-container image**, not from
host nix and not by installing nix per-job:
- **Base `node:22-bookworm-slim`.** Debian slim already provides `bash` +
coreutils (`sleep`); the `node` tag adds the actions runtime. This satisfies
the act_runner job-container contract at a fraction of the size of the
catthehacker runner images (chosen on the user's prompt to avoid those
multi-GB images), and far more reliably than a bare `nixos/nix` (which can't
start). `.gitea/ci-image/Dockerfile`.
- **Single-user nix on top**, flakes enabled, with the **flake's devShell
pre-warmed** (`nix develop` realizes nixpkgs + the pinned Rust toolchain +
`cargo-sweep` + the musl cc into the store). CI then runs `nix develop -c …`
against a warm store — the *same* pinned toolchain as dev (ADR-ci-002),
reaching a ready toolchain in ~1.4 s.
- **Built + pushed by `build-ci-image.yaml`** via the DinD service to the
Gitea container registry as `git.lazyeval.net/<owner>/rdbms-playground-ci`,
a **public** package (anonymous pull, no gate-side credentials). It runs only
when an image input changes (Dockerfile / `flake.nix` / `flake.lock` /
`rust-toolchain.toml`) or on manual dispatch.
### 2. Gate — `ci.yaml`
On branch pushes and PRs, a single job runs **inside the CI image**:
`nix develop -c cargo clippy --all-targets -- -D warnings` then
`nix develop -c cargo test --no-fail-fast`.
**`fmt` is deliberately not gated.** The tree isn't clean under stock
`rustfmt` (~100 files would change; no `rustfmt.toml` is committed) and
reformatting would churn blame across the in-flight website branch and ongoing
`main` work — so, by user decision, the gate is **clippy + test** and fmt is
revisited on `main` (also recorded in ADR-ci-002).
### 3. Release — `release.yaml`
On a `v*` tag, one job in the CI image:
1. **tests** (`cargo test`) — so a tag can never publish untested code, even
one pointing at a never-gated commit (user choice over relying solely on the
branch gate);
2. **builds the static binary** for **`x86_64-unknown-linux-musl`** (D2:
single static binary, no runtime deps). The glibc/nix-store build is
non-portable; the musl target with `crt-static` is fully static. rusqlite's
`bundled` SQLite C is compiled by a **musl `cc`** (`pkgsCross.musl64`) wired
into the flake devShell via `CC_<target>` + `CARGO_TARGET_<TARGET>_LINKER`;
`[profile.release] strip = "symbols"` trims it (~13 MB → ~10 MB);
3. **publishes** the binary + a `.sha256` to a Gitea release via the API and
the auto-provided **`GITEA_TOKEN`** — no third-party action (just `curl` +
`node`, both in the image).
### 4. Triggers — branch vs tag hygiene
- Gate and image-build are scoped to **branch** pushes (`branches: ['**']`).
Tag pushes ignore `paths:` filters and would otherwise spuriously rebuild the
unchanged image and re-gate an already-gated commit; the branch filter
excludes tags. **`release.yaml` owns tags** (`tags: ['v*']`).
- Pushing commits + a tag together still gates the commits (via the branch
ref) and releases (via the tag ref) — no lost coverage, no duplicate runs.
### 5. Auth
- **Image push:** a dedicated PAT with `write:package`, supplied as the
`REGISTRY_USERNAME` / `REGISTRY_TOKEN` Actions secrets (the package owner
must match the token's user — an `oli`-namespace push with a different user
is refused with `reqPackageAccess`).
- **Release publish:** the auto `GITEA_TOKEN` (repo/release scope).
### 6. Scope this iteration — Linux x86_64, step by step
The user's target is the full **D1** matrix, approached incrementally. This
iteration ships **Linux x86_64 only**; the rest is deferred (below).
## Consequences
- **One toolchain, dev and CI.** They build through the same flake and cannot
drift. New image rebuilds only when the flake/toolchain/Dockerfile change.
- **D2 is met on Linux.** The release artifact is a genuinely static,
stripped musl binary that runs with no runtime dependencies.
- **DinD is per-job (no layer cache across runs),** so every `build-ci-image`
run rebuilds from scratch (~6 min). Acceptable at its trigger frequency;
base-pull caching via the `dind-cached` proxy variant is a possible later
optimisation.
- **The CI image is ~5.5 GB+** (the Rust toolchain closure, now also musl).
Pulled once per runner and cached; slimming (multi-stage, prune) is optional.
- **Every gate run recompiles the full dependency graph** (warm *toolchain*,
cold *deps*; clippy and test don't share artifacts), ~2 min total. Fine for
now; dependency/`target` caching is a deferred speed item.
- **`GITEA_TOKEN` must retain release scope;** if an instance policy narrows
it, the release publish falls back to a repo-scoped PAT secret.
## Alternatives considered
- **Run on the runner host's nix.** Rejected — the probe showed steps run in a
container where host nix is unreachable.
- **Install nix per-job in the default image.** Works but cold every run
(slow) and throwaway once the image exists; rejected in favour of the baked
image.
- **`catthehacker` or bare `nixos/nix` as the base.** catthehacker is a
multi-GB runner emulation we don't need; bare `nixos/nix` lacks
`sleep`/`bash`/`node` and won't start. `node:22-bookworm-slim` is the small,
contract-satisfying middle (user's suggestion).
- **A standard `rust:1.95` CI image instead of the flake.** Simpler in CI but a
*second* toolchain definition (drift) — counter to the unify-with-dev goal.
- **A third-party Gitea release action.** Avoided; the API + auto token keep
the release self-contained and debuggable.
## Deferred / out of scope (tracked, step by step)
- **D1 matrix:** **macOS only** now (x86_64 + aarch64). The four non-macOS
targets shipped via cargo-zigbuild (see the 2026-06-13 amendment); macOS needs
Apple's SDK (osxcross + private SDK, or a Mac runner).
- **D3 packaging:** Homebrew / Scoop / winget / `cargo-binstall` manifests
(and binstall-friendly asset naming/archives).
- **Tier 4 (PTY E2E):** still unwired (`requirements.md` **TT4**); the gate runs
tiers 13 only, so **TT5** ("CI runs all tiers on Linux/macOS/Windows") is
partially met — Linux, tiers 13.
- **CI speed:** dependency/`target` caching (cargo-chef into the image, or
`actions/cache`), and image slimming / `dind-cached` base-pull caching.
- **Website deploy:** the static site → Cloudflare via Gitea Actions (a
separate, simpler workflow on the website branch).
- **fmt gate:** revisit on `main` once a `rustfmt` style is chosen.
## Relationship to other decisions
- **Builds on ADR-ci-002** (nix flake dev + build env). This ADR adds the
musl-target/cc to that flake and consumes it from CI.
- **Advances `requirements.md`:** **TT5** (CI runs the tiers — Linux, 13),
**D2** (static binary — Linux, done), **D1**/**D3** (partial/deferred).
- **Mirrors the website subproject's** separate ADR namespace and its
static→Cloudflare-via-Gitea-Actions deployment posture (ADR-website-001).
+135
View File
@@ -0,0 +1,135 @@
# ADR-ci-002: Nix flake for a reproducible dev + build environment
## Status
**Accepted (2026-06-12).** Implemented the same day on the `ci` branch:
`flake.nix`, `flake.lock`, `rust-toolchain.toml`, `.envrc`. Verified
end-to-end before acceptance — `nix develop` provides the pinned
toolchain; `nix build .#default` produces a working binary; `cargo
clippy --all-targets -- -D warnings` is clean and `cargo test` is
**2424 passed / 0 failed / 1 ignored** (the ignored item is the
intentional ```` ```ignore ```` doctest at `src/friendly/mod.rs:21`),
all run *through the flake*. This ADR is the dev/build-environment
foundation; the CI **pipeline** that consumes it (runner model, image,
gate, release) is **ADR-ci-001**.
> **History.** Created as **ADR-0049** in `main`'s integer ADR namespace
> (`docs/adr/`); moved here to **ADR-ci-002** on 2026-06-12 to keep the
> CI/dev-env decisions out of `main`'s sequence and end the cross-branch
> number collision (`main` independently reaches for the next integer too —
> the same problem the website subproject hit). Content is otherwise
> unchanged. See ADR-0000 "Numbering discipline".
## Context
The project is near feature-complete and CI is finally being set up
(`requirements.md` **TT5**, **CI** in the deferred list). CI must not
depend on whatever Rust/toolchain happens to be installed on the build
machine — that is neither reproducible nor honest about what the build
needs.
The sibling project **datamage** already solved this with a Nix flake
(its ADR 0046): the flake is the single, version-pinned declaration of
the toolchain, and both the dev shell and CI go through it so they
cannot drift. We adopt the same pattern here. Ours is dramatically
simpler than datamage's — this is a pure-Rust TUI with no Tauri /
WebKitGTK / Node / WASM surface — so the flake carries almost no system
dependencies.
Two build facts drove the (tiny) dependency set, confirmed from
`Cargo.lock`:
- **`libsqlite3-sys` is built with `bundled`** → SQLite is compiled
from vendored C, which needs a C compiler. `nixpkgs`' `stdenv`
provides one automatically; nothing is declared for it.
- **`arboard`'s clipboard backend is `x11rb`** — a pure-Rust socket
XCB client that links *no* C X11 libraries. So no X11/`pkg-config`
system inputs are needed to build or test. A live X server is only
required at *runtime* to actually copy; headless sessions fall back
to OSC 52.
## Decision
Adopt a **Nix flake** at the repository root as the canonical
declaration of the dev *and* build environment.
- **`flake.nix`** exposes two outputs (user-chosen 2026-06-12 over a
dev-shell-only variant):
- **`devShells.default`** — the pinned Rust toolchain (from
`rust-toolchain.toml` via `rust-overlay`) plus `cargo-sweep` for
the `target/` build-hygiene discipline (CLAUDE.md / the datamage
ADR 0050 equivalent).
- **`packages.default`** (= `packages.rdbms-playground`) — a
`rustPlatform.buildRustPackage` that produces the binary
reproducibly from the pinned toolchain and the committed
`Cargo.lock` (`cargoLock.lockFile``importCargoLock`, which
fetches each dependency by its lockfile checksum: offline,
deterministic, no `cargoHash` to churn). `nix build` yields the
artifact CI's gate/release can consume.
- **`rust-toolchain.toml`** pins an **exact stable release**
(`1.95.0`), not the floating `stable` channel, so `nix flake update`
cannot surprise-bump Rust into new clippy lints that would fail the
`-D warnings` gate (same reasoning as datamage ADR 0046). Components:
`rustfmt` + `clippy`. No coverage/WASM tooling and no
cross-compilation targets yet — those are added when the release
matrix needs them, not before.
- **`flake.lock`** pins every input (`nixpkgs` `nixos-26.05`,
`rust-overlay`, `flake-utils`) to a commit, making the env
bit-reproducible.
- **`.envrc`** contains `use flake` for direnv auto-activation, kept
for parity with datamage even though direnv is not installed on the
current dev VM (entry is via `nix develop`).
- **`packages.default` sets `doCheck = false`.** The test suite is
*not* run during `nix build` — the Nix build sandbox has no `HOME`
and no X server, which fights the project-directory / clipboard
paths the tests touch. Tests run as their own CI stage via
`nix develop -c cargo test`, keeping "build the artifact" and "run
the suite" cleanly separate.
- **The package version is read from `Cargo.toml`** via
`builtins.fromTOML`, so it never drifts from the crate metadata.
## Consequences
- **One toolchain definition.** Dev and CI share the exact pinned
toolchain; they cannot drift. New contributors run `nix develop`
(or get auto-activation via direnv) and have the same Rust as CI.
- **D2 (static binary) is unaffected and still pending.** The
`nix build` artifact links the Nix-store glibc *dynamically* — it is
a reproducible build/test artifact, **not** the single static
release binary D2 calls for. Release binaries will target a static
toolchain (e.g. `x86_64-unknown-linux-musl`) in the forthcoming CI
release work; that is a release-step concern, not a dev-shell one.
- **`fmt` is deliberately *not* gated yet.** The tree is not clean
under stock `rustfmt` (~100 files would change; no `rustfmt.toml` is
committed and the code was shaped by something other than default
`rustfmt`). Reformatting churns blame across every file and would
conflict with the in-flight website branch and ongoing `main` work,
so — user decision 2026-06-12 — the `fmt` gate is left out for now
and revisited on `main`. The CI gate is `clippy` + `test`.
- **Engine-name posture (CLAUDE.md) is respected.** The flake's
comments may name SQLite/`rusqlite` where technically necessary
(build-input rationale); no user-facing string is affected.
## Alternatives considered
- **Dev-shell only (no build package).** Matches datamage exactly; CI
would `cargo build` inside `nix develop -c`. Rejected (user choice):
a `nix build` package gives a reproducible release artifact straight
from the pinned toolchain, which the release job wants.
- **A standard `rust:1.95` image in CI, flake for dev only.** Simpler
in CI (no nix-in-CI caching to solve), but it is a *second* place
that defines the toolchain — exactly the drift this ADR exists to
prevent. Rejected for the unified-env goal; the nix-in-CI caching
cost is solved in the CI pipeline work instead.
- **`rustup` on the build machine.** The status quo CI would replace —
non-reproducible, machine-dependent, the thing we are eliminating.
## Relationship to other decisions
- Mirrors **datamage ADR 0046** (nix flake dev env) and its build
hygiene companion. This is the rdbms-playground analogue, scoped to
a pure-Rust project.
- Feeds **ADR-ci-001** (the CI + release pipeline), which consumes this
flake for `requirements.md` **TT5** (CI runs the tiers) and the
**D1/D2/D3** distribution items (the release uses a static musl target
built through this flake).
+195
View File
@@ -0,0 +1,195 @@
# ADR-ci-003: Cross-platform release builds (the D1 matrix)
## Status
**Accepted (2026-06-13); implemented the same day on the `ci` branch.** Every
fork was settled with the user. Verified end-to-end:
- all four targets cross-build locally from Linux x86_64;
- the Linux binaries are statically linked (D2); the Windows artifacts are
valid PE32+ (x86-64 / Aarch64);
- a real release-matrix run (tag `v.0.0.0-citest3`) published **8 assets** — the
four binaries + a `.sha256` each.
**Runtime-verified (2026-06-13, by the user):** the **Linux x86_64** and
**Windows aarch64** binaries launch and run correctly — one of each OS family
and both architectures. The remaining two (**Linux aarch64**, **Windows
x86_64**) are link-clean and valid format but not yet runtime smoke-tested.
This ADR records the **cross-platform build strategy**; it sits on top of
**ADR-ci-002** (the nix flake, which now carries the cross toolchain) and
**ADR-ci-001** (the pipeline, whose release job this fills in).
## Amendment — 2026-06-14: macOS implemented (closes D1)
macOS is no longer deferred. The two `*-apple-darwin` targets now build on a
**Tart (Apple-Silicon) macOS runner** registered to Gitea — building on **real
Apple hardware** makes the SDK fully licensed, so the whole osxcross / SDK
grey-area + public-image-redistribution problem (§5 below) simply **does not
arise**. With all six D1 targets producing artifacts, **D1 is complete.**
Details, all verified on the runner via a throwaway smoke-test before wiring the
release leg:
- **`release-macos.yaml`** — `workflow_dispatch` with a `tag` input,
`runs-on: macos`. The runner registered as `macos:host`, but `:host` is
act_runner's execution-backend schema (run on host, no container), **not** part
of the label, so the label is `macos`. Steps: `cargo test` (macOS gets the only
automated test coverage outside the Linux gate — user choice) → build both
darwin targets natively through the flake (`apple-sdk` added to the devShell so
the toolchain links AppKit) → **upload to the same release** via the idempotent
create-or-get.
- **De-nix + re-sign.** The darwin stdenv bakes a `/nix/store` `libiconv` load
path into the binary (the *only* non-system dependency; everything else is
AppKit/Foundation/CoreGraphics/IOKit + `libSystem`/`libobjc`). The release step
rewrites it to `/usr/lib/libiconv.2.dylib` with `install_name_tool` and
**re-signs ad-hoc** (`codesign -f -s -`) — `install_name_tool` invalidates the
signature and Apple Silicon refuses an unsigned binary. A guard fails the build
if any `/nix/store` path remains. Result: portable, signed binaries (the native
one was confirmed to launch).
- **Dispatch-only, intermittent runner.** The Mac isn't always on, so macOS is a
separate dispatched workflow (not a job in `release.yaml`) — a release always
carries the four Linux/Windows assets regardless of the Mac, and the two macOS
assets are added by dispatching `release-macos` for that tag. **Caveat:** Gitea
exposes `workflow_dispatch` only for workflows on the **default branch**, so
`release-macos` becomes triggerable once the CI work is merged to `main`.
- **Cache hygiene (host-execution runner).** The runner wipes the workspace each
run, so cargo `target/` never accumulates; the persistent cache is the nix
store, bounded by **generation** — record the current devShell in a persistent
profile, keep the 2 newest generations (`nix-env --delete-generations +2`),
reclaim the rest. (The first sweep reclaimed a ~3.8 GB one-time backlog of
build scaffolding — source + build-only deps, not re-installed toolchains.)
- **D2 on macOS.** macOS binaries cannot be fully static (`libSystem` is always
dynamic); "no runtime deps" there means *system libraries only*, which the
de-nix step guarantees.
## Context
`requirements.md` **D1** asks for binaries on **Linux, macOS, Windows × x86_64
and aarch64** (six targets); **D2** asks for a **single static binary, no
runtime deps**. The CI runner executes jobs in a **Linux x86_64** container
(ADR-ci-001), so every target is **cross-compiled from Linux**.
What's feasible is decided almost entirely by one dependency — **`arboard`**
(the clipboard backend for the `copy` command). Its per-platform backends in
`Cargo.lock`:
| Target family | arboard backend | Needs a platform SDK to cross-link? |
|---|---|---|
| Linux x86_64 / aarch64 | `x11rb` (pure Rust) | No |
| Windows x86_64 / aarch64 | `clipboard-win` + `windows-sys` (import libs bundled) | No |
| **macOS x86_64 / aarch64** | **`objc2-app-kit` → links AppKit** | **Yes — Apple's SDK** |
So **four targets cross-compile with no SDK**; **macOS is the hard wall**
AppKit can only be linked against Apple's SDK.
## Decision
### 1. Tooling — `cargo-zigbuild`
Cross-compile with **`cargo-zigbuild`** (Zig's bundled clang + libc as a single
universal cross `cc`/linker), added to the flake devShell alongside `zig`. One
tool serves every non-macOS target, **including the `cc`-crate compile of
rusqlite's bundled SQLite C**, with no per-target toolchain. It replaced the
earlier single-target musl `cc` (ADR-ci-002's first cut).
### 2. Targets this iteration — the four non-macOS
Added to `rust-toolchain.toml` and the release matrix:
- **`x86_64-unknown-linux-musl`**, **`aarch64-unknown-linux-musl`** — musl +
`crt-static`, so **fully static** portable binaries (D2);
- **`x86_64-pc-windows-gnu`**, **`aarch64-pc-windows-gnullvm`** — Zig statically
links its libc, so the `.exe` is **standalone** (no mingw runtime DLLs).
### 3. The Windows `synchronization` stub
Rust's `std` links **`-lsynchronization`** (its `WaitOnAddress`-based thread
parking). That import library is normally supplied by Rust's `rust-mingw`
"self-contained" component — which **rust-overlay does not ship** — and Zig's
mingw doesn't carry it either, so the link fails with *"unable to find dynamic
system library 'synchronization'"*. The functions (`WaitOnAddress`,
`WakeByAddress*`) are **forwarded by `kernel32`** (already linked), so an
**empty stub** `libsynchronization.a` (committed at **`ci/winstub/`**, 8 bytes,
wired via **`.cargo/config.toml`** for the Windows targets *only*) satisfies the
linker without contributing symbols. Host and Linux builds are untouched by it.
### 4. Workflow shape — test once, then a build matrix
`release.yaml` is **`test``build`**:
- **`test`** runs once on the host (`cargo test`) — a tag never publishes
untested code;
- **`build`** is a **matrix over the four targets** (`needs: test`,
`fail-fast: false`), each `cargo zigbuild --release --target <triple>`, then
packages the binary (`.exe` for Windows) + a `.sha256` and uploads both to the
**shared release** via an **idempotent create-or-get** (the first matrix job
creates the release; the rest fetch it).
### 5. macOS — deferred, with rationale
macOS is **not** in this iteration. `arboard`→AppKit needs the macOS SDK, and:
- the SDK ships **only inside Xcode**; Apple's license ties its use to
**Apple-branded hardware**, so using it on a Linux runner is a **grey area**
(widely done, low enforcement, but technically against the terms);
- **redistributing** the SDK is a clearer violation — and our **CI image is
public**, so the SDK **cannot be baked into it** even if the grey area were
accepted; it would have to live in a private store;
- the **clean** path is building on **real Apple hardware** (a Mac registered as
a Gitea runner, or hosted Mac CI), where the SDK is fully licensed.
macOS therefore becomes its **own step**, choosing between **(a)** osxcross + a
**private** SDK kept out of the public image, or **(b)** a **Mac runner**. The
user decides when we get there.
## Consequences
- **D1: four of six targets met** from a single Linux runner; **D2 met on
Linux** (static musl). Windows `.exe`s are standalone.
- **Runtime coverage:** Linux x86_64 + Windows aarch64 confirmed running
(user, 2026-06-13); Linux aarch64 + Windows x86_64 are the outstanding
runtime checks.
- **Each matrix target recompiles from scratch** (~24 min; ~10 min total on the
single runner), and Zig's per-target libc cache is cold each run. Fine at
release frequency; cacheable later if it matters.
- **The empty stub depends on `kernel32` forwarding `WaitOnAddress`** (true on
Windows 8+), which covers every supported target.
- **Asset naming** `rdbms-playground-<tag>-<target>[.exe]` is close to what
`cargo-binstall` / the D3 package managers will want.
## Alternatives considered
- **`cross` (cross-rs).** Docker-image-per-target; covers Linux + Windows but
**not macOS** (no legally redistributable Apple images), and needs DinD
orchestration inside our job. Rejected — no macOS, more moving parts than
zigbuild.
- **Per-target nix cross (`pkgsCross`).** Clean for Linux-musl and
Windows-x86_64 (mingw-w64, which *does* ship `libsynchronization.a`), but
Windows-aarch64 isn't readily packaged and **macOS-from-Linux is unsupported**
in nixpkgs. Rejected — incomplete.
- **Native runners per OS.** Cleanest for macOS/Windows, but needs mac/windows
runners we don't have. Kept on the table specifically for the deferred macOS
step.
- **A real `libsynchronization.a`** (from nixpkgs mingw or a `rust-mingw`
component) instead of the empty stub. More principled, but more flake
machinery, doesn't cover Windows-aarch64, and unnecessary — the stub links
clean because the symbols resolve via `kernel32`.
## Deferred / out of scope
- ~~**macOS** (x86_64 + aarch64)~~**done** via the Tart runner (see the
2026-06-14 amendment); §5 below is the as-deferred rationale, kept for history.
- **D3 packaging** — Homebrew / Scoop / winget / `cargo-binstall` manifests
(and binstall-friendly archive naming).
- **CI speed** — caching per-target builds / Zig's libc cache.
- **Runtime smoke test** of the two not-yet-checked targets (Linux aarch64,
Windows x86_64).
## Relationship to other decisions
- **Extends ADR-ci-002** — the flake devShell now carries `cargo-zigbuild` +
`zig` and the four release targets.
- **Fills in ADR-ci-001 §3 (Release)** — that single-target job is now this
matrix.
- **Advances `requirements.md`** **D1** (4/6) and **D2** (Linux, done).
+23
View File
@@ -0,0 +1,23 @@
# CI / Build Architecture Decision Records
Decision records for the **continuous-integration + release pipeline**
subproject — the Gitea Actions workflows under `.gitea/`, the nix CI image,
and the release tooling. These are kept in their own namespace, separate
from the project-wide ADRs in [`docs/adr/`](../../adr/README.md), so CI
decisions never compete with the main global ADR sequence for numbers — the
same split the website subproject uses (`docs/website/adr/`, on the `website`
branch), and for the same reason (see
[ADR-0000 "Numbering discipline"](../../adr/0000-record-architecture-decisions.md)).
**Numbering.** Files are named `<date>-adr-ci-<NNN>.md` and referenced in
prose as `ADR-ci-NNN`. The `<date>` (the ADR's accepted/created day,
`YYYYMMDD`) plus the `ci` segment keeps the namespace disjoint from `main`'s
integers. Assign the next free `NNN` from this index. Every ADR change
updates this index in the same edit (the ADR-0000 index-upkeep rule applies
here too).
## Index
- [ADR-ci-001 — CI + release pipeline on Gitea Actions](20260612-adr-ci-001.md) — **Accepted 2026-06-12** (implemented the same day on the `ci` branch). Establishes the CI/release pipeline on the self-hosted Gitea instance's Actions runner (`ci-public`). **Runner model** (established by a throwaway probe): jobs execute *inside* a container (`catthehacker/ubuntu:act-22.04` by default), as root, so the runner host's nix is **not** reachable from steps. **Toolchain delivery:** a **baked CI image**`node:22-bookworm-slim` (satisfies the act_runner job-container contract: `/bin/sleep` keep-alive, `bash`, `node` for JS actions; a bare `nixos/nix` image lacks these and won't start) **+ single-user nix + the flake's devShell pre-warmed** — built by `build-ci-image.yaml` via DinD and pushed to the Gitea container registry as a **public** package, so CI runs `nix develop -c …` against the **same pinned toolchain as dev** (the flake, ADR-ci-002) with a warm store (~1.4 s to a ready toolchain). **Gate** (`ci.yaml`): `clippy -D warnings` + `cargo test` inside that image on branch pushes + PRs; **fmt deliberately not gated** (the tree isn't stock-rustfmt-clean — user decision, revisit on `main`; see ADR-ci-002). **Release** (`release.yaml`): on a `v*` tag, runs the tests, builds the **static `x86_64-unknown-linux-musl` binary** (D2: single static binary, no runtime deps — the glibc/nix build is non-portable), strips it, and publishes it + a `.sha256` to a Gitea release via the API and the auto-provided `GITEA_TOKEN`. **Triggers:** gate + image-build are scoped to **branch** pushes (`branches: ['**']`) so a release tag doesn't spuriously re-run them; the image-build additionally path-filters to its inputs (Dockerfile/flake/toolchain); the release owns tags. **Auth:** a dedicated PAT (`REGISTRY_USERNAME`/`REGISTRY_TOKEN` secrets) pushes the image; the auto `GITEA_TOKEN` publishes releases. **Scope:** the original release job was Linux x86_64 only; it's now the **four non-macOS D1 targets** (Linux + Windows × x86_64/aarch64) cross-built via cargo-zigbuild — see **ADR-ci-003**. macOS, D3 package-manager manifests, CI-speed dependency caching, and the website's static→Cloudflare deploy remain deferred, added step by step. Verified live: probe → runner facts; image built + checked locally; gate green (**2424 tests**); release exercised end-to-end (`v0.0.0-citest2` published with binary + checksum). Builds on **ADR-ci-002** (the nix flake, relocated here from main's ADR-0049 to avoid exactly this cross-branch collision).
- [ADR-ci-002 — Nix flake for a reproducible dev + build environment](20260612-adr-ci-002.md) — **Accepted 2026-06-12** (relocated from main's **ADR-0049** on the same day — content unchanged — to keep CI/dev-env decisions out of `main`'s integer sequence). The single, version-pinned declaration of the **dev *and* build toolchain** so CI never relies on whatever Rust is on the build machine — mirroring **datamage ADR 0046**, but far simpler (pure-Rust TUI). Root **Nix flake** with two outputs: **`devShells.default`** (pinned **Rust 1.95.0** via `rust-toolchain.toml` + `rust-overlay`, `cargo-sweep`, and the musl cc for the static release build) and **`packages.default`** (`rustPlatform.buildRustPackage` from the committed `Cargo.lock`; `doCheck = false`). Exact-pin (not floating `stable`) so `nix flake update` can't surprise-bump clippy past the `-D warnings` gate. System inputs near-empty by design (`libsqlite3-sys bundled` → stdenv cc only; `arboard``x11rb` pure-Rust). `.envrc` (`use flake`) for direnv parity. Verified through the flake: `nix build` yields a working binary, clippy clean, **2424 tests pass / 0 fail / 1 intentional ignored doctest**. Consumed by **ADR-ci-001** (the pipeline). Alternatives rejected: dev-shell-only; a standard `rust:1.95` CI image (a second toolchain definition = drift); `rustup` on the build host (non-reproducible).
- [ADR-ci-003 — Cross-platform release builds (the D1 matrix)](20260613-adr-ci-003.md) — **Accepted 2026-06-13** (implemented + a real matrix release verified the same day — tag `v.0.0.0-citest3` published 8 assets). Cross-compiles the **four non-macOS D1 targets** from the Linux x86_64 runner with **`cargo-zigbuild`** (Zig's bundled clang + libc as one universal cross cc/linker, incl. rusqlite's bundled SQLite C; added to the flake devShell, replacing the single-target musl cc): **`x86_64`/`aarch64-unknown-linux-musl`** (musl + crt-static → fully static, **D2**) and **`x86_64-pc-windows-gnu`** / **`aarch64-pc-windows-gnullvm`** (Zig statically links libc → standalone `.exe`). **Windows `synchronization` stub:** Rust std links `-lsynchronization` (WaitOnAddress thread-parking), an import lib rust-overlay's toolchain doesn't ship and Zig's mingw lacks; the symbols are forwarded by `kernel32`, so an **empty 8-byte stub** `libsynchronization.a` (`ci/winstub/`, wired via `.cargo/config.toml` for the Windows targets only) satisfies the linker. **Workflow:** `release.yaml` = **`test` once (host) → `build` matrix** over the four targets (`needs: test`, `fail-fast: false`); each job packages binary (`.exe` for Windows) + `.sha256` and uploads to the **shared release** via idempotent create-or-get. **macOS** (2026-06-14 amendment) — built natively on a **Tart (Apple-Silicon) runner** (`runs-on: macos`), which makes the SDK fully licensed and dissolves the grey-area/public-image problem; `release-macos.yaml` is **dispatch-only** (intermittent runner; becomes triggerable once CI is on `main`), de-nixes the binary's libiconv load path (`install_name_tool``/usr/lib`) + re-signs ad-hoc, and uploads to the tagged release. **D1 complete (all six targets).** Alternatives rejected: `cross` (no macOS, needs DinD), per-target nix cross (Windows-aarch64 unpackaged, macOS-from-Linux unsupported), a real `libsynchronization.a` (more machinery, doesn't cover Windows-aarch64). Runtime-verified by the user (2026-06-13): Linux x86_64 + Windows aarch64 run correctly; Linux aarch64 + Windows x86_64 are the outstanding runtime checks. Builds on ADR-ci-002 (flake) and fills in ADR-ci-001 §3 (Release).
+104
View File
@@ -0,0 +1,104 @@
# CI subproject handoff — 2026-06-15 (ci-01)
First handover for the **CI / release subproject** (the `ci` branch). Kept in
`docs/ci/handoff/`, a namespace separate from the project's global
`docs/handoff/` session sequence so it can't collide with `main`'s numbering —
the same split as `docs/ci/adr/`, and needed for the same reason: `main`
independently wrote its own **handoff-70** this same day (just as it took
**ADR-0049**), which would have collided.
A dedicated infrastructure session that built the project's **entire CI/CD
pipeline** on the self-hosted Gitea Actions runner — from nothing to a live
gate plus a six-target cross-platform release. Net: the **CI** /
`requirements.md` **TT5** item and **D1**/**D2** are now done; **D3** and a
couple of TT5 tails remain. Decisions are recorded in the sibling ADR namespace
**`docs/ci/adr/`** (ADR-ci-001/002/003).
## §1. State at handoff
**Branch:** `ci` (worktree). **`main` has been merged into `ci`** (commit
`138e766`, clean — `ci` and `main` touched disjoint files) so the gate runs
against current `main` before CI lands there. Working tree clean except the
in-progress doc updates from this handoff. Pushes/promotion are the user's
step.
**Gate verified locally on the merged code:** `clippy -D warnings` clean;
**`cargo test` 2488 passing / 0 failing / 1 ignored** (the long-standing
`friendly` doctest). main's features came in with their tests (2424 → 2488).
**Pipeline (`.gitea/workflows/`):**
- `build-ci-image.yaml` — builds + pushes the CI image (`node:22-bookworm-slim`
+ single-user nix + the flake's devShell pre-warmed) to the Gitea registry.
Triggers only on image-input changes (Dockerfile / flake / toolchain).
- `ci.yaml` — the gate: `clippy -D warnings` + `cargo test`, branch pushes + PRs
(docs-only changes skipped).
- `release.yaml` — on a `v*` tag: `test``build` matrix over the **four
non-macOS** targets via `cargo-zigbuild`, upload to the Gitea release.
- `release-macos.yaml`**workflow_dispatch** (tag input) on the Tart
Apple-Silicon runner (`runs-on: macos`): test → build both `*-apple-darwin`
→ de-nix `libiconv` + ad-hoc re-sign → upload.
**Verified live this session:** the 4-target release published **8 assets**
(binary + `.sha256` each) for tag `v.0.0.0-citest3`; the macOS build was proven
portable (system-only deps) + signed + launches on the runner.
## §2. What was built (and the non-obvious bits)
- **Nix flake** (ADR-ci-002, relocated from a would-be `main` ADR-0049): one
pinned toolchain (`1.95.0`) for dev *and* CI; `cargo-zigbuild` + `zig` (Linux
only) for the cross targets; `apple-sdk` on darwin.
- **Runner facts** (ADR-ci-001): jobs run *inside* a container (`ci-public`
`catthehacker/ubuntu`), so host nix is unreachable — hence the baked image.
The Mac runner is **host execution**; its label is `macos` (`:host` in the
registration is the act_runner backend, not part of the label).
- **Cross-compile** (ADR-ci-003): `cargo-zigbuild` for the 4 non-macOS targets.
Windows needs an **empty `libsynchronization.a` stub** (`ci/winstub/`, wired
via `.cargo/config.toml`) — std links `-lsynchronization`, absent from
rust-overlay's toolchain + zig's mingw, but forwarded by `kernel32`.
- **macOS** (ADR-ci-003 amendment): built on **real Apple hardware** (Tart), so
the SDK is fully licensed — no osxcross grey area. The darwin stdenv bakes a
`/nix/store` `libiconv` path into the binary; the build rewrites it to
`/usr/lib/libiconv.2.dylib` (`install_name_tool`) and re-signs ad-hoc
(`codesign -f -s -`; `install_name_tool` invalidates the signature, arm64
refuses unsigned). A guard fails the build on any remaining `/nix/store` dep.
- **Cache hygiene (Mac):** the runner wipes the workspace each run, so cargo
`target/` never accumulates; the persistent nix store is bounded by
**generation** (record the devShell in a persistent profile, keep the 2
newest via `nix-env --delete-generations +2`, GC the rest). First sweep
reclaimed a ~3.8 GB one-time backlog of build scaffolding (source + build-only
deps, *not* re-installed toolchains).
## §3. Immediate next steps (user)
1. **Push `ci`** → the gate re-runs in CI (should be green; no image rebuild —
the merge didn't touch the flake/Dockerfile).
2. **Promote:** `git checkout main && git merge ci` — a **fast-forward** (`ci`
already contains `main`) — then push `main`. CI goes live; `release-macos`
becomes dispatchable (workflow_dispatch needs the default branch).
3. **First real release:** tag `v0.1.0` (auto-builds the 4 Linux/Windows
assets), then **dispatch `release-macos` for `v0.1.0`** with the Mac up (adds
the 2 macOS assets) → a full 6-binary release.
4. **Cleanup:** delete the `v.0.0.0-citest*` test tags + their releases.
5. **Runner-side:** add `min-free`/`max-free` to the Mac's `/etc/nix/nix.conf`
as a hands-off nix-store backstop.
## §4. Known gaps / follow-ups
- **Versioning is not wired into the binary** (flagged by the user). The release
**git tag is nowhere in the produced binary** — there is no `--version` flag,
no `CARGO_PKG_VERSION` use anywhere in `src/`, and the release workflows use
the tag only for the *release name* + *asset filenames*
(`rdbms-playground-<tag>-<target>`). `Cargo.toml` is a static `version =
"0.1.0"`, decoupled from the tag. So a `v0.5.0` tag yields a `…-v0.5.0-…`
asset whose binary knows nothing of "0.5.0". To fix later: add a `--version`
flag, and inject the tag at build time (e.g. a `build.rs` reading a
CI-provided env, or bumping `Cargo.toml` as part of tagging) so the binary and
the release agree.
- **D3 packaging** — Homebrew / Scoop / winget / `cargo binstall` manifests
(asset naming is already binstall-friendly).
- **TT5 tails** — Windows is build-only (no execution runner); Tier-4 PTY (TT4)
is unwired in CI.
- **`fmt` gate** — deliberately off (tree isn't stock-`rustfmt`-clean); revisit
on `main`.
- **Website → Cloudflare** deploy — the separate, simpler workflow, still to do.
+21
View File
@@ -0,0 +1,21 @@
# CI / Build subproject — session handoffs
Handover notes for the **CI / release pipeline** work (the Gitea Actions
workflows under `.gitea/`, the nix flake, the release tooling). Kept in their
own namespace, separate from the project-wide session handoffs in
[`docs/handoff/`](../../handoff/), so a CI-branch handoff never competes with
`main`'s global handoff sequence for numbers — the same split the CI ADRs use
([`docs/ci/adr/`](../adr/README.md)). This is not hypothetical: `main`
independently wrote a `handoff-70` the same day this subproject's first handoff
was drafted.
**Numbering.** Files are named `<date>-handoff-ci-<NN>.md` and referenced in
prose as `handoff-ci-NN`. Assign the next free `NN` from this index.
## Index
- [handoff-ci-01 — the CI/release pipeline build-out](20260615-handoff-ci-01.md)
— Gitea Actions gate (clippy + test) + a six-target release (four via
`cargo-zigbuild` on a `v*` tag, two macOS via dispatch on a Tart runner), all
on a nix flake; decisions in `docs/ci/adr/`. Built on the `ci` branch, merged
`main` in, gate green (2488 tests), ready to promote to `main`.
+203
View File
@@ -0,0 +1,203 @@
# Session handoff — 2026-06-14 (69)
Sixty-ninth handover. Continues from handoff-68 (an issue-burndown that
closed #25/#26/#31/#32/#33/#34). This session **closed the four
remaining open issues** — #29, #28, #27, #30 — each landed with the full
phased workflow + `/runda` + Devil's-Advocate passes before commit, and
each producing a new ADR. Net: **four issues closed, four commits, four
new ADRs (00490052), +63 tests, zero regressions, the tracker is now
empty.**
The four interlock: **#29** added the input-field readline keys, **#27**
advertises them in a state-aware status strip, and **#30**'s history
recall now respects modes. **#30** also turned into a real architecture
change (journaling relocation) — read §2.4 carefully before touching that
area.
## §1. State at handoff
**Branch:** `main`. Working tree **clean**; all work committed. The two
most recent commits are local (normal working state — push is the user's
step).
**Tests: 2471 passing / 0 failing / 0 skipped / 1 ignored** (the
long-standing `friendly` doctest). **Clippy clean** (nursery, all
targets). Breakdown: 1771 lib + 500 integration (`it`) + 200
typing-surface-matrix. **+35 over handoff-68's 2436** (net: #29 +22, #28
+0, #27 +9, #30 +4 — its new history.rs/app.rs/iteration6 tests minus the
15 retired worker-journaling tests; trust the live `cargo test` count).
**Commits this session:**
```
4aeea55 feat(history): mode-tagged history + top-of-chain journaling (#30)
eceedc1 feat(ui): context- and state-aware bottom keybinding strip (#27)
8ac3537 feat(render): incidental-DDL confirmations show structure only, no relationships (#28)
66c8bda feat(input): readline keymap — Esc-clear + Ctrl-A/E/W/K/U (#29)
```
**Open Gitea issues: none.** `tea issues list --state open` is empty.
## §2. Issues closed this session (all committed, tested, `/runda`-reviewed)
Each closed on `git.lazyeval.net/oli/rdbms-playground` with a summary
comment.
### 2.1 — #29 (`66c8bda`) — input-field readline keymap (ADR-0049)
Implements the deferred **I1b** readline shortcuts: `Esc` clears a
partly-typed command (only when no completion memo is alive — the memo
wins first, ADR-0022); `Ctrl-A`/`Ctrl-E` = Home/End; `Ctrl-W` deletes
the previous word (readline-style, UTF-8 safe); `Ctrl-K`/`Ctrl-U` kill to
end/start. Cursor-only keys leave history nav intact; buffer-mutating
keys end it. **DA caught** the need for the `Ctrl-O`+`Esc` (sidebar
nav-exit) interaction not to clear the draft — locked with a regression
test. `requirements.md` I1b → `[x]`.
### 2.2 — #28 (`8ac3537`) — incidental-DDL confirmations: structure-only (ADR-0050)
Incidental-DDL confirmation echoes (`create table`, `add`/`drop`/
`rename`/`change column`, `add`/`drop index`) now render **structure
only** — no `References:` / `Referenced by:` block. Relationship-subject
surfaces (`show table`, `add`/`drop relationship`) keep their ADR-0044
diagrams. The prose renderer (`relationship_prose_lines` + `cols_disp`)
was deleted. **Supersedes** ADR-0044 §1's incidental-DDL prose clause and
the relationship-block half of ADR-0016 §5 (both annotated).
### 2.3 — #27 (`eceedc1`) — context- and state-aware keybinding strip (ADR-0051)
The bottom status line is now keystrokes-only and **state-selected** by
priority (sidebar focus / completion-memo / history-nav / editing /
default). The editing state surfaces the #29 keys (closing ADR-0049's
deferred advertisement). Mode-switch advertisements left the strip; the
empty-input hint gained a simple-mode `` `mode advanced` for SQL `` pointer
(advanced mode shows none — user decision). New `App::is_browsing_history()`
exposes the private `history_cursor`. 15 full-panel snapshots re-accepted.
### 2.4 — #30 (`4aeea55`) — mode-tagged history + top-of-chain journaling (ADR-0052) **← read before touching journaling**
Closed both the feature (advanced history reusable in simple mode) and
the bug (the `:` one-shot prefix lost across sessions). Two halves:
1. **Mode-tagged history.** The `history.log` status token gains an
optional `:adv` suffix (`ok` / `ok:adv` / `err` / `err:adv`); `source`
stays last + canonical so replay is unaffected. The in-memory ring
(still `Vec<String>`) stores advanced entries in their `: `-prefixed
simple-mode runnable form; recall **strips the `:` in advanced mode**
and keeps it in simple; hydration reconstructs the prefix from the tag.
App commands journal simple and are excluded from the ring's advanced
flag, so they recall bare.
2. **Journaling relocation (the architecture change).** Success
journaling **moved out of the worker** to the dispatch layer
(`spawn_dsl_dispatch` / `run_replay` / the app-command sites), next to
the already-top-level failure journaling — so the submission mode is in
scope with no worker plumbing. `finalize_persistence` now writes only
the **state** sources (yaml/csv); the journal write is **best-effort**
(the command is already committed — consistent with the failure path).
**Amends ADR-0015 §6** (history.log out of the worker tx; commit-db-last
scopes yaml/csv/db only), **ADR-0034** (status tag + journaling
location), **ADR-0040** (journal-write best-effort, not fatal).
**Two DA findings, both resolved:** (a) the app-command `advanced` flag
must exclude app commands (else `: save as` diverges); (b) the spawn
journals on `outcome.is_ok()`, so journaling is now **uniform** — read
commands that didn't journal before (`show tables`/`show relationships`/
`show indexes`, `show relationship <name>`, `explain`) now do, matching
ADR-0034 §1. **User-confirmed** as the more-correct behaviour (harmless
on replay — reads/`explain` don't mutate).
**Test migration:** 15 worker-level journaling tests were retired (the
worker no longer journals — their yaml/csv/operation assertions were
kept) and re-covered at the new layer: `history.rs` status-tag +
`:`-reconstruct; `app.rs` recall matrix; the cross-session regression
`advanced_command_journalled_then_hydrated_recalls_with_colon_in_simple`
in `iteration6_resume_history`; the replay tests cover `run_replay`
journaling.
Plan: `docs/plans/20260613-issue-30-top-of-chain-journaling.md`.
## §3. Next session — start here
The user's stated plan for the next session, in order:
1. **Pick up the ADR-0052 follow-up** (below).
2. **Check for any newly-filed open issues** (`tea issues list --state
open`) — none at handoff, but check fresh.
3. **Then** take on remaining open tasks from the general requirements
(`docs/requirements.md`) — see §5.
### The ADR-0052 follow-up — unwind the vestigial worker `source` plumbing
When journaling moved out of the worker, the `source` that the worker
threaded purely for journaling became dead. To avoid orphaning the param
across ~28 handlers, the refactor **left it in place** as vestigial:
- `finalize_persistence(conn, persistence, _source, changes)` — the
`_source` param is now unused (kept so its ~28 callers still pass
`source`, which they otherwise also use for `snapshot_then`).
- `do_rebuild_from_text(conn, _persistence, _source, project_path)`
both `_persistence` and `_source` vestigial.
- Three thin read-only wrappers in `db.rs`
`do_describe_table_request`, `do_query_data_request`,
`do_run_select_request` — now just delegate to their non-`_request`
twin (`do_describe_table` / `do_query_data` / `do_run_select`) with
vestigial `_persistence` / `_source` params and one caller each
(`db.rs` Request arms ~2409 / ~2749 / ~2759).
**The cleanup:** remove `_source` from `finalize_persistence` + drop the
arg at its ~28 callers (the callers keep `source` for `snapshot_then`, so
only the `finalize_persistence(...)` call loses the arg); remove the
`_persistence`/`_source` params from `do_rebuild_from_text`; and inline
the three `*_request` wrappers at their single call sites (replace
`do_describe_table_request(conn, persistence, source, name)` with
`do_describe_table(conn, &name)`, etc.), deleting the wrappers. Purely
mechanical, compiler-guided, no behaviour change. Establish the green
baseline first (`cargo test`), then verify nothing moved.
## §4. Carried-over follow-up (website branch, not `main`)
- **Website `seed` cast re-record** (from #34, handoff-68 §4) — still
tracked on the `website` branch, not here. Likely redundant (full
re-record sweep before publication).
## §5. Remaining roadmap — `docs/requirements.md` (next session's §3-step 3)
With the issue tracker empty, the next work comes from the document-based
requirements. Open / partial items worth weighing (the user picks):
- **H2 `hint`** — the last A1 gap (contextual help for the current
command); its own ADR. (`requirements.md` H2.)
- **TT5 CI** — runs all tiers on Linux/macOS/Windows; no CI workflow yet
(a `ci` branch reportedly exists — check its state first). Couples with
**D1D3** (cross-platform prebuilt binaries + Homebrew/Scoop).
- **TT4 PTY (Tier-4)** — ADR-0008 specifies the PTY harness + four
critical flows; still not wired (no PTY deps/tests).
- **I1 multi-line input** (Ctrl-Enter submits, Enter inserts newline) and
**I5 / B3 in-flight cancellation** (Ctrl-C cancels a running command).
- **V4 session journal** — scrollable per-session log + Markdown export
(the bigger UX project; own ADR).
- **TU1 tutorial / lesson system** — design + ADR pending (acknowledged
in scope).
- Smaller partials: **C3a** modify relationship (drop+add covers it
today), **C4** m:n convenience, **V3** ER-diagram export, the **NFR-***
performance/visual targets (mostly unmeasured), **N4** global rolling
history (OOS for v1).
No strong ordering — these are the user's call. Several need a new ADR
(H2, V4, TU1); CI/release (TT5/D1D3) is the most "shippable-product"
track if that's the priority.
## §6. How to take over
1. Read handoffs 67 → 68 → 69, `CLAUDE.md`, `docs/requirements.md`.
2. Confirm green baseline: `cargo test` (expect **2471 pass / 1 ignored**)
+ `cargo clippy --all-targets` (clean).
3. `tea issues list --state open` — pick up anything new first.
4. Then the ADR-0052 follow-up (§3), then requirements (§5).
5. Follow the project workflow: phased (requirements → divergent → eval →
execute → verify), test-first, `/runda` + DA pass before every commit,
ADR amendment for any decided-area change + the README index-upkeep
rule, and confirm the commit message with the user before committing.
6. Consider a `cargo sweep` at this milestone (`target/` grows across
sessions; see CLAUDE.md "Build hygiene"). (`sweep.timestamp` was
removed this session.)
+165
View File
@@ -0,0 +1,165 @@
# Session handoff — 2026-06-15 (70)
Seventieth handover. Continues from handoff-69 (which closed the last
four Gitea issues and left the tracker empty). This session did the
**ADR-0052 follow-up** (unwinding vestigial worker `source` plumbing),
then **designed and fully implemented H2 — the contextual `hint`
command + F1 keybinding (ADR-0053)** end to end (Phases AD). The CI
branch was also merged into `main` mid-session (not my work — see §5).
Net: **2 feature areas shipped, 1 new ADR (0053) + 1 ADR amendment
(0052), 4 new Gitea issues (#35#38), the `hint` corpus (~57 teaching
blocks), and A1 + H2 closed in `requirements.md`.**
## §1. State at handoff
**Branch:** `main`. Working tree **clean**; all work committed. Commits
are local (push is the user's step).
**Tests: 2499 passing / 0 failing / 0 skipped / 1 ignored** (the
long-standing `friendly` doctest). **Clippy clean** (nursery, all
targets). Breakdown: 1799 lib + 500 `it` + 200 typing-surface-matrix.
**Open Gitea issues (4, all enhancement, all filed this session):**
- **#35** — enforce `cargo fmt` across the codebase (single reformat +
CI gate). The tree is *not* fmt-clean (~1800 pre-existing diffs); do it
once, coordinated with CI, before first publication.
- **#36** — `help` collapses advanced-SQL forms onto their simple sibling
(a `help`-list dedup artifact); they deserve distinct help content.
- **#37** — `hint` clause-concept hints (`on delete` actions, constraint
slots, `with pk`, cardinality) — a deferred `hint.concept.<topic>`
layer.
- **#38** — `hint` pre-submit-diagnostic route + the ~33 `diagnostic.*`
tier-3 blocks (deferred; `Diagnostic` carries no class key).
## §2. ADR-0052 follow-up — vestigial worker `source` unwind (`e8fa859`)
The first task from handoff-69 §3. ADR-0052 moved success-journaling out
of the worker, leaving the `source` that handlers threaded purely for the
old `history.log` write dead. **Bigger than the handoff estimated** (it
framed it as ~28 call-site edits): the cascade ran through ~30 worker
handlers + the `DescribeTable`/`QueryData`/`RunSelect` request fields +
their `DatabaseHandle` methods (~164 mostly-test call sites). Fully
unwound, compiler-guided, **no behaviour change** (journaling uses a
`source_for_journal` clone at the spawn, independent of the worker). The
only worker `source` left is the snapshot/undo label. Amended ADR-0052
*Consequences* + README. (Two scope forks escalated + user-approved.)
## §3. H2 — contextual `hint` (ADR-0053), Phases AD — **shipped**
The bulk of the session. ADR-0053 settles the `hint` slot ADR-0003 left
"ADR pending"; **closes A1** (all 15 app commands now exist) and
**requirements H2**. Read ADR-0053 before touching this area — it went
through three revisions and several user decisions.
### The design (all user-chosen)
- **Two surfaces:** an **F1 keybinding** → tier-3 hint for the *live*
partial input (read-only overlay — never touches buffer/cursor/memo);
a submitted **`hint` command** → expands on the *most recent runtime
error*. No topic arg (contextual only; `help <topic>` owns reference).
- **Tier-3 teaching layer** beneath the existing tier-1 (colour / error
headline) and tier-2 (ambient one-liner; the error `hint:` shown **by
default** since `Verbosity::Verbose` is the default). Each block is
`what` / `example` / `concept`, rendered as a `Hint` heading + aligned
labels.
- **Per-form keying** (Phase-B revision — the original per-node `hint_id`
was too coarse for multi-form commands like `add`/`drop`/`show`): a new
**`hint_ids: &[&str]`** field on `CommandNode` mirroring `usage_ids`,
resolved by `hint_key_for_input_in_mode` (reuses `usage_key`'s
form-word disambiguation + a mode-primary fallback for shared entry
words so advanced `insert``sql_insert`, simple → `insert`).
- **Comprehensive for v1 = command forms + 9 runtime error classes**
(the ~33 `diagnostic.*` classes were **deferred**, #38 — see §4).
### Key files
- `src/dsl/command.rs``AppCommand::Hint`.
- `src/dsl/grammar/app.rs``HINT` node + `build_hint`.
- `src/dsl/grammar/mod.rs` — the `hint_ids` field, `hint_key_for_input_in_mode`,
the factored `pick_form_key`, and the two **comprehensiveness coverage
tests** (every node has a resolving `hint.cmd.*`; every runtime error
class has a `hint.err.*`).
- `src/app.rs` — F1 arm in `handle_key` (read-only overlay, placed before
the completion-memo clear); `note_hint_for_input` / `note_hint_for_recent_error`
/ `note_getting_started` / `emit_tier3_block`; `last_error_hint_key`
state (set in `handle_dsl_failure`, cleared in `submit` for DSL
commands).
- `src/friendly/translate.rs``error_hint_class` (maps a `DbError` +
ctx to its `hint.err.<class>`; mirrors `translate`'s dispatch — keep in
sync, unit-tested).
- `src/friendly/strings/en-US.yaml` + `keys.rs` — the corpus under
`hint.cmd.<form>` / `hint.err.<class>` + `hint.block.*` labels +
`shortcut.hint`.
- `src/ui.rs` — ADR-0051 strip advertises **F1** (editing + default
states); 12 full-panel snapshots re-accepted.
### Phases (one commit each unless noted)
- **A** (`050b363`) skeleton + tier-2 fallback; **B** (`4a5fd1b`) per-form
keying + 3 exemplars; **C** content in 5 batches (`4bdfce6` app,
`6429b56` DDL, `9c4d520` DML, `97970f2` advanced-SQL, `b6b98ad` runtime
errors) + `417cbc8` diagnostic deferral; **D** (`447112b`) coverage gate
+ F1 strip + status flips; **/runda fix** (`329adfc`) — see §3.1.
### 3.1 — what the final `/runda` caught (don't skip)
Per-batch substring tests masked a **presentation gap**: `emit_tier3_block`
was emitting three *bare, unlabelled* lines, deviating from the approved
exemplar format. Fixed to render a `Hint` heading + aligned `What:` /
`Example:` / `Concept:` lines, **locked by an `insta` snapshot**
(`hint_block_insert`). Also confirmed the `Next:` line (ADR D2 exemplar)
is correctly **omitted** — tier-2 ambient already owns live
position-awareness. Lesson for the next content/UI work: **add a rendered
snapshot early**; substring asserts don't see layout.
## §4. Deferrals (all tracked, all user-confirmed)
- **#38 diagnostic route + `diagnostic.*` blocks** — `Diagnostic`
(`walker/outcome.rs`) carries only its rendered `message`, not a class
key, so the F1 diagnostic route would need a `class` field threaded
through every diagnostic site (broad) for marginal value (tier-2
already surfaces diagnostics; many duplicate runtime classes). F1 still
shows the useful command block when a diagnostic is present.
- **#37 clause-concept hints** — per-form is the right tier-3 granularity;
clause-level concepts are a separate `hint.concept.<topic>` layer for
later.
- **#36 `help` advanced-SQL** — out of H2's scope (touches shipped `help`).
## §5. CI branch merged into `main` (not my work)
Mid-session the **`ci` branch was merged** (commits `47a0816`, `138e766`
+ the `ci:`/`build:`/`docs(ci):` commits). `main` now carries a CI
pipeline, a nix flake, and **D1 cross-platform release builds** (matrix +
macOS), documented under a **new `docs/ci/adr/` namespace** (ci-001..003).
Implications for the roadmap: **D1 (cross-platform binaries) is now
substantially underway** — re-assess D1/D2/D3 status against what landed
before treating them as open. My H2 work is layered cleanly on top (all
green post-merge).
## §6. Next session — start here
1. **Push** (user step) — 30-odd local commits incl. the CI merge + all
of H2.
2. **Re-baseline the roadmap** against the merged CI work: D1/D2/D3 and
**TT5 CI** are partly/largely done now — read `docs/ci/adr/` and the
workflows before assuming they're open (handoff-69 §5 predates this).
3. **#35 (cargo fmt gate)** is the natural pairing with the now-merged CI
— the user wanted it done once, before first publication.
4. Other `requirements.md` open items (verify against CI merge first):
**TT4** PTY tier-4 (still unwired), **I1** multi-line input, **I5/B3**
in-flight cancellation, **V4** session journal (own ADR), **TU1**
tutorial system (own ADR). H2/A1 are now **done**.
5. The H2 deferrals (#36/#37/#38) are available if the user wants to
round out the hint/help surface.
## §7. How to take over
1. Read handoffs 68 → 69 → 70, `CLAUDE.md`, `docs/requirements.md`.
2. Confirm green: `cargo test` (expect **2499 pass / 1 ignored**) +
`cargo clippy --all-targets` (clean).
3. Read `docs/ci/adr/` (the merged CI work) before touching CI/release/D*.
4. For anything in the `hint` area, read **ADR-0053** first (3 revisions
+ deferrals #37/#38). For journaling, ADR-0052 (+ its 2026-06-14
follow-up note).
5. Project workflow unchanged: phased, test-first, `/runda` + DA before
commits, ADR amendment + README index-upkeep for decided-area changes,
confirm commit messages with the user.
6. Consider a `cargo sweep` at this milestone (`target/` grows; see
CLAUDE.md "Build hygiene").
@@ -0,0 +1,247 @@
# Plan — issue #30: mode-tagged history + top-of-chain journaling
**Status:** draft for `/runda` review (2026-06-13).
**Issue:** #30 — advanced history reusable in simple mode (prepend `:`),
and the bug: the `:` one-shot prefix is lost across sessions.
**ADR:** ADR-0052 (new); amends ADR-0015 §6, ADR-0034, ADR-0040;
references ADR-0003.
## 1. Goal & root cause
Two coupled needs, one root cause — **history entries carry no mode**:
- **Bug:** the in-memory ring stores the raw `:select 1`, but the worker
journals the *stripped* `select 1`, so cross-session the `:` is lost
and the command recalls bare (unusable in simple mode).
- **Feature:** persistent-advanced commands (`select 1` typed in advanced
mode) can't be told apart from simple DSL, so they can't be offered
back with a `:` in simple mode.
Fix: **record the submission mode per entry** (status tag `:adv`), keep
the on-disk `source` canonical, and have **recall prepend/strip `:`** for
the current mode.
## 2. The architecture insight (why this plan is shaped this way)
Journaling **success** lives deep in the worker: `finalize_persistence`
(db.rs:3096-3099) writes `history.log` *inside the db transaction, before
`tx.commit()`*, alongside yaml/csv — plus four no-op-skip sites and three
read-only helpers. **Failure** journaling already lives at the top
(runtime.rs:484-495, best-effort). Threading the mode *down* to the
worker would mean ~30 `Request` variants + `Database` methods +
`execute_command_typed` arms — because the journal write is far from
where the mode is known.
So instead: **move success journaling up to the dispatch layer**, next to
where failure journaling already is and where mode + outcome + source are
all in scope. The mode then needs no plumbing. This is the correct
separation anyway — `history.log` is an append-only *journal of what was
typed*, not *state*; the state sources (yaml/csv/db) stay atomic in the
worker.
### Semantic changes this entails (must be vetted)
1. **history.log leaves the worker transaction** (amends ADR-0015 §6).
`commit-db-last` still governs yaml/csv/db (the state); the journal is
written *after* the worker replies (i.e. after `tx.commit`), at the
dispatch layer.
2. **Success-journal write failure: fatal → best-effort** (amends
ADR-0040). Today a failed `history.log` write on a *successful*
command rolls the command back and shows a fatal banner. After: the
command stays committed; the journal write is best-effort (logged +
ignored), exactly like the failure path already is. The two journal
paths become *consistent*.
3. **Consequence:** on a rare journal-write failure (disk full /
permissions) a successful command is applied but may be missing from
`history.log` — not recallable next session, not replayable. The state
(yaml/csv/db) is unaffected and consistent. This is a graceful
degradation, not corruption, and is logged. (Today the same disk-full
instead kills the app mid-command.)
**Open question for review/user:** is trading "fatal on journal-write
failure" for "best-effort, command still succeeds" acceptable? The plan
assumes **yes** (a journal is auxiliary; killing the app over it is worse
UX). If not, journaling must stay coupled in the worker and we pay the
~30-site mode plumbing instead.
## 3. On-disk format (mode tag in status — already chosen + partly built)
Record stays `<ts>|<status>|<source>`; the **status token** gains an
optional `:adv` suffix (ADR-0052). `source` stays canonical so replay is
unaffected.
| Submission | Success | Failure |
|---|---|---|
| Simple / app command | `ok` | `err` |
| Advanced (SQL, persistent or one-shot) | `ok:adv` | `err:adv` |
**Done already** (history.rs / mod.rs):
- `status_token(base, advanced)`, `parse_status(status) -> (is_ok, advanced)`.
- `parse_record_source` reconstructs `": {cmd}"` for `:adv` records.
- `parse_journal_record.status_is_ok` via `parse_status` (so `ok:adv` replays).
- `append_history(text, advanced)`, `append_history_failure(text, advanced)`.
Back-compat: old `ok`/`err` logs → simple; nothing migrates.
## 4. In-memory ring & recall (app.rs) — the #30 behaviour
The ring stays `Vec<String>`. An **advanced** entry is stored in its
`: `-prefixed simple-mode runnable form (matching the existing in-session
one-shot ring); a **simple** entry bare. A leading `:` unambiguously
marks advanced (simple DSL can never start with `:`).
- **`submit`** (app.rs:1704): compute `effective_input` + `submission_mode`,
parse once for the app-command check (already done at 1751), then build
the ring line. The **`advanced` flag excludes app commands** —
`advanced = submission_mode.is_advanced() && !is_app_command` — because
app commands (`undo`, `mode …`, `save as`, …) run in *any* mode and must
**not** get a `:` on recall. Ring line: `": " + effective_input` if
`advanced`, else `effective_input`; `push_history(&ring_line)`. (Today it
pushes the raw `trimmed` *before* stripping; the reorder also drops a
bare `:`, which executed nothing, and is what lets the app-command check
precede the push.) `ExecuteDsl.source` stays the **canonical**
`effective_input`.
- *Why the app-command exclusion matters (DA finding):* without it,
`: save as foo` (an app command via the one-shot) would store `: save
as foo` in the ring but journal `save as foo` (app commands journal
simple at their own sites, §5) — the very in-session-vs-cross-session
divergence #30 is fixing, re-introduced for app commands. Excluding
them keeps ring and disk agreeing (both bare).
- **`history_back` / `history_forward`**: after cloning the stored entry
into `self.input`, strip a leading `:` **iff `self.mode == Advanced`**
(so an advanced entry runs as bare SQL in advanced mode, and as `: …`
one-shot in simple mode). A small helper `recall_display(stored)`.
- `seed_history` / `ProjectSwitched` payload: **unchanged** (`Vec<String>`);
hydration already returns the `: `-prefixed form (§3).
Recall matrix:
| entry \ current mode | Simple | Advanced |
|---|---|---|
| advanced (`: select 1`) | `: select 1` (one-shot) | `select 1` (SQL) |
| simple (`create …`) | `create …` | `create …` |
## 5. Move success journaling worker → dispatch layer
**Remove** (worker stops journaling success):
- `finalize_persistence` history write (db.rs:3096-3099). Keep yaml/csv.
The now-unused `source` param: remove it + drop the arg at its ~30
callers (mechanical, compiler-guided). (Handlers keep their own
`source` for `snapshot_then`.)
- The 4 no-op-skip `append_history` (db.rs:2267, 2311, 2524, 2560) — these
outcomes (`SchemaSkipped` etc.) are `Ok` at the dispatch layer, so the
new top-level journal covers them.
- The 3 read-only helper `append_history` (db.rs:8372 show table, 9996
show data, 10014 select) — `Ok(Query)`/`Ok(ShowList)` at the top.
**Add** (dispatch-layer journaling, all best-effort + logged):
- **`spawn_dsl_dispatch`** (runtime.rs ~1433): pass `project_path` in;
after `execute_command_typed`, `if outcome.is_ok() {
Persistence::new(path).append_history(&source_for_journal,
submission_mode.is_advanced()) }`. (Failures stay in the existing path,
§6 — no double-journal, since Ok and Err are exclusive.)
- **`run_replay`** (runtime.rs ~2540): after each line's
`execute_command_typed`, `if outcome.is_ok() { append_history(
&command_text, false) }` — replay is mode-agnostic, journalled
**simple**. (Preserves ADR-0034 §3 "replayed sub-commands land in
history"; a replayed advanced command re-journals without `:adv` — a
documented OOS, not a regression: today it re-journals as plain `ok`.)
- **`spawn_rebuild`** (runtime.rs ~503): after a successful rebuild,
`append_history("rebuild"/source, false)`. (Rebuild journalled via
`finalize_persistence` today; that write is gone, so add it here.)
**Unchanged** (already at the dispatch layer, app commands):
- `perform_switch` (974: save-as/load/new) and `spawn_export` (1043) —
already best-effort `append_history(&source)`; add the new `advanced`
arg as `false` (app commands run in any mode → no `:` needed on recall;
this also fixes the would-be "redundant `: undo`" — app commands
journal **simple** because they're dispatched here, never via
`ExecuteDsl`/the spawn).
- `undo`/`redo`/`copy`/`help`/`quit`: not journalled today; unchanged.
- The **`replay` command itself**: dispatched as `Action::Replay`, never
reaches the spawn → not journalled (preserves the ADR-0034 §3 exclusion
without extra work); nested `replay` skip in `run_replay` unchanged.
### DA-confirmed design choice: split, don't unify
Success journals in the spawn (`Ok` arm); **all** failures stay in the
existing App→`JournalFailure`→runtime path (just gaining the mode).
Considered and rejected: moving worker-rejection failures into the spawn
too (to "unify"). It doesn't actually unify — parse failures never reach
the spawn, so they'd stay in the App path regardless — and it adds a
double-journal hazard (must also strip the App's `DslFailed`
`JournalFailure` emission). The split keeps the failure path **untouched
in structure** (lowest risk); `Ok`/`Err` are exclusive so there is no
double-journal. **Verified safe:** undo/redo never touches `history.log`
(the snapshot copies db+yaml+csv only, undo.rs:15-16), and `snapshot_then`'s
redo-clear keys on `source.is_some()`, independent of journaling — so
removing the worker journal write does not perturb undo/snapshot at all.
## 6. Failure journaling — add the mode (location unchanged)
Keep both failure origins where they are (best-effort, dispatch/App
layer); thread the mode so they tag `err:adv`:
- **`Action::JournalFailure`** (action.rs:42): add `advanced: bool` (or
`submission_mode`).
- **`AppEvent::DslFailed`** (event.rs): add `submission_mode` (the
worker-rejection path — the App can't recover the mode from an async
reply otherwise).
- **App**: the parse-failure path (`dispatch_dsl` Err arm) has
`submission_mode` directly; the `DslFailed` handler reads it off the
event. Both emit `JournalFailure { source, advanced }`.
- **runtime.rs:492**: `append_history_failure(&source, advanced)`.
## 7. Tests
- **history.rs (Tier-1):** `status_token`/`parse_status` round-trip;
`read_recent_sources` reconstructs `": …"` for `:adv` and leaves
`ok`/`err` bare; `status_is_ok` true for `ok` & `ok:adv`; old-log
back-compat.
- **app.rs (Tier-1):** advanced submission stored `: `-prefixed; recall
prepends in simple / strips in advanced; simple bare in both; bare `:`
not stored; a parse-failure is still recallable; dedup/cap hold.
- **iteration6_resume_history (Tier-3) — headline regression:** journal
an advanced command (`append_history(text, true)`), hydrate, recall in
simple → `: …`; and the full bug repro through `submit` + journal +
hydrate if feasible.
- **replay_command (Tier-3):** replayed commands still land in
history.log (now via `run_replay`'s call); the `replay`-self-exclusion
+ nested-skip still hold; advanced lines replay (status `ok:adv`
treated as ok).
- **Journaling relocation:** a success no longer fatals on a journal
write failure (best-effort) — if cheaply testable; at minimum a worker
test that previously asserted worker-side journaling is updated/removed.
- **Update mechanical call sites:** `append_history(_, advanced)` /
`append_history_failure(_, advanced)` at the db.rs inline tests
(8372/9996/10014/11324 — likely now removed with the production sites),
iteration6 (144-170), mod.rs (600).
## 8. ADR work
- **ADR-0052 (new):** the #30 feature + bug, the status-tag format, the
`: `-prefixed ring + recall, AND the journaling relocation (it's the
enabling refactor). Forks: status-tag format; unified scope;
dispatch-layer journaling (best-effort).
- **ADR-0015 §6 amendment:** history.log out of the worker transaction;
commit-db-last now scopes yaml/csv/db; journal is a dispatch-layer
best-effort side-record.
- **ADR-0034 amendment:** journaling location (dispatch layer);
status-field `:adv` extension (it already reserved the field).
- **ADR-0040 amendment:** a success-path journal-write failure is no
longer fatal — best-effort, consistent with the failure path.
- README index upkeep for every ADR touched.
## 9. Risks / watch-list
- **Double-journaling**: ensure Ok→spawn and Err→App-path stay exclusive;
do NOT also leave a worker journal.
- **Under/over-journaling vs today**: top-level "journal on every Ok"
must match today's "journal every command with a source" — verified:
reads + skips are Ok outcomes, internal ops never reach the spawn.
- **finalize_persistence source-param removal**: 30 mechanical call-site
edits; compiler-guided.
- **Replay re-journal mode fidelity**: replayed advanced commands
re-journal as simple (OOS, not a regression).
- **best-effort journal**: rare write-failure leaves a command unjournaled
(logged). User decision (§2 open question).
- **app-command mode**: journalled simple by construction (dispatched
outside the spawn) — this is correct (they run in any mode), and
resolves the earlier "redundant `: undo`" worry.
@@ -0,0 +1,243 @@
# Plan — ADR-0053: contextual `hint` command + F1 keybinding (H2)
Implements ADR-0053. Closes the last open piece of **A1** (the canonical
app-command set) and requirements **H2**. No Gitea issue — this is
requirements-driven work; any genuine "later" item found en route gets
its own issue (cf. #36, already filed for the parallel `help`-side gap).
## 1. Goal
Give learners on-demand, **teaching-grade** contextual help — a *third*
tier beneath the existing terse always-on text (tier 1) and the
short contextual lines that are already shown (tier 2: the live ambient
prose, and the error `hint:` which is on by default since
`Verbosity::Verbose` is the default). Two surfaces:
- **F1** (read-only overlay) → a tier-3 block for the **live partial
input**, or — on empty input — for the **most recent runtime error**.
- **`hint`** (submitted app command) → the tier-3 block for the **most
recent runtime error** (the buffer is empty post-submit, so it can only
act on recent context).
The mechanism is small; the **content corpus is the feature** (~80
blocks, comprehensive for v1, authored exemplars-first per ADR-0053 D7).
## 2. The shape of the work (why this order)
The mechanism and the content are separable, and the mechanism should
land first with **graceful tier-2 fallback** so every surface works
before any tier-3 text exists. That lets us:
- build + test the trigger matrix / routing / `:`-strip / read-only-
overlay behaviour against a skeleton (TDD), then
- pour in content in reviewable batches without re-touching the wiring,
- and turn on the **comprehensiveness coverage test** only once the
corpus is complete (it is red until then — by design).
Build order: **Phase A** (mechanism skeleton, falls back to tier-2) →
**Phase B** (catalogue structure + the three approved exemplars) →
**Phase C** (comprehensive content, batched) → **Phase D** (polish:
strip advertisement, snapshots, full green).
## 3. Grammar: the `hint_ids` field + the `HINT` node
### 3a. New `CommandNode.hint_ids` (per-form — revised in Phase B)
- Add `pub hint_ids: &'static [&'static str]` to `CommandNode`
(`src/dsl/grammar/mod.rs:512`, beside `help_id` / `usage_ids`),
**mirroring `usage_ids`***not* a per-node `Option<&str>`. The Phase-B
exemplar (`add 1:n relationship`) showed per-*node* keying is too coarse:
`add`/`drop`/`show`/`create` are each one node spanning many forms, and
a live-input hint must be specific to the typed form. Compiler forces
every node literal (~37, across `grammar/app.rs`, `data.rs`, `ddl.rs`) to
set it — Phase A/B leave most `&[]` (tier-2 fallback); Phase C fills them.
**Multi-form nodes list ALL their form keys** (e.g. `add`
`["add_column", "add_relationship", "add_index", "add_constraint"]`) so
the form-word disambiguation resolves correctly and unauthored forms fall
back at render rather than mis-resolving to a sibling.
- **Lookup:** `hint_key_for_input_in_mode(source, mode)` returns the single
typed form's hint stem, reusing `pick_form_key` (factored out of
`usage_key_for_input_in_mode` — shared digit/`m:n`/suffix disambiguation).
- **Why a new field, not `help_id`** (ADR-0053 D3): `help_id` is `None` on
the 7 advanced-SQL forms purely to dedup the `help` *list*; those forms
have distinct SQL syntax and need their own block. `hint_ids` is per
form. (The parallel `help`-side gap is issue #36; clause-concept hints
are deferred — issue #37.)
### 3b. `AppCommand::Hint` + the `HINT` node
- `AppCommand::Hint` variant (no fields — no topic arg) in
`src/dsl/command.rs:544`.
- `pub static HINT: CommandNode` in `grammar/app.rs` mirroring `HELP` but
with **no topic shape** (bare keyword, like `UNDO`): `entry:
Word::keyword("hint")`, `shape: EMPTY_SEQ` (as `UNDO`,
`grammar/app.rs:333`), `ast_builder:
build_hint` (returns `Command::App(AppCommand::Hint)`), `help_id:
Some("app.hint")`, `hint_id: Some("app.hint")`, `usage_ids:
&["parse.usage.hint"]`.
- Register `(&app::HINT, CommandCategory::Simple)` in `REGISTRY`
(`grammar/mod.rs`), beside `HELP`. (App commands are available in both
modes via the existing mechanism.)
## 4. Command identification (live-input → node)
The F1 live-input path needs "which command form is being typed." **The
lookup machinery already exists** — do not rebuild entry matching:
- `command_for_entry_word(word) -> Option<(usize, &'static CommandNode)>`
(`grammar/mod.rs:811`) returns the matched node for an entry word
(Simple-first; the caller extracts the first word of the input).
- `usage_keys_for_input_in_mode(source, mode)` (`grammar/mod.rs:564`)
already performs the **mode-aware** Simple/Advanced selection the hint
path needs (advanced `create` → the SQL nodes, simple → the DSL node) —
it just returns `usage_ids` rather than the node.
- **The only new bit:** a thin `hint_id_for_input_in_mode(source, mode)`
(or a node-returning sibling of `usage_keys_for_input_in_mode`) that
applies the same mode selection and returns the chosen node's
`hint_id`. Mirror the existing function; don't duplicate its matching.
- **`:`-strip:** in Simple mode, strip a leading `:` (one-shot escape,
ADR-0003) before identification so `: SELECT …` resolves to the
advanced `SELECT` node.
- No match (empty / unrecognised entry word) → the "getting started"
pointer (D2).
## 5. F1 keybinding (read-only overlay)
In `App::handle_key` (`src/app.rs:1155`):
- Add an F1 arm (`KeyCode::F(1)`) **after** the modal gate and the
sidebar-nav gate (inert there, per D2), and **before** the
"any other key clears the completion memo" fall-through (`_ =>
self.last_completion = None`, ~line 1228) — F1 must **not** clear the
memo or touch the buffer/cursor (D1).
- Behaviour (the trigger matrix, D2):
- non-empty input → `note_hint_for_input()` (the command's `hint.cmd`
block + the live "Next:" expected-set from the walker).
- empty input + `last_error_hint_key` set → `note_hint_for_error()`.
- empty input + no recent error → `note_getting_started()`.
- Returns `Vec::new()` (pure output emission, like `help`).
- `demo_badge_label` (`app.rs:520`) gains an `F1 → "[F1]"` entry so demo
mode surfaces it (ADR-0047).
## 6. The two error routes (D2 / D5)
- **Runtime errors:** add `last_error_hint_key: Option<String>` to `App`.
Set it where friendly errors are rendered (`runtime.rs:2615`,
`app.rs:2424`) from the error's class key; clear on the next successful
command. The `hint` command and empty-input F1 read it.
- **Pre-submit diagnostics:** the F1 live-input path, when the input
carries an under-cursor diagnostic, reads it straight from the walker
(`input_diagnostics_in_mode`, the same source the ambient panel uses)
and renders that diagnostic's `hint.err.<class>` block instead of (or
alongside) the command block. No stored state.
- Both render from `hint.err.*`.
## 7. Rendering: the `note_hint*` family (D4)
- New `App::note_hint_for_input`, `note_hint_for_error`,
`note_getting_started` (siblings of `note_help`/`note_help_topic`,
`app.rs:2982`/`3021`).
- A tier-3 block is **structured** (`what` / `example` / `concept`, plus
the live `Next:` line on the input path). The catalogue stores each part
under sub-keys (`hint.cmd.<id>.what`, `.example`, `.concept`); the
renderer fetches each via `t!` and lays them out as a small framed
block.
- Styling: `OutputKind::System`; `OutputStyleClass::Hint` (muted) on
`what`/`concept`/`Next`, `Neutral` on `example` so the runnable line
stands out. Reuse `OutputLine::styled` + `push_category_three_prose`
patterns (`app.rs:3121`).
- **Fallback:** if a node's `hint_id` is `None` or a key is missing,
degrade to tier-2 (ambient prose for the input path; the verbose error
`hint:` for the error path) — never blank.
## 8. Catalogue + `keys.rs`
- New sub-namespaces under the existing top-level `hint:` in
`src/friendly/strings/en-US.yaml`: `hint.cmd.<hint_id>.{what,example,
concept}` and `hint.err.<class>.{what,example,concept}`.
- Register every key + its placeholders in `src/friendly/keys.rs`
(`KEYS_AND_PLACEHOLDERS`) so the build-time validation covers them.
- `parse.usage.hint` + `help.app.hint` strings for the command itself.
## 9. Content (Phase C — the bulk, batched per D7)
Exemplars approved in the ADR (`insert` live-input, FK child-side error,
`add relationship`) are the template. Author in reviewable batches:
1. **App commands** (~16): save/save as/load/new/rebuild/export/import/
replay/undo/redo/mode/messages/copy/help/hint/quit.
2. **DDL** (simple): create table, create m:n, add column/relationship/
index, drop, rename, change column.
3. **DML** (simple): insert, update, delete, show, seed, explain,
select/with.
4. **Advanced-mode SQL forms** (7): SQL CREATE TABLE, ALTER TABLE,
CREATE/DROP INDEX, DROP TABLE, SQL INSERT/UPDATE/DELETE, EXPLAIN SQL —
**own blocks, SQL-syntax examples**.
5. **Runtime error classes** (9): unique, foreign_key ×{child,parent},
not_null, check, type_mismatch, not_found, already_exists, generic,
invalid_value.
6. **`diagnostic.*` classes** (~33): arity/type/unknown-table-column/etc.
Each block: `what` (12 sentences), `example` (one runnable line,
mode-correct), `concept` (the relational idea — the teaching part;
optional only where genuinely none, e.g. `quit`).
## 10. Tests
Written test-first against the Phase-A skeleton where possible.
- **Tier 1 (unit, `app.rs`):**
- trigger matrix: F1 non-empty → command block; F1 empty + recent error
→ error block; F1 empty + none → getting-started; `hint` command +
error → error block; `hint` + none → getting-started.
- `last_error_hint_key` set on a failing command, cleared on the next
success.
- routing: a pre-submit diagnostic on the input drives the diagnostic
`hint.err`; a runtime error drives the stored-key route.
- `:`-strip: `: SELECT …` in Simple mode resolves to the advanced node.
- **read-only overlay:** F1 leaves `input`, `input_cursor`, and
`last_completion` unchanged.
- tier-2 fallback when `hint_id`/key absent.
- **Tier 2 (`insta`):** snapshot a representative rendered tier-3 block
(the `insert` exemplar) so the framed layout + styling spans are locked.
- **Tier 3 (integration, `tests/it/`):** type a partial command → F1 →
block appears, buffer untouched; run a failing insert → `hint` → FK
error expansion.
- **Comprehensiveness coverage test** (enforces D6, the key one): iterate
`REGISTRY` and assert every node has a `hint_id` resolving to a
`hint.cmd.*` block; assert every runtime-error + `diagnostic.*` class
has a `hint.err.*` block. **Red until Phase C completes** — enable
(un-`ignore`) as the final gate.
- `keys.rs` validation continues to guarantee every *referenced* key
resolves.
## 11. Keybinding strip + discoverability (Phase D)
- The ADR-0051 bottom strip advertises **F1 = hint** in the editing/
typing state (and on the empty-input state, since F1 still does
something there). Re-accept the affected full-panel snapshots.
## 12. ADR / docs
- ADR-0053 is committed (`e16ad50`). On completion, flip its Status from
"implementation pending" to implemented (with date), and update the
README index entry + `requirements.md` **H2 → [x]** and **A1 → [x]**
(A1 closes when `hint` lands).
## 13. Risks / watch-list
- **Command-identification reuse.** The lookup exists
(`command_for_entry_word` + the mode-aware `usage_keys_for_input_in_mode`,
`grammar/mod.rs:811`/`564`); the only new code is a thin node/`hint_id`
variant that reuses their selection. Do **not** re-implement entry-word
matching — mirror the existing functions.
- **Structured-key ergonomics.** Three sub-keys per block × ~80 blocks is
~240 catalogue keys; keep the `keys.rs` registration generation tidy
(consider a helper that registers the `{what,example,concept}` triple
for an id).
- **Content voice drift across batches.** Re-check each batch against the
approved exemplars; the `concept` line is where drift (too terse / too
advanced) creeps in. Pedagogy wins ties.
- **F1 terminal capture.** A few terminals intercept F1; acceptable
(it's the convention) but note it if testing surfaces it.
- **Snapshot churn.** The strip change re-accepts ADR-0051 snapshots;
keep that diff isolated.
- **Coverage-test timing.** It is red through Phases AC; gate it so CI
isn't broken mid-stream (e.g. `#[ignore]` until the final batch), then
make passing it the completion criterion.
```
+47 -11
View File
@@ -61,11 +61,32 @@ since ADR-0027.)
## Distribution and install ## Distribution and install
- [ ] **D1** Cross-platform binaries: Linux, macOS, Windows on - [x] **D1** Cross-platform binaries: Linux, macOS, Windows on
x86_64 and aarch64. 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, - [ ] **D3** Released via prebuilt binaries plus Homebrew, Scoop,
`winget`, and `cargo binstall`. `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 ## TUI shell
@@ -250,16 +271,13 @@ since ADR-0027.)
## App-level commands (per ADR-0003) ## 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`, available in both modes: `save`, `save as`, `load`, `new`,
`rebuild`, `export`, `import`, `seed`, `replay`, `undo`, `rebuild`, `export`, `import`, `seed`, `replay`, `undo`,
`redo`, `mode`, `help`, `hint`, `quit`. `redo`, `mode`, `help`, `hint`, `quit`.
*(Partial: **14 of 15** implemented and available in both modes — *(Done 2026-06-15: the last command, **`hint`**, landed with H2
`quit`/`q`, `mode simple|advanced`, `help`, `save`, `save as`, (ADR-0053). All 15 canonical app commands are now registered and
`load`, `new`, `rebuild`, `export`, `import`, `replay`, `undo`, available in both modes.)*
`redo`, and now **`seed`** (ADR-0048 / SD1, done 2026-06-11).
**Only `hint`** (tracked as H2) remains unregistered. A1 closes
when H2 lands.)*
## DSL data commands ## DSL data commands
@@ -793,8 +811,16 @@ since ADR-0027.)
`returning `) still shows the raw expression first-set — `returning `) still shows the raw expression first-set —
typing-time completion already offers the right candidates typing-time completion already offers the right candidates
there, so the payoff is small. 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. 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 - [x] **H3** `help` provides general reference and per-command
help. help.
*(Done 2026-06-07: the **general reference** is `help` (no arg) — *(Done 2026-06-07: the **general reference** is `help` (no arg) —
@@ -878,8 +904,18 @@ since ADR-0027.)
PTY. Correcting a stale `CLAUDE.md` line that read "Tier 4 is PTY. Correcting a stale `CLAUDE.md` line that read "Tier 4 is
wired only for the listed critical flows" — it was not wired at wired only for the listed critical flows" — it was not wired at
all. Genuinely deferred.)* 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. stable Rust.
*(Partial, 2026-06-15. **CI is live** on the self-hosted Gitea
Actions (`docs/ci/adr/`): the gate runs `clippy -D warnings` +
`cargo test` (Tiers 13) on the **Linux** runner for every branch
push / PR, and `release-macos` runs the suite natively on the
**macOS** runner. **Windows is build-only** — cross-compiled, not
executed (no Windows runner). **Tier 4** (PTY, TT4) is still
unwired, so "all tiers" is not yet fully met. "Stable Rust" is
satisfied by the flake's pinned `1.95.0` (a stable release, not
nightly). Remaining for full TT5: a Windows execution runner and
Tier-4 PTY in CI.)*
## Cross-cutting ## Cross-cutting
Generated
+82
View File
@@ -0,0 +1,82 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1780902259,
"narHash": "sha256-q8yYEC5f1mFlQO9RGna4LTc9QrcvWunX6FYp83munkQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "bd0ff2d3eac24699c3664d5966b9ef36f388e2ca",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1781234414,
"narHash": "sha256-HdA+P4fKRGOomkewnI/Tww5Wz4xK1O7+hDO90YAsPB4=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "1d18bfe3de6244c641ca4e8011186d0981b81d76",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+98
View File
@@ -0,0 +1,98 @@
{
description = "RDBMS Playground Rust TUI dev environment + reproducible build";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, rust-overlay, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs {
inherit system;
overlays = [ (import rust-overlay) ];
};
# Single source of the Rust toolchain: the rustup toolchain file.
# rust-overlay provisions the exact channel + components declared there,
# so the dev shell and the build package share one pinned toolchain.
rust = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml;
# Read the package version straight from Cargo.toml so it never drifts
# from the crate metadata (no hand-maintained duplicate here).
cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml);
# System build inputs are deliberately tiny — this is a pure-Rust TUI:
# * libsqlite3-sys is built with the `bundled` feature, so SQLite is
# compiled from vendored C. That needs a C compiler, which the
# stdenv provides automatically (no entry required here).
# * arboard's clipboard backend is `x11rb` — a pure-Rust socket XCB
# client. It links no C X11 libraries, so none appear below. A live
# X server is only needed at *runtime* to copy; headless sessions
# fall back to OSC 52.
# If a future dependency introduces a pkg-config / native-lib link, add
# it here (and document why) rather than leaking it into the host env.
nativeBuildInputs = [ ];
buildInputs = [ ];
# `nix build` → the release binary, built reproducibly from the pinned
# toolchain and the committed Cargo.lock (importCargoLock fetches each
# dependency by its lockfile checksum — offline, no cargoHash to churn).
# CI's release job consumes this artifact; the gate's tests run
# separately via `nix develop -c cargo test` (see below), so the package
# build skips the suite — the nix sandbox has no HOME/X server and would
# fight the project-dirs / clipboard paths the tests touch.
rdbms-playground = pkgs.rustPlatform.buildRustPackage {
pname = cargoToml.package.name;
version = cargoToml.package.version;
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
inherit nativeBuildInputs buildInputs;
doCheck = false;
};
in {
packages.default = rdbms-playground;
packages.rdbms-playground = rdbms-playground;
devShells.default = pkgs.mkShell {
buildInputs = buildInputs ++ pkgs.lib.optionals pkgs.stdenv.isDarwin [
# macOS release builds (aarch64/x86_64-apple-darwin) link AppKit
# (arboard) + libSystem; the Apple SDK provides those framework/
# system-lib stubs as *system* paths (/usr/lib, /System/Library).
# NOTE: the darwin stdenv still propagates a *nix-store* libiconv and
# links it regardless of inputs, so the release workflow rewrites that
# one load path to /usr/lib/libiconv.2.dylib (install_name_tool) and
# re-signs — see release-macos / the macOS smoke-test. Adding
# `pkgs.libiconv` here would only reinforce the wrong path, so don't.
pkgs.apple-sdk
];
nativeBuildInputs = nativeBuildInputs ++ [
rust
# Dev-disk maintenance: cargo never garbage-collects stale per-hash
# build artifacts, so target/ creeps into the tens of GB (see
# CLAUDE.md "Build hygiene"). cargo-sweep prunes them; run it
# periodically between milestones.
pkgs.cargo-sweep
] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [
# Cross-compilation for the non-macOS D1 targets: `cargo zigbuild`
# uses Zig's bundled clang + libc as one universal cross cc/linker
# (incl. the `cc`-crate compile of rusqlite's bundled SQLite C) for
# Linux musl + Windows gnu/gnullvm. macOS builds natively with the
# Apple toolchain on the Mac runner, so these are Linux-only.
pkgs.cargo-zigbuild
pkgs.zig
];
shellHook = ''
echo "RDBMS Playground dev shell ($(uname -s))"
echo " rust: $(rustc --version | cut -d' ' -f1-2)"
echo " cargo: $(cargo --version | cut -d' ' -f1-2)"
'';
};
});
}
+26
View File
@@ -0,0 +1,26 @@
[toolchain]
# Pinned to an exact stable release (not the floating "stable" channel) so
# `nix flake update` cannot surprise-bump Rust into new clippy lints that would
# fail the `-D warnings` CI gate. Matches the host toolchain and the datamage
# flake's convention (its ADR 0046). Bump deliberately, in its own commit.
channel = "1.95.0"
# rustfmt + clippy back the `fmt`/`clippy` CI stages; no coverage or WASM
# tooling is needed here (pure-Rust TUI).
components = ["rustfmt", "clippy"]
# The non-macOS D1 release matrix, all cross-built from Linux x86_64 via
# `cargo zigbuild` (D1: cross-platform binaries; D2: single static binary).
# Linux uses musl + crt-static for fully static, portable binaries; Windows
# uses the gnu/gnullvm ABIs (Zig statically links libc, so the .exe is
# standalone). macOS is deferred — its arboard/AppKit link needs Apple's SDK,
# which a Linux runner can't supply cleanly (see docs/ci/adr ADR-ci-001).
targets = [
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-musl",
"x86_64-pc-windows-gnu",
"aarch64-pc-windows-gnullvm",
# macOS — built natively on the Apple-Silicon Mac runner (aarch64 native,
# x86_64 cross). These need Apple's SDK to link, which a Linux runner can't
# supply, so they are produced only on the Mac (see docs/ci/adr ADR-ci-003).
"aarch64-apple-darwin",
"x86_64-apple-darwin",
]
+5
View File
@@ -41,6 +41,11 @@ pub enum Action {
/// §4). `source` is the original user-typed text. /// §4). `source` is the original user-typed text.
JournalFailure { JournalFailure {
source: String, 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 /// User issued the `rebuild` app-level command (ADR-0015
/// §7, §11). Runtime computes a summary from /// §7, §11). Runtime computes a summary from
+573 -20
View File
@@ -271,6 +271,13 @@ pub struct App {
pub nav_focus: NavFocus, pub nav_focus: NavFocus,
pub output: VecDeque<OutputLine>, pub output: VecDeque<OutputLine>,
pub hint: Option<String>, pub hint: Option<String>,
/// Catalog class key of the most recent runtime error (H2 /
/// ADR-0053 D5), e.g. `foreign_key.child_side`. Set when a
/// friendly error is rendered, cleared on the next successful
/// command. The submitted `hint` command and empty-input F1 use
/// it to render that error's tier-3 `hint.err.<class>` block.
/// `None` → no recent error → the "getting started" pointer.
pub last_error_hint_key: Option<String>,
/// The validity indicator's currently-visible verdict /// The validity indicator's currently-visible verdict
/// (ADR-0027). `None` means the indicator shows nothing — /// (ADR-0027). `None` means the indicator shows nothing —
/// the input is clean, or it is hidden mid-typing while the /// the input is clean, or it is hidden mid-typing while the
@@ -521,6 +528,7 @@ pub const fn demo_badge_label(key: &KeyEvent) -> Option<&'static str> {
match (key.code, key.modifiers) { match (key.code, key.modifiers) {
(KeyCode::Tab, _) => Some("[TAB]"), (KeyCode::Tab, _) => Some("[TAB]"),
(KeyCode::BackTab, _) => Some("[SHIFT-TAB]"), (KeyCode::BackTab, _) => Some("[SHIFT-TAB]"),
(KeyCode::F(1), _) => Some("[F1]"),
(KeyCode::Enter, _) => Some("[ENTER]"), (KeyCode::Enter, _) => Some("[ENTER]"),
(KeyCode::Esc, _) => Some("[ESC]"), (KeyCode::Esc, _) => Some("[ESC]"),
(KeyCode::Up, _) => Some("[UP]"), (KeyCode::Up, _) => Some("[UP]"),
@@ -557,6 +565,7 @@ impl App {
nav_focus: NavFocus::Input, nav_focus: NavFocus::Input,
output: VecDeque::with_capacity(OUTPUT_CAPACITY), output: VecDeque::with_capacity(OUTPUT_CAPACITY),
hint: None, hint: None,
last_error_hint_key: None,
input_indicator: None, input_indicator: None,
tables: Vec::new(), tables: Vec::new(),
relationships: Vec::new(), relationships: Vec::new(),
@@ -646,6 +655,17 @@ impl App {
} }
} }
/// Whether the user is currently browsing a recalled history entry
/// (Up/Down navigation, unedited). Exposes the private
/// `history_cursor` predicate so the context-aware status strip
/// (ADR-0051) can select its history-navigation state. Editing the
/// recalled line ends navigation (`cancel_history_navigation`), so
/// this is `false` again the moment the user types.
#[must_use]
pub const fn is_browsing_history(&self) -> bool {
self.history_cursor.is_some()
}
/// The input view the **live-feedback** walkers (completion, ambient /// The input view the **live-feedback** walkers (completion, ambient
/// hint, validity verdict, highlight overlays) should see, plus the /// hint, validity verdict, highlight overlays) should see, plus the
/// byte offset stripped from the front and the cursor mapped into the /// byte offset stripped from the front and the cursor mapped into the
@@ -863,13 +883,16 @@ impl App {
error, error,
facts, facts,
source, source,
advanced,
} => { } => {
self.handle_dsl_failure(&command, error, facts); self.handle_dsl_failure(&command, error, facts);
// ADR-0034 §1/§2: an execution failure is journalled // ADR-0034 §1/§2: an execution failure is journalled
// `err` so it is recallable across sessions (the // `err` so it is recallable across sessions (the
// worker only journals successful commands). The App // worker only journals successful commands). The App
// emits the intent; the runtime does the append. // emits the intent; the runtime does the append. The
vec![Action::JournalFailure { source }] // mode rides along (ADR-0052) so an advanced failure
// tags `err:adv`.
vec![Action::JournalFailure { source, advanced }]
} }
AppEvent::TablesRefreshed(tables) => { AppEvent::TablesRefreshed(tables) => {
trace!(count = tables.len(), "tables refreshed"); trace!(count = tables.len(), "tables refreshed");
@@ -1194,6 +1217,21 @@ impl App {
return self.handle_nav_key(key); return self.handle_nav_key(key);
} }
// H2 / ADR-0053: F1 is a read-only contextual-hint overlay —
// it emits into the output journal and must NOT touch the input
// buffer, cursor, or the completion memo, so it sits ahead of
// the memo-clearing completion match below. Non-empty input →
// a hint for the command being typed; empty input → expand on
// the most recent error (or a getting-started pointer).
if key.code == KeyCode::F(1) {
if self.input.trim().is_empty() {
self.note_hint_for_recent_error();
} else {
self.note_hint_for_input();
}
return Vec::new();
}
// ADR-0022 stage 8 — non-modal completion. Tab / // ADR-0022 stage 8 — non-modal completion. Tab /
// Shift-Tab cycle; Esc / Backspace undo the whole // Shift-Tab cycle; Esc / Backspace undo the whole
// last-Tab insertion in one keystroke while the memo // last-Tab insertion in one keystroke while the memo
@@ -1637,11 +1675,27 @@ impl App {
Some(i) => i - 1, Some(i) => i - 1,
}; };
self.history_cursor = Some(next_index); self.history_cursor = Some(next_index);
self.input = self.history[next_index].clone(); let stored = self.history[next_index].clone();
self.input = self.recall_display(&stored);
self.input_cursor = self.input.len(); self.input_cursor = self.input.len();
self.input_scroll_offset = 0; self.input_scroll_offset = 0;
} }
/// The display form of a stored history entry for the current mode
/// (ADR-0052, issue #30). An advanced entry is stored in its
/// `:`-prefixed simple-mode runnable form; in **advanced** mode the
/// `:` is stripped so it runs as bare SQL, while in **simple** mode it
/// stays prefixed and runs via the one-shot escape. A simple entry
/// (never starting with `:`) is returned unchanged in either mode.
fn recall_display(&self, stored: &str) -> String {
if self.mode == Mode::Advanced
&& let Some(rest) = stored.strip_prefix(':')
{
return rest.trim_start().to_string();
}
stored.to_string()
}
/// Move forwards in history (towards newer entries; eventually /// Move forwards in history (towards newer entries; eventually
/// returning to the user's saved draft). /// returning to the user's saved draft).
fn history_forward(&mut self) { fn history_forward(&mut self) {
@@ -1650,7 +1704,8 @@ impl App {
}; };
if i + 1 < self.history.len() { if i + 1 < self.history.len() {
self.history_cursor = Some(i + 1); self.history_cursor = Some(i + 1);
self.input = self.history[i + 1].clone(); let stored = self.history[i + 1].clone();
self.input = self.recall_display(&stored);
} else { } else {
// Past the most recent entry — restore the draft and // Past the most recent entry — restore the draft and
// exit navigation mode. // exit navigation mode.
@@ -1698,10 +1753,6 @@ impl App {
if trimmed.is_empty() { if trimmed.is_empty() {
return Vec::new(); return Vec::new();
} }
// Record the original (trimmed) line in history regardless
// of whether it parses, so users can recall and edit
// typo'd commands.
self.push_history(trimmed);
// `:` one-shot escape: in simple mode, a leading `:` means // `:` one-shot escape: in simple mode, a leading `:` means
// treat *this single submission* as advanced. The persistent // treat *this single submission* as advanced. The persistent
@@ -1718,6 +1769,9 @@ impl App {
}; };
if effective_input.is_empty() { if effective_input.is_empty() {
// A bare `:` (one-shot with nothing after it) executes
// nothing and is not recorded — the push moved below the
// strip (ADR-0052), so it no longer lands in history.
return Vec::new(); return Vec::new();
} }
@@ -1728,16 +1782,38 @@ impl App {
"submit" "submit"
); );
// Parse-first: app-level commands and DSL commands now // Parse-first: app-level commands and DSL commands share the
// share the chumsky parser (per the round-5 refactor). // parser. App commands work in both modes — they're not gated by
// App commands work in both modes — they're not gated by // `effective_mode`. Anything that parses to a non-App variant (or
// `effective_mode`. Anything that parses to a non-App // fails to parse) falls through to the mode-specific path.
// variant falls through to the existing mode-specific let parsed = parse_command(&effective_input);
// path: simple → DSL execution; advanced → SQL placeholder.
// Anything that fails to parse falls through too — the // ADR-0052 (issue #30): record the command for cross-mode recall.
// simple-mode path renders the friendly parse error, the // An **advanced** (SQL) command is stored in its `:`-prefixed
// advanced-mode path renders the SQL placeholder. // simple-mode runnable form, so it can be recalled and re-run in
if let Ok(Command::App(app_cmd)) = parse_command(&effective_input) { // simple mode (recall strips the `:` again in advanced mode). A
// simple command — and **any app command**, which runs in either
// mode and so must not gain a `:` — is stored bare. Recorded
// regardless of whether it parses, so typo'd commands stay
// recallable. The canonical (un-prefixed) text is what reaches
// the journal via `ExecuteDsl.source`.
let is_app = matches!(&parsed, Ok(Command::App(_)));
// H2 / ADR-0053 D5: a new *DSL* command supersedes the previous
// runtime error for `hint`. App commands (incl. `hint` itself)
// and parse errors leave it intact, so `hint` still expands the
// last real error after, say, a `help` in between.
if matches!(&parsed, Ok(cmd) if !matches!(cmd, Command::App(_))) {
self.last_error_hint_key = None;
}
let advanced = submission_mode.is_advanced() && !is_app;
let ring_line = if advanced {
format!(": {effective_input}")
} else {
effective_input.clone()
};
self.push_history(&ring_line);
if let Ok(Command::App(app_cmd)) = parsed {
return self.dispatch_app_command(app_cmd, &effective_input); return self.dispatch_app_command(app_cmd, &effective_input);
} }
@@ -1769,6 +1845,13 @@ impl App {
} }
Vec::new() Vec::new()
} }
// H2 / ADR-0053: a submitted `hint` acts on the most recent
// runtime error (the buffer is empty post-submit). The
// live-input surface is the F1 keybinding (handle_key).
AppCommand::Hint => {
self.note_hint_for_recent_error();
Vec::new()
}
AppCommand::Rebuild => vec![Action::PrepareRebuild], AppCommand::Rebuild => vec![Action::PrepareRebuild],
AppCommand::Save => self.handle_save_command(false), AppCommand::Save => self.handle_save_command(false),
AppCommand::SaveAs => self.handle_save_command(true), AppCommand::SaveAs => self.handle_save_command(true),
@@ -1950,6 +2033,7 @@ impl App {
self.note_error(note); self.note_error(note);
return vec![Action::JournalFailure { return vec![Action::JournalFailure {
source: input.to_string(), source: input.to_string(),
advanced: submission_mode.is_advanced(),
}]; }];
} }
// Issue #17: simple-mode (DSL) counterpart. A wrong-count // Issue #17: simple-mode (DSL) counterpart. A wrong-count
@@ -1977,6 +2061,7 @@ impl App {
self.note_error(render_usage_block(input, mode)); self.note_error(render_usage_block(input, mode));
return vec![Action::JournalFailure { return vec![Action::JournalFailure {
source: input.to_string(), source: input.to_string(),
advanced: submission_mode.is_advanced(),
}]; }];
} }
self.push_output(OutputLine::echo(input, mode)); self.push_output(OutputLine::echo(input, mode));
@@ -2063,6 +2148,7 @@ impl App {
// append; the App only emits the intent. // append; the App only emits the intent.
vec![Action::JournalFailure { vec![Action::JournalFailure {
source: input.to_string(), source: input.to_string(),
advanced: submission_mode.is_advanced(),
}] }]
} }
} }
@@ -2374,6 +2460,10 @@ impl App {
// runtime built before posting the event. // runtime built before posting the event.
let ctx = self.build_translate_context(command, facts); let ctx = self.build_translate_context(command, facts);
let rendered = crate::friendly::translate_error(&error, &ctx).render(); let rendered = crate::friendly::translate_error(&error, &ctx).render();
// H2 / ADR-0053 D5: remember this error's tier-3 class so a
// following `hint` (or empty-input F1) can expand on it.
self.last_error_hint_key =
crate::friendly::error_hint_class(&error, &ctx).map(String::from);
warn!( warn!(
verb = command.verb(), verb = command.verb(),
error = %rendered, error = %rendered,
@@ -3043,6 +3133,109 @@ impl App {
} }
} }
// ── H2 / ADR-0053: contextual `hint` ────────────────────────
// Phase A wires the two surfaces (F1 → live input; the `hint`
// command → most recent error) plus the tier-2 fallback. The
// tier-3 corpus (`hint.cmd.*` / `hint.err.*`) is authored in later
// phases; until a block exists, `emit_tier3_block` returns false
// and the surface degrades to the ambient prose / getting-started
// pointer — never blank.
/// F1 with a non-empty buffer: a tier-3 hint for the command form
/// being typed, else the tier-2 ambient prose (ADR-0053 D2).
/// Read-only — callers guarantee the buffer/cursor/memo are left
/// untouched.
fn note_hint_for_input(&mut self) {
// `feedback_view` strips the `:` one-shot sigil and
// `effective_mode` reflects the one-shot advanced surface, so
// the hint matches the command the user is actually typing.
let (view, cursor, _off) = self.feedback_view();
let probe = view.to_string();
let mode = self.effective_mode().as_mode();
if let Some(id) = crate::dsl::grammar::hint_key_for_input_in_mode(&probe, mode)
&& self.emit_tier3_block(&format!("hint.cmd.{id}"))
{
return;
}
// Tier-2 fallback: surface the ambient prose as a persistent
// line (computed exactly as the live panel does).
let ambient = crate::input_render::ambient_hint_in_mode(
&probe,
cursor,
self.last_completion.as_ref(),
&self.schema_cache,
mode,
);
match ambient {
Some(crate::input_render::AmbientHint::Prose(text)) => {
self.push_category_three_prose(text);
}
Some(crate::input_render::AmbientHint::Candidates { items, .. }) => {
let names = items
.iter()
.map(|c| c.text.clone())
.collect::<Vec<_>>()
.join(", ");
self.push_category_three_prose(crate::t!("hint.ambient_expected", expected = names));
}
None => self.note_getting_started(),
}
}
/// The `hint` command (and empty-input F1): expand on the most
/// recent runtime error, else point the user at how to start
/// (ADR-0053 D2/D5).
fn note_hint_for_recent_error(&mut self) {
if let Some(class) = self.last_error_hint_key.clone()
&& self.emit_tier3_block(&format!("hint.err.{class}"))
{
return;
}
self.note_getting_started();
}
fn note_getting_started(&mut self) {
self.note_system(crate::t!("hint.getting_started"));
}
/// Render a tier-3 block (`<stem>.what` / `.example` / `.concept`)
/// when it has been authored; returns `false` if the `what` part is
/// absent so the caller can fall back to tier 2. `what` is
/// mandatory, `example`/`concept` optional (ADR-0053 D3). Styling
/// polish (the framed block) lands with the corpus.
fn emit_tier3_block(&mut self, stem: &str) -> bool {
let cat = crate::friendly::catalog();
let what_key = format!("{stem}.what");
if cat.get(&what_key).is_none() {
return false;
}
// Labelled block (ADR-0053 D4): a `Hint` heading, then aligned
// `What:` / `Example:` / `Concept:` lines. `concept` renders
// muted (`OutputStyleClass::Hint`); the rest are plain system.
let labelled = |label: &str, value: &str| {
// Pad `<Label>:` to a common width so the values align.
format!(" {:<9}{value}", format!("{label}:"))
};
self.note_system(crate::t!("hint.block.heading"));
self.note_system(labelled(
&crate::t!("hint.block.what"),
&crate::friendly::translate(&what_key, &[]),
));
if cat.get(&format!("{stem}.example")).is_some() {
self.note_system(labelled(
&crate::t!("hint.block.example"),
&crate::friendly::translate(&format!("{stem}.example"), &[]),
));
}
if cat.get(&format!("{stem}.concept")).is_some() {
self.push_category_three_prose(labelled(
&crate::t!("hint.block.concept"),
&crate::friendly::translate(&format!("{stem}.concept"), &[]),
));
}
true
}
fn note_system(&mut self, text: impl Into<String>) { fn note_system(&mut self, text: impl Into<String>) {
self.push_multiline(text.into(), OutputKind::System); self.push_multiline(text.into(), OutputKind::System);
} }
@@ -5482,6 +5675,7 @@ mod tests {
}, },
facts: crate::friendly::FailureContext::default(), facts: crate::friendly::FailureContext::default(),
source: String::new(), source: String::new(),
advanced: false,
}); });
let last = app.output.back().unwrap(); let last = app.output.back().unwrap();
assert_eq!(last.kind, OutputKind::Error); assert_eq!(last.kind, OutputKind::Error);
@@ -5490,6 +5684,286 @@ mod tests {
assert!(last.text.contains("Ghost"), "{}", last.text); assert!(last.text.contains("Ghost"), "{}", last.text);
} }
// ── H2 / ADR-0053: contextual `hint` (Phase A skeleton) ──────
fn f1(app: &mut App) -> Vec<Action> {
app.update(key(KeyCode::F(1)))
}
fn no_such_table_failure() -> AppEvent {
AppEvent::DslFailed {
command: Command::DropTable {
name: "Ghost".to_string(),
},
error: crate::db::DbError::Sqlite {
message: "no such table: Ghost".to_string(),
kind: crate::db::SqliteErrorKind::NoSuchTable,
},
facts: crate::friendly::FailureContext::default(),
source: String::new(),
advanced: false,
}
}
#[test]
fn hint_command_parses_to_app_hint() {
use crate::dsl::{parse_command, AppCommand, Command};
assert!(matches!(
parse_command("hint"),
Ok(Command::App(AppCommand::Hint))
));
}
#[test]
fn hint_command_with_no_recent_error_shows_getting_started() {
let mut app = App::new();
type_str(&mut app, "hint");
submit(&mut app);
assert!(output_contains(&app, "press F1"), "{}", error_lines(&app));
}
#[test]
fn f1_on_empty_input_with_no_error_shows_getting_started() {
let mut app = App::new();
let before = app.output.len();
f1(&mut app);
assert!(app.output.len() > before, "F1 must emit something");
assert!(output_contains(&app, "press F1"));
}
#[test]
fn f1_is_a_read_only_overlay() {
let mut app = App::new();
type_str(&mut app, "insert into T");
let input = app.input.clone();
let cursor = app.input_cursor;
let before = app.output.len();
f1(&mut app);
assert_eq!(app.input, input, "F1 must not change the buffer");
assert_eq!(app.input_cursor, cursor, "F1 must not move the cursor");
assert!(app.output.len() > before, "F1 emits a hint line");
}
#[test]
fn f1_preserves_the_completion_memo() {
let mut app = App::new();
type_str(&mut app, "show ");
app.update(key(KeyCode::Tab));
assert!(app.last_completion.is_some(), "precondition: Tab sets the memo");
let input = app.input.clone();
f1(&mut app);
assert!(app.last_completion.is_some(), "F1 must not clear the memo");
assert_eq!(app.input, input, "F1 must not change the buffer");
}
#[test]
fn dsl_failure_sets_hint_class_and_a_later_dsl_command_clears_it() {
let mut app = App::new();
app.update(no_such_table_failure());
assert_eq!(app.last_error_hint_key.as_deref(), Some("not_found"));
// A new DSL command supersedes the previous error.
type_str(&mut app, "drop table Ghost");
submit(&mut app);
assert_eq!(app.last_error_hint_key, None);
}
#[test]
fn app_command_does_not_clear_the_hint_class() {
let mut app = App::new();
app.update(no_such_table_failure());
assert_eq!(app.last_error_hint_key.as_deref(), Some("not_found"));
// `help` (an app command) leaves the last error intact, so a
// following `hint` still expands on it.
type_str(&mut app, "help");
submit(&mut app);
assert_eq!(
app.last_error_hint_key.as_deref(),
Some("not_found"),
"an app command must not clear the last error's hint class"
);
}
#[test]
fn hint_after_error_emits_a_hint_without_panicking() {
// Phase A: no tier-3 `hint.err.*` content exists yet, so the
// error path falls back to the getting-started pointer. (Phase C
// replaces this with the real error block.)
let mut app = App::new();
app.update(no_such_table_failure());
let before = app.output.len();
type_str(&mut app, "hint");
submit(&mut app);
assert!(app.output.len() > before, "hint must emit something");
}
#[test]
fn help_list_includes_hint() {
let mut app = App::new();
type_str(&mut app, "help");
submit(&mut app);
assert!(
output_contains(&app, "explain the most recent error"),
"help list must advertise the hint command"
);
}
#[test]
fn help_hint_describes_the_hint_command() {
let mut app = App::new();
type_str(&mut app, "help hint");
submit(&mut app);
assert!(output_contains(&app, "explain the most recent error"));
}
// ── Phase B: tier-3 exemplar content renders ────────────────
#[test]
fn f1_on_insert_input_renders_the_insert_hint_block() {
let mut app = App::new();
type_str(&mut app, "insert into Customers ");
f1(&mut app);
assert!(
output_contains(&app, "Add one or more rows to a table"),
"expected the insert tier-3 block"
);
}
/// Locks the rendered tier-3 block format (ADR-0053 D4): a `Hint`
/// heading + aligned `What:` / `Example:` / `Concept:` lines.
#[test]
fn insert_hint_block_renders_in_the_labelled_format() {
let mut app = App::new();
type_str(&mut app, "insert into Customers ");
f1(&mut app);
let block = app
.output
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!("hint_block_insert", block);
}
#[test]
fn f1_on_add_relationship_renders_the_relationship_block() {
let mut app = App::new();
type_str(&mut app, "add 1:n relationship from Customers.id to Orders.cust ");
f1(&mut app);
assert!(
output_contains(&app, "one parent, many children"),
"expected the add-relationship tier-3 block"
);
}
#[test]
fn f1_on_add_column_does_not_render_the_relationship_block() {
// Per-form disambiguation (ADR-0053 D3): `add column` resolves
// to `add_column` (no tier-3 block yet → tier-2 fallback), NOT
// the relationship block — proving the multi-form node keys
// per form, not per node.
let mut app = App::new();
type_str(&mut app, "add column Note text to Customers");
f1(&mut app);
assert!(!output_contains(&app, "one parent, many children"));
assert!(!output_contains(&app, "1:n"));
}
#[test]
fn hint_renders_the_foreign_key_error_block() {
let mut app = App::new();
app.last_error_hint_key = Some("foreign_key.child_side".to_string());
type_str(&mut app, "hint");
submit(&mut app);
assert!(
output_contains(&app, "doesn't match any parent row"),
"expected the FK child-side tier-3 block"
);
}
// ── Phase C batch 1: app-command hints render ───────────────
#[test]
fn f1_on_an_app_command_renders_its_hint_block() {
let mut app = App::new();
type_str(&mut app, "mode advanced");
f1(&mut app);
assert!(
output_contains(&app, "Switch between simple mode"),
"expected the `mode` tier-3 block"
);
}
// ── Phase C batch 2: DDL hints render (incl. multi-form DROP) ──
#[test]
fn f1_on_create_table_renders_its_hint_block() {
let mut app = App::new();
type_str(&mut app, "create table Customers with pk id(serial)");
f1(&mut app);
assert!(output_contains(&app, "Create a new table"));
}
#[test]
fn f1_disambiguates_drop_forms() {
let mut app = App::new();
type_str(&mut app, "drop index idx_email");
f1(&mut app);
// Resolves drop_index, not drop_table/column/etc.
assert!(output_contains(&app, "Remove an index by name"));
assert!(!output_contains(&app, "Remove a table"));
}
// ── Phase C batch 3: DML hints render (incl. multi-form SHOW) ──
#[test]
fn f1_on_update_renders_its_hint_block() {
let mut app = App::new();
type_str(&mut app, "update Customers set email = 'x' ");
f1(&mut app);
assert!(output_contains(&app, "Change values in the rows"));
}
#[test]
fn f1_disambiguates_show_forms() {
let mut app = App::new();
type_str(&mut app, "show relationships");
f1(&mut app);
assert!(output_contains(&app, "List all the relationships"));
assert!(!output_contains(&app, "rows stored in a table"));
}
// ── Phase C batch 4: advanced-SQL hints (mode-aware) ────────
#[test]
fn f1_in_advanced_mode_renders_the_sql_insert_hint() {
let mut app = App::new();
app.mode = Mode::Advanced;
type_str(&mut app, "insert into Customers (name) values ('x')");
f1(&mut app);
assert!(output_contains(&app, "Insert rows with SQL"));
assert!(!output_contains(&app, "Add one or more rows"));
}
#[test]
fn f1_on_select_renders_the_select_hint() {
let mut app = App::new();
app.mode = Mode::Advanced;
type_str(&mut app, "select name from Customers");
f1(&mut app);
assert!(output_contains(&app, "heart of SQL"));
}
// ── Phase C batch 5: runtime error-class hints render ───────
#[test]
fn hint_renders_a_runtime_error_block() {
let mut app = App::new();
app.last_error_hint_key = Some("unique".to_string());
type_str(&mut app, "hint");
submit(&mut app);
assert!(output_contains(&app, "must be unique"));
}
#[test] #[test]
fn messages_command_toggles_verbosity_and_reports() { fn messages_command_toggles_verbosity_and_reports() {
let mut app = App::new(); let mut app = App::new();
@@ -5540,6 +6014,7 @@ mod tests {
error: err, error: err,
facts, facts,
source: String::new(), source: String::new(),
advanced: false,
}); });
let body = app let body = app
.output .output
@@ -5589,6 +6064,7 @@ mod tests {
error: err, error: err,
facts, facts,
source: String::new(), source: String::new(),
advanced: false,
}); });
let body = app let body = app
.output .output
@@ -5621,6 +6097,7 @@ mod tests {
error: err(), error: err(),
facts: crate::friendly::FailureContext::default(), facts: crate::friendly::FailureContext::default(),
source: String::new(), source: String::new(),
advanced: false,
}); });
let verbose_text = app let verbose_text = app
.output .output
@@ -5641,6 +6118,7 @@ mod tests {
error: err(), error: err(),
facts: crate::friendly::FailureContext::default(), facts: crate::friendly::FailureContext::default(),
source: String::new(), source: String::new(),
advanced: false,
}); });
let short_text = app let short_text = app
.output .output
@@ -6316,7 +6794,7 @@ mod tests {
assert!( assert!(
matches!( matches!(
actions.as_slice(), actions.as_slice(),
[Action::JournalFailure { source }] if source == "florp glorp" [Action::JournalFailure { source, .. }] if source == "florp glorp"
), ),
"expected JournalFailure for the typo'd line; got {actions:?}", "expected JournalFailure for the typo'd line; got {actions:?}",
); );
@@ -6339,11 +6817,12 @@ mod tests {
}, },
facts: crate::friendly::FailureContext::default(), facts: crate::friendly::FailureContext::default(),
source: "drop table Ghost".to_string(), source: "drop table Ghost".to_string(),
advanced: false,
}); });
assert!( assert!(
matches!( matches!(
actions.as_slice(), actions.as_slice(),
[Action::JournalFailure { source }] if source == "drop table Ghost" [Action::JournalFailure { source, .. }] if source == "drop table Ghost"
), ),
"expected JournalFailure carrying the source; got {actions:?}", "expected JournalFailure carrying the source; got {actions:?}",
); );
@@ -6472,6 +6951,80 @@ mod tests {
assert_eq!(app.input, "drop table AX"); assert_eq!(app.input, "drop table AX");
} }
// ---- ADR-0052 (issue #30): mode-aware history recall ----
#[test]
fn one_shot_advanced_command_recalls_with_colon_in_simple_mode() {
// The bug: a `:`-one-shot advanced command must recall WITH the
// `:` so it re-runs in simple mode (in-session and, via the
// `:`-prefixed ring form, across sessions too).
let mut app = App::new();
type_str(&mut app, ": select 1");
submit(&mut app);
app.update(key(KeyCode::Up));
assert_eq!(app.input, ": select 1");
}
#[test]
fn persistent_advanced_command_recalls_with_colon_back_in_simple_mode() {
// The feature: a command typed in *persistent* advanced mode
// recalls into simple mode with a `:` so it stays runnable.
let mut app = App::new();
app.mode = Mode::Advanced;
type_str(&mut app, "select 1");
submit(&mut app);
// Switch back to simple and recall.
app.mode = Mode::Simple;
app.update(key(KeyCode::Up));
assert_eq!(app.input, ": select 1");
}
#[test]
fn advanced_command_recalls_bare_in_advanced_mode() {
// In advanced mode the stored `:`-prefix is stripped so it runs
// as bare SQL.
let mut app = App::new();
app.mode = Mode::Advanced;
type_str(&mut app, "select 1");
submit(&mut app);
app.update(key(KeyCode::Up));
assert_eq!(app.input, "select 1");
}
#[test]
fn simple_command_recalls_bare_in_either_mode() {
let mut app = App::new();
type_str(&mut app, "drop table T");
submit(&mut app);
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table T");
app.mode = Mode::Advanced;
app.update(key(KeyCode::Down)); // back to draft
app.update(key(KeyCode::Up));
assert_eq!(app.input, "drop table T");
}
#[test]
fn app_command_recalls_bare_even_when_typed_with_colon() {
// An app command runs in any mode, so it must NOT gain a `:` on
// recall even when entered via the one-shot escape.
let mut app = App::new();
type_str(&mut app, ": mode advanced");
submit(&mut app);
app.update(key(KeyCode::Up));
assert_eq!(app.input, "mode advanced");
}
#[test]
fn a_bare_colon_is_not_recorded_in_history() {
let mut app = App::new();
type_str(&mut app, ":");
submit(&mut app);
// Nothing recallable.
app.update(key(KeyCode::Up));
assert_eq!(app.input, "");
}
#[test] #[test]
fn add_column_with_text_type_emits_execute_action() { fn add_column_with_text_type_emits_execute_action() {
let mut app = App::new(); let mut app = App::new();
+137 -306
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -552,6 +552,11 @@ pub enum AppCommand {
Help { Help {
topic: Option<String>, 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 /// Rebuild `playground.db` from `project.yaml` + data/, with
/// confirmation modal. /// confirmation modal.
Rebuild, Rebuild,
@@ -1013,6 +1018,7 @@ impl Command {
Self::App(app) => match app { Self::App(app) => match app {
AppCommand::Quit => "quit", AppCommand::Quit => "quit",
AppCommand::Help { .. } => "help", AppCommand::Help { .. } => "help",
AppCommand::Hint => "hint",
AppCommand::Rebuild => "rebuild", AppCommand::Rebuild => "rebuild",
AppCommand::Save => "save", AppCommand::Save => "save",
AppCommand::SaveAs => "save as", AppCommand::SaveAs => "save as",
+25
View File
@@ -177,6 +177,9 @@ const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result<Command, Va
const fn build_undo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> { const fn build_undo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Undo)) 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> { const fn build_redo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Redo)) Ok(Command::App(AppCommand::Redo))
@@ -263,6 +266,7 @@ pub static QUIT: CommandNode = CommandNode {
shape: EMPTY_SEQ, shape: EMPTY_SEQ,
ast_builder: build_quit, ast_builder: build_quit,
help_id: Some("app.quit"), help_id: Some("app.quit"),
hint_ids: &["quit"],
usage_ids: &["parse.usage.quit"],}; usage_ids: &["parse.usage.quit"],};
pub static HELP: CommandNode = CommandNode { pub static HELP: CommandNode = CommandNode {
@@ -270,13 +274,24 @@ pub static HELP: CommandNode = CommandNode {
shape: HELP_TOPIC_OPT, shape: HELP_TOPIC_OPT,
ast_builder: build_help, ast_builder: build_help,
help_id: Some("app.help"), help_id: Some("app.help"),
hint_ids: &["help"],
usage_ids: &["parse.usage.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 { pub static REBUILD: CommandNode = CommandNode {
entry: Word::keyword("rebuild"), entry: Word::keyword("rebuild"),
shape: EMPTY_SEQ, shape: EMPTY_SEQ,
ast_builder: build_rebuild, ast_builder: build_rebuild,
help_id: Some("app.rebuild"), help_id: Some("app.rebuild"),
hint_ids: &["rebuild"],
usage_ids: &["parse.usage.rebuild"],}; usage_ids: &["parse.usage.rebuild"],};
pub static SAVE: CommandNode = CommandNode { pub static SAVE: CommandNode = CommandNode {
@@ -284,6 +299,7 @@ pub static SAVE: CommandNode = CommandNode {
shape: SAVE_AS_OPT, shape: SAVE_AS_OPT,
ast_builder: build_save, ast_builder: build_save,
help_id: Some("app.save"), help_id: Some("app.save"),
hint_ids: &["save"],
usage_ids: &["parse.usage.save"],}; usage_ids: &["parse.usage.save"],};
pub static NEW: CommandNode = CommandNode { pub static NEW: CommandNode = CommandNode {
@@ -291,6 +307,7 @@ pub static NEW: CommandNode = CommandNode {
shape: EMPTY_SEQ, shape: EMPTY_SEQ,
ast_builder: build_new, ast_builder: build_new,
help_id: Some("app.new"), help_id: Some("app.new"),
hint_ids: &["new"],
usage_ids: &["parse.usage.new"],}; usage_ids: &["parse.usage.new"],};
pub static LOAD: CommandNode = CommandNode { pub static LOAD: CommandNode = CommandNode {
@@ -298,6 +315,7 @@ pub static LOAD: CommandNode = CommandNode {
shape: EMPTY_SEQ, shape: EMPTY_SEQ,
ast_builder: build_load, ast_builder: build_load,
help_id: Some("app.load"), help_id: Some("app.load"),
hint_ids: &["load"],
usage_ids: &["parse.usage.load"],}; usage_ids: &["parse.usage.load"],};
pub static EXPORT: CommandNode = CommandNode { pub static EXPORT: CommandNode = CommandNode {
@@ -305,6 +323,7 @@ pub static EXPORT: CommandNode = CommandNode {
shape: EXPORT_PATH_OPT, shape: EXPORT_PATH_OPT,
ast_builder: build_export, ast_builder: build_export,
help_id: Some("app.export"), help_id: Some("app.export"),
hint_ids: &["export"],
usage_ids: &["parse.usage.export"],}; usage_ids: &["parse.usage.export"],};
pub static IMPORT: CommandNode = CommandNode { pub static IMPORT: CommandNode = CommandNode {
@@ -312,6 +331,7 @@ pub static IMPORT: CommandNode = CommandNode {
shape: IMPORT_BODY_OPT, shape: IMPORT_BODY_OPT,
ast_builder: build_import, ast_builder: build_import,
help_id: Some("app.import"), help_id: Some("app.import"),
hint_ids: &["import"],
usage_ids: &["parse.usage.import"],}; usage_ids: &["parse.usage.import"],};
pub static MODE: CommandNode = CommandNode { pub static MODE: CommandNode = CommandNode {
@@ -319,6 +339,7 @@ pub static MODE: CommandNode = CommandNode {
shape: MODE_VALUE, shape: MODE_VALUE,
ast_builder: build_mode, ast_builder: build_mode,
help_id: Some("app.mode"), help_id: Some("app.mode"),
hint_ids: &["mode"],
usage_ids: &["parse.usage.mode"],}; usage_ids: &["parse.usage.mode"],};
pub static MESSAGES: CommandNode = CommandNode { pub static MESSAGES: CommandNode = CommandNode {
@@ -326,6 +347,7 @@ pub static MESSAGES: CommandNode = CommandNode {
shape: MESSAGES_VALUE_OPT, shape: MESSAGES_VALUE_OPT,
ast_builder: build_messages, ast_builder: build_messages,
help_id: Some("app.messages"), help_id: Some("app.messages"),
hint_ids: &["messages"],
usage_ids: &["parse.usage.messages"],}; usage_ids: &["parse.usage.messages"],};
pub static UNDO: CommandNode = CommandNode { pub static UNDO: CommandNode = CommandNode {
@@ -333,6 +355,7 @@ pub static UNDO: CommandNode = CommandNode {
shape: EMPTY_SEQ, shape: EMPTY_SEQ,
ast_builder: build_undo, ast_builder: build_undo,
help_id: Some("app.undo"), help_id: Some("app.undo"),
hint_ids: &["undo"],
usage_ids: &["parse.usage.undo"],}; usage_ids: &["parse.usage.undo"],};
pub static REDO: CommandNode = CommandNode { pub static REDO: CommandNode = CommandNode {
@@ -340,6 +363,7 @@ pub static REDO: CommandNode = CommandNode {
shape: EMPTY_SEQ, shape: EMPTY_SEQ,
ast_builder: build_redo, ast_builder: build_redo,
help_id: Some("app.redo"), help_id: Some("app.redo"),
hint_ids: &["redo"],
usage_ids: &["parse.usage.redo"],}; usage_ids: &["parse.usage.redo"],};
pub static COPY: CommandNode = CommandNode { pub static COPY: CommandNode = CommandNode {
@@ -347,4 +371,5 @@ pub static COPY: CommandNode = CommandNode {
shape: COPY_VALUE_OPT, shape: COPY_VALUE_OPT,
ast_builder: build_copy, ast_builder: build_copy,
help_id: Some("app.copy"), help_id: Some("app.copy"),
hint_ids: &["copy"],
usage_ids: &["parse.usage.copy"],}; usage_ids: &["parse.usage.copy"],};
+20
View File
@@ -1790,6 +1790,13 @@ pub static SHOW: CommandNode = CommandNode {
shape: SHOW_SHAPE, shape: SHOW_SHAPE,
ast_builder: build_show, ast_builder: build_show,
help_id: Some("data.show"), help_id: Some("data.show"),
hint_ids: &[
"show_data",
"show_table",
"show_tables",
"show_relationships",
"show_indexes",
],
usage_ids: &[ usage_ids: &[
"parse.usage.show_data", "parse.usage.show_data",
"parse.usage.show_table", "parse.usage.show_table",
@@ -1805,6 +1812,7 @@ pub static SEED: CommandNode = CommandNode {
shape: SEED_SHAPE, shape: SEED_SHAPE,
ast_builder: build_seed, ast_builder: build_seed,
help_id: Some("data.seed"), help_id: Some("data.seed"),
hint_ids: &["seed"],
usage_ids: &["parse.usage.seed"], usage_ids: &["parse.usage.seed"],
}; };
@@ -1813,6 +1821,8 @@ pub static INSERT: CommandNode = CommandNode {
shape: INSERT_SHAPE, shape: INSERT_SHAPE,
ast_builder: build_insert, ast_builder: build_insert,
help_id: Some("data.insert"), help_id: Some("data.insert"),
// ADR-0053 Phase-B exemplar.
hint_ids: &["insert"],
usage_ids: &["parse.usage.insert"],}; usage_ids: &["parse.usage.insert"],};
pub static UPDATE: CommandNode = CommandNode { pub static UPDATE: CommandNode = CommandNode {
@@ -1820,6 +1830,7 @@ pub static UPDATE: CommandNode = CommandNode {
shape: UPDATE_SHAPE, shape: UPDATE_SHAPE,
ast_builder: build_update, ast_builder: build_update,
help_id: Some("data.update"), help_id: Some("data.update"),
hint_ids: &["update"],
usage_ids: &["parse.usage.update"],}; usage_ids: &["parse.usage.update"],};
pub static DELETE: CommandNode = CommandNode { pub static DELETE: CommandNode = CommandNode {
@@ -1827,6 +1838,7 @@ pub static DELETE: CommandNode = CommandNode {
shape: DELETE_SHAPE, shape: DELETE_SHAPE,
ast_builder: build_delete, ast_builder: build_delete,
help_id: Some("data.delete"), help_id: Some("data.delete"),
hint_ids: &["delete"],
usage_ids: &["parse.usage.delete"],}; usage_ids: &["parse.usage.delete"],};
pub static REPLAY: CommandNode = CommandNode { pub static REPLAY: CommandNode = CommandNode {
@@ -1834,6 +1846,7 @@ pub static REPLAY: CommandNode = CommandNode {
shape: REPLAY_PATH, shape: REPLAY_PATH,
ast_builder: build_replay, ast_builder: build_replay,
help_id: Some("data.replay"), help_id: Some("data.replay"),
hint_ids: &["replay"],
usage_ids: &["parse.usage.replay"],}; usage_ids: &["parse.usage.replay"],};
pub static EXPLAIN: CommandNode = CommandNode { pub static EXPLAIN: CommandNode = CommandNode {
@@ -1841,6 +1854,7 @@ pub static EXPLAIN: CommandNode = CommandNode {
shape: EXPLAIN_SHAPE, shape: EXPLAIN_SHAPE,
ast_builder: build_explain, ast_builder: build_explain,
help_id: Some("data.explain"), help_id: Some("data.explain"),
hint_ids: &["explain"],
usage_ids: &["parse.usage.explain"],}; usage_ids: &["parse.usage.explain"],};
/// `explain` over advanced-mode SQL (ADR-0039). /// `explain` over advanced-mode SQL (ADR-0039).
@@ -1860,6 +1874,7 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode {
// too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE` // too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE`
// precedent; otherwise `note_help` would print `explain` twice. // precedent; otherwise `note_help` would print `explain` twice.
help_id: None, help_id: None,
hint_ids: &["explain_sql"],
usage_ids: &[],}; usage_ids: &[],};
/// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032). /// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032).
@@ -1875,6 +1890,7 @@ pub static SELECT: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL), shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL),
ast_builder: build_select, ast_builder: build_select,
help_id: None, help_id: None,
hint_ids: &["select"],
usage_ids: &["parse.usage.select"],}; usage_ids: &["parse.usage.select"],};
/// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c). /// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c).
@@ -1889,6 +1905,7 @@ pub static WITH: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL), shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL),
ast_builder: build_select, ast_builder: build_select,
help_id: None, help_id: None,
hint_ids: &["with"],
usage_ids: &["parse.usage.with"],}; usage_ids: &["parse.usage.with"],};
/// SQL `INSERT` — the `Advanced`-category node of the shared /// SQL `INSERT` — the `Advanced`-category node of the shared
@@ -1906,6 +1923,7 @@ pub static SQL_INSERT: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE), shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE),
ast_builder: build_sql_insert, ast_builder: build_sql_insert,
help_id: None, help_id: None,
hint_ids: &["sql_insert"],
usage_ids: &[], usage_ids: &[],
}; };
@@ -1919,6 +1937,7 @@ pub static SQL_UPDATE: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE), shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE),
ast_builder: build_sql_update, ast_builder: build_sql_update,
help_id: None, help_id: None,
hint_ids: &["sql_update"],
usage_ids: &[], usage_ids: &[],
}; };
@@ -1934,6 +1953,7 @@ pub static SQL_DELETE: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE), shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE),
ast_builder: build_sql_delete, ast_builder: build_sql_delete,
help_id: None, help_id: None,
hint_ids: &["sql_delete"],
usage_ids: &[], usage_ids: &[],
}; };
+26
View File
@@ -968,6 +968,13 @@ pub static DROP: CommandNode = CommandNode {
shape: DROP_SHAPE, shape: DROP_SHAPE,
ast_builder: build_drop, ast_builder: build_drop,
help_id: Some("ddl.drop"), help_id: Some("ddl.drop"),
hint_ids: &[
"drop_table",
"drop_column",
"drop_relationship",
"drop_index",
"drop_constraint",
],
usage_ids: &[ usage_ids: &[
"parse.usage.drop_table", "parse.usage.drop_table",
"parse.usage.drop_column", "parse.usage.drop_column",
@@ -981,6 +988,16 @@ pub static ADD: CommandNode = CommandNode {
shape: ADD_SHAPE, shape: ADD_SHAPE,
ast_builder: build_add, ast_builder: build_add,
help_id: Some("ddl.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: &[ usage_ids: &[
"parse.usage.add_column", "parse.usage.add_column",
"parse.usage.add_relationship", "parse.usage.add_relationship",
@@ -993,6 +1010,7 @@ pub static RENAME: CommandNode = CommandNode {
shape: RENAME_COLUMN, shape: RENAME_COLUMN,
ast_builder: build_rename_column, ast_builder: build_rename_column,
help_id: Some("ddl.rename"), help_id: Some("ddl.rename"),
hint_ids: &["rename_column"],
usage_ids: &["parse.usage.rename_column"],}; usage_ids: &["parse.usage.rename_column"],};
pub static CHANGE: CommandNode = CommandNode { pub static CHANGE: CommandNode = CommandNode {
@@ -1000,6 +1018,7 @@ pub static CHANGE: CommandNode = CommandNode {
shape: CHANGE_COLUMN, shape: CHANGE_COLUMN,
ast_builder: build_change_column, ast_builder: build_change_column,
help_id: Some("ddl.change"), help_id: Some("ddl.change"),
hint_ids: &["change_column"],
usage_ids: &["parse.usage.change_column"],}; usage_ids: &["parse.usage.change_column"],};
// ================================================================= // =================================================================
@@ -1360,6 +1379,7 @@ pub static CREATE: CommandNode = CommandNode {
shape: CREATE_TABLE, shape: CREATE_TABLE,
ast_builder: build_create_table, ast_builder: build_create_table,
help_id: Some("ddl.create"), help_id: Some("ddl.create"),
hint_ids: &["create_table"],
usage_ids: &["parse.usage.create_table"],}; usage_ids: &["parse.usage.create_table"],};
// ================================================================= // =================================================================
@@ -1428,6 +1448,7 @@ pub static CREATE_M2N: CommandNode = CommandNode {
shape: CREATE_M2N_SHAPE, shape: CREATE_M2N_SHAPE,
ast_builder: build_create_m2n, ast_builder: build_create_m2n,
help_id: Some("ddl.create_m2n"), help_id: Some("ddl.create_m2n"),
hint_ids: &["create_m2n"],
usage_ids: &["parse.usage.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), shape: Node::Subgrammar(&super::sql_create_table::SQL_CREATE_TABLE_SHAPE),
ast_builder: build_sql_create_table, ast_builder: build_sql_create_table,
help_id: Some("ddl.sql_create_table"), help_id: Some("ddl.sql_create_table"),
hint_ids: &["sql_create_table"],
usage_ids: &["parse.usage.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, shape: SQL_DROP_TABLE_SHAPE,
ast_builder: build_sql_drop_table, ast_builder: build_sql_drop_table,
help_id: Some("ddl.sql_drop_table"), help_id: Some("ddl.sql_drop_table"),
hint_ids: &["sql_drop_table"],
usage_ids: &["parse.usage.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, shape: SQL_DROP_INDEX_SHAPE,
ast_builder: build_sql_drop_index, ast_builder: build_sql_drop_index,
help_id: Some("ddl.sql_drop_index"), help_id: Some("ddl.sql_drop_index"),
hint_ids: &["sql_drop_index"],
usage_ids: &["parse.usage.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, shape: SQL_CREATE_INDEX_SHAPE,
ast_builder: build_sql_create_index, ast_builder: build_sql_create_index,
help_id: Some("ddl.sql_create_index"), help_id: Some("ddl.sql_create_index"),
hint_ids: &["sql_create_index"],
usage_ids: &["parse.usage.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, shape: SQL_ALTER_TABLE_SHAPE,
ast_builder: build_sql_alter_table, ast_builder: build_sql_alter_table,
help_id: Some("ddl.sql_alter_table"), help_id: Some("ddl.sql_alter_table"),
hint_ids: &["sql_alter_table"],
usage_ids: &["parse.usage.sql_alter_table"], usage_ids: &["parse.usage.sql_alter_table"],
}; };
+217 -39
View File
@@ -530,6 +530,18 @@ pub struct CommandNode {
/// so a newly-registered command appears in `help` /// so a newly-registered command appears in `help`
/// automatically (ADR-0024 §help_id). /// automatically (ADR-0024 §help_id).
pub help_id: Option<&'static str>, 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 /// Catalog keys under `parse.usage.*` to render in the
/// "usage:" block when a parse error fires for this command /// "usage:" block when a parse error fires for this command
/// (ADR-0021 §1, ADR-0024 §architecture). Multi-form families /// (ADR-0021 §1, ADR-0024 §architecture). Multi-form families
@@ -574,32 +586,100 @@ pub fn usage_keys_for_input_in_mode(
source: &str, source: &str,
mode: crate::mode::Mode, mode: crate::mode::Mode,
) -> Option<(&'static str, Vec<&'static str>)> { ) -> 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}; use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
let start = skip_whitespace(source, 0); 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 word = &source[kw_start..kw_end];
let candidates = commands_for_entry_word(word); let candidates = commands_for_entry_word(word);
if candidates.is_empty() { 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)> = let selected: Vec<(usize, &'static CommandNode, CommandCategory)> =
if mode == crate::mode::Mode::Advanced { if mode == crate::mode::Mode::Advanced {
let mut v: Vec<_> = candidates let mut v: Vec<_> = candidates
@@ -621,17 +701,7 @@ pub fn usage_keys_for_input_in_mode(
.filter(|(_, _, c)| *c == CommandCategory::Simple) .filter(|(_, _, c)| *c == CommandCategory::Simple)
.collect() .collect()
}; };
// Degenerate guard: an advanced-only word in simple mode (not if selected.is_empty() { candidates } else { selected }
// 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))
} }
/// The single usage template most relevant to `source`, when /// The single usage template most relevant to `source`, when
@@ -658,14 +728,24 @@ pub fn usage_key_for_input_in_mode(
source: &str, source: &str,
mode: crate::mode::Mode, mode: crate::mode::Mode,
) -> Option<&'static str> { ) -> Option<&'static str> {
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?; 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()?; let first = *keys.first()?;
if keys.len() == 1 { if keys.len() == 1 {
return Some(first); return Some(first);
} }
// Multi-form: the form is named by the token right after
// the entry keyword.
let start = skip_whitespace(source, 0); let start = skip_whitespace(source, 0);
let (_, entry_end) = consume_ident(source, start)?; let (_, entry_end) = consume_ident(source, start)?;
let after = skip_whitespace(source, entry_end); 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")); return keys.iter().copied().find(|k| k.ends_with("relationship"));
} }
// The `create m:n relationship` form (ADR-0045) opens with `m:n` // 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 // — a letter, so the digit branch misses it; its key ends `…m2n`.
// `…create_m2n` (not `relationship`).
if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) { 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")); return keys.iter().copied().find(|k| k.ends_with("m2n"));
} }
// Otherwise the form word is an identifier — `column`, // Otherwise the form word is an identifier — `column`, `index`,
// `index`, `table`, `relationship` — matched against the // `table`, `relationship` — matched against each key's suffix.
// usage key's suffix.
let (s, e) = consume_ident(source, after)?; let (s, e) = consume_ident(source, after)?;
let form = source[s..e].to_ascii_lowercase(); let form = source[s..e].to_ascii_lowercase();
keys.iter().copied().find(|k| k.ends_with(form.as_str())) 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)] = &[ pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[
(&app::QUIT, CommandCategory::Simple), (&app::QUIT, CommandCategory::Simple),
(&app::HELP, CommandCategory::Simple), (&app::HELP, CommandCategory::Simple),
(&app::HINT, CommandCategory::Simple),
(&app::REBUILD, CommandCategory::Simple), (&app::REBUILD, CommandCategory::Simple),
(&app::SAVE, CommandCategory::Simple), (&app::SAVE, CommandCategory::Simple),
(&app::NEW, CommandCategory::Simple), (&app::NEW, CommandCategory::Simple),
@@ -836,6 +915,105 @@ pub fn commands_for_entry_word(
.collect() .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)] #[cfg(test)]
mod usage_key_tests { mod usage_key_tests {
use super::usage_key_for_input; use super::usage_key_for_input;
+2
View File
@@ -6910,6 +6910,7 @@ mod dispatch_3a_tests {
shape: Node::Word(Word::keyword("dsltail")), shape: Node::Word(Word::keyword("dsltail")),
ast_builder: dsl_builder, ast_builder: dsl_builder,
help_id: None, help_id: None,
hint_ids: &[],
usage_ids: &[], usage_ids: &[],
}; };
static SMOKE_SQL: CommandNode = CommandNode { static SMOKE_SQL: CommandNode = CommandNode {
@@ -6917,6 +6918,7 @@ mod dispatch_3a_tests {
shape: Node::Word(Word::keyword("sqltail")), shape: Node::Word(Word::keyword("sqltail")),
ast_builder: sql_builder, ast_builder: sql_builder,
help_id: None, help_id: None,
hint_ids: &[],
usage_ids: &[], usage_ids: &[],
}; };
+5
View File
@@ -161,6 +161,11 @@ pub enum AppEvent {
/// commands, so an execution failure would otherwise be /// commands, so an execution failure would otherwise be
/// lost across sessions. /// lost across sessions.
source: String, 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. /// Refreshed list of tables in the database.
TablesRefreshed(Vec<String>), TablesRefreshed(Vec<String>),
+193 -5
View File
@@ -180,6 +180,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.unknown_topic", &["topic"]), ("help.unknown_topic", &["topic"]),
("help.app.quit", &[]), ("help.app.quit", &[]),
("help.app.help", &[]), ("help.app.help", &[]),
("help.app.hint", &[]),
("help.app.rebuild", &[]), ("help.app.rebuild", &[]),
("help.app.save", &[]), ("help.app.save", &[]),
("help.app.new", &[]), ("help.app.new", &[]),
@@ -222,6 +223,183 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
&["message", "usage"], &["message", "usage"],
), ),
("hint.ambient_expected", &["expected"]), ("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", "hint.ambient_invalid_ident",
&["kind", "found"], &["kind", "found"],
@@ -299,6 +477,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.usage.rename_column", &[]), ("parse.usage.rename_column", &[]),
("parse.usage.export", &[]), ("parse.usage.export", &[]),
("parse.usage.help", &[]), ("parse.usage.help", &[]),
("parse.usage.hint", &[]),
("parse.usage.import", &[]), ("parse.usage.import", &[]),
("parse.usage.copy", &[]), ("parse.usage.copy", &[]),
("parse.usage.load", &[]), ("parse.usage.load", &[]),
@@ -446,6 +625,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("undo.redo_failed", &["error"]), ("undo.redo_failed", &["error"]),
// ---- Status bar + panels ---- // ---- Status bar + panels ----
("panel.hint_empty", &[]), ("panel.hint_empty", &[]),
("panel.hint_mode_advanced", &[]),
("panel.hint_title", &[]), ("panel.hint_title", &[]),
("panel.output_title", &[]), ("panel.output_title", &[]),
("panel.relationships_empty", &[]), ("panel.relationships_empty", &[]),
@@ -462,18 +642,26 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("save.title_as", &[]), ("save.title_as", &[]),
("save.title_save", &[]), ("save.title_save", &[]),
// ---- Shortcut hint labels ---- // ---- Shortcut hint labels ----
("shortcut.advanced_once", &[]),
("shortcut.back_to_list", &[]), ("shortcut.back_to_list", &[]),
("shortcut.browse", &[]),
("shortcut.browse_path", &[]), ("shortcut.browse_path", &[]),
("shortcut.cancel", &[]), ("shortcut.cancel", &[]),
("shortcut.cancel_one_shot", &[]), ("shortcut.clear", &[]),
("shortcut.complete", &[]),
("shortcut.confirm", &[]), ("shortcut.confirm", &[]),
("shortcut.cycle", &[]),
("shortcut.del_word", &[]),
("shortcut.hint", &[]),
("shortcut.history", &[]),
("shortcut.home_end", &[]),
("shortcut.load", &[]), ("shortcut.load", &[]),
("shortcut.nav", &[]),
("shortcut.next_pane", &[]),
("shortcut.no", &[]), ("shortcut.no", &[]),
("shortcut.quit", &[]), ("shortcut.run", &[]),
("shortcut.scroll", &[]),
("shortcut.select", &[]), ("shortcut.select", &[]),
("shortcut.submit", &[]), ("shortcut.to_input", &[]),
("shortcut.switch", &[]),
("shortcut.yes", &[]), ("shortcut.yes", &[]),
// ---- mode / messages banners ---- // ---- mode / messages banners ----
("messages.set_short", &[]), ("messages.set_short", &[]),
+1 -1
View File
@@ -35,7 +35,7 @@ pub mod translate;
pub use error::{DiagnosticTable, FriendlyError}; pub use error::{DiagnosticTable, FriendlyError};
pub use format::{catalog, Catalog}; 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 // `translate::translate` and `format::translate` are different
// callables — the former is the structured DbError → FriendlyError // callables — the former is the structured DbError → FriendlyError
+278 -5
View File
@@ -256,6 +256,8 @@ help:
help: |- help: |-
help — show this command list help — show this command list
help <command> — detailed help for one command (e.g. `help insert`) 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 — rebuild the project database from project.yaml + data/ (with confirmation) rebuild — rebuild the project database from project.yaml + data/ (with confirmation)
save: |- save: |-
@@ -386,6 +388,259 @@ hint:
ambient_complete: "Submit with Enter" ambient_complete: "Submit with Enter"
ambient_expected: "Next: {expected}" ambient_expected: "Next: {expected}"
ambient_error_with_usage: "{message} — usage: {usage}" ambient_error_with_usage: "{message} — usage: {usage}"
# H2 / ADR-0053: shown by `hint` / F1 when there is nothing specific
# to expand on (no recent error, empty input).
getting_started: "Start typing a command and press F1 for a hint, or type `help` for the full command list."
# Tier-3 block scaffolding (ADR-0053 D4): the heading + the labels the
# `what` / `example` / `concept` parts render under.
block:
heading: "Hint"
what: "What"
example: "Example"
concept: "Concept"
# ── Tier-3 teaching blocks (ADR-0053 D3) ──────────────────────────
# Per-form command hints (`hint.cmd.<form>`) and per-class error
# hints (`hint.err.<class>`), each a `what` (12 sentences) / `example`
# (one runnable, mode-correct line) / `concept` (the relational idea —
# the teaching part). Phase B seeds the three approved exemplars; the
# rest are authored in Phase C.
cmd:
insert:
what: "Add one or more rows to a table."
example: "insert into Customers values ('Ann', 'ann@example.io')"
concept: "A row is one record; each value lines up with a column, in order. Columns typed `serial`/`shortid` fill themselves — leave them out."
add_relationship:
what: "Link two tables so a parent row can own many child rows."
example: "add 1:n relationship from Customers.id to Orders.customer_id"
concept: "The \"1:n\" means one parent, many children. The child column holds the foreign key; add `--create-fk` to create that column if it doesn't exist yet."
# App-lifecycle commands (Phase C batch 1). Reference-leaning, so
# `concept` appears only where there's a real idea to teach.
quit:
what: "Leave the playground. Your project is already saved to disk."
example: "quit"
help:
what: "List every command, or show the detail for one."
example: "help insert"
concept: "`help` is the reference; press F1 while typing for a hint about the command you're building right now."
hint:
what: "Explain the most recent error — or, pressing F1 while typing, the command you're building."
example: "hint"
rebuild:
what: "Rebuild the project database from its saved text files."
example: "rebuild"
concept: "The text files (project.yaml + the data folder) are the source of truth; the database is derived and can always be rebuilt from them."
save:
what: "Save the current project under a name; `save as` copies it to a new one."
example: "save as my-shop"
new:
what: "Close the current project and start a fresh temporary one."
example: "new"
load:
what: "Open the project picker to switch to a saved project."
example: "load"
export:
what: "Write a shareable zip of the project — its text files only, never the database."
example: "export my-shop.zip"
concept: "The zip carries the schema and data as text, so anyone can rebuild the very same database from it."
import:
what: "Unpack a project zip into a new project and switch to it."
example: "import my-shop.zip as shop-copy"
mode:
what: "Switch between simple mode (the guided teaching commands) and advanced mode (raw SQL)."
example: "mode advanced"
concept: "Simple mode uses keyword commands; advanced mode lets you write SQL directly. A leading `:` runs a single advanced command without switching modes."
messages:
what: "Show or set how much detail error messages give."
example: "messages short"
concept: "Verbose (the default) adds a fix-it hint under each error headline; short shows just the headline."
undo:
what: "Undo the most recent change, after a confirmation."
example: "undo"
concept: "Every data or schema change is snapshotted first, so you can step back; `redo` re-applies what you undid."
redo:
what: "Re-apply the most recently undone change."
example: "redo"
copy:
what: "Copy the output panel to the clipboard — all of it, or just the last command's output."
example: "copy last"
# DDL — schema-shaping commands (Phase C batch 2).
create_table:
what: "Create a new table — its columns, their types, and a primary key."
example: "create table Customers with pk id(serial), name(text), email(text)"
concept: "A table is a set of rows that share the same columns. The primary key uniquely identifies each row; a `serial` key numbers the rows for you."
create_m2n:
what: "Create a junction table linking two tables many-to-many."
example: "create m:n relationship from Students to Courses"
concept: "A many-to-many link (a student takes many courses; a course has many students) can't live in either table, so it gets its own junction table holding a foreign key to each side."
add_column:
what: "Add a new column to an existing table."
example: "add column Customers: phone (text)"
concept: "Existing rows take the column's default, or null. A `not null` column with no default can't be added to a table that already has rows — there'd be nothing to put in them."
add_index:
what: "Create an index on one or more columns to speed up lookups."
example: "add index as idx_email on Customers (email)"
concept: "An index is a sorted side-structure that makes a lookup like `where email = …` fast, at the cost of a little space and slightly slower writes."
add_constraint:
what: "Add a constraint — not null, unique, default, or check — to an existing column."
example: "add constraint not null to Customers.email"
concept: "A constraint is a rule the database enforces on every row. Adding one fails if existing rows already break it, so you fix the data first."
drop_table:
what: "Remove a table and all of its rows."
example: "drop table Customers"
concept: "If other tables reference this one through a relationship, drop those relationships (or their child rows) first — the database won't orphan them."
drop_column:
what: "Remove a column from a table."
example: "drop column Customers: phone"
concept: "The column's values are lost. You can't drop a primary-key column, or one a relationship depends on."
drop_relationship:
what: "Remove a relationship between two tables."
example: "drop relationship customer_orders"
concept: "This drops the foreign-key link and stops the database enforcing it; the tables and their rows stay. The foreign-key column itself remains unless you also drop it."
drop_index:
what: "Remove an index by name."
example: "drop index idx_email"
concept: "Only the lookup shortcut goes — the data is untouched. Queries still work, just without that speed-up."
drop_constraint:
what: "Remove a constraint from a column."
example: "drop constraint not null from Customers.email"
concept: "The rule stops being enforced from now on; rows already stored are left as they are."
rename_column:
what: "Rename a column, keeping its values and type."
example: "rename column Customers: email to contact_email"
concept: "Only the name changes — the stored data is the same. References to the column are reconciled so nothing breaks."
change_column:
what: "Change a column's type, converting the existing values."
example: "change column Customers: status (int)"
concept: "The database converts each stored value to the new type; if a value can't convert it refuses the change, so you don't silently lose data. Flags let you force or skip the conversion."
# DML — querying and changing data (Phase C batch 3).
update:
what: "Change values in the rows that match a condition."
example: "update Customers set email = 'new@example.io' where id = 1"
concept: "The `where` clause picks which rows change, and it's required — pass `--all-rows` to change the whole table on purpose — so you never update more than you meant to."
delete:
what: "Remove the rows that match a condition."
example: "delete from Orders where status = 'cancelled'"
concept: "A `where` is required (use `--all-rows` to clear the table on purpose). Rows a relationship points at may be blocked or cascade-deleted, per its `on delete` action."
show_data:
what: "Show the rows stored in a table."
example: "show data Customers"
concept: "This reads the data and never changes it. Add a `where` to show only matching rows."
show_table:
what: "Show a table's structure — its columns, types, keys, and relationships."
example: "show table Customers"
concept: "Structure, not data: the column definitions and how this table links to others. Use `show data` to see the rows themselves."
show_tables:
what: "List all the tables in the project."
example: "show tables"
show_relationships:
what: "List all the relationships between tables."
example: "show relationships"
concept: "Each relationship is a foreign-key link from a child column to a parent's key, with an `on delete` / `on update` rule."
show_indexes:
what: "List all the indexes in the project."
example: "show indexes"
concept: "Indexes speed up lookups; this shows which columns each one covers and whether it enforces uniqueness."
seed:
what: "Fill a table with generated sample rows, or fill one column on existing rows."
example: "seed Customers 50"
concept: "Seeding invents realistic-looking data so you have something to query. Pin a value with `set col = …`, choose a generator with `as`, or give a numeric range with `between`."
explain:
what: "Show how the database will run a query — without running it."
example: "explain show data Customers where email = 'a@example.io'"
concept: "The plan reveals whether the database scans the whole table or jumps straight to rows through an index — the payoff of `add index`. `explain` never executes, so it's safe even on a delete."
replay:
what: "Re-run the commands recorded in a history file."
example: "replay session.log"
concept: "Every successful command is journalled, so replaying re-applies them in order to reproduce a project's state — handy for scripting or redoing a sequence."
# Advanced-mode SQL forms (Phase C batch 4). Examples are SQL, the
# advanced surface — distinct from their simple-mode siblings.
sql_create_table:
what: "Create a table using SQL syntax (advanced mode)."
example: "create table Customers (id int primary key, name text, email text)"
concept: "Advanced mode speaks SQL: constraints go inline (`primary key`, `not null`, `unique`, `check`). This is the raw form of simple mode's `create table … with pk …`."
sql_alter_table:
what: "Change a table's structure with SQL `alter table` (advanced mode)."
example: "alter table Customers add column phone text"
concept: "`alter table` adds or drops columns, renames, and adds constraints — the SQL equivalent of simple mode's `add column` / `drop column` / `change column`."
sql_create_index:
what: "Create an index with SQL (advanced mode)."
example: "create index ix_email on Customers (email)"
concept: "Add `unique` to also forbid duplicate values. The simple-mode equivalent is `add index`."
sql_drop_index:
what: "Remove an index with SQL (advanced mode)."
example: "drop index ix_email"
concept: "Only the lookup shortcut goes; the data is untouched. Add `if exists` to ignore a missing index."
sql_drop_table:
what: "Remove a table with SQL (advanced mode)."
example: "drop table Customers"
concept: "Add `if exists` to avoid an error when the table might not be there. Relationships pointing at it may block the drop."
sql_insert:
what: "Insert rows with SQL (advanced mode)."
example: "insert into Customers (name, email) values ('Ann', 'ann@example.io')"
concept: "Naming the columns lets you supply them in any order and skip ones that have a default — the SQL form of simple mode's `insert`."
sql_update:
what: "Update rows with SQL (advanced mode)."
example: "update Customers set email = 'new@example.io' where id = 1"
concept: "`set` lists the new values; `where` picks which rows change. The SQL form of simple mode's `update`."
sql_delete:
what: "Delete rows with SQL (advanced mode)."
example: "delete from Orders where status = 'cancelled'"
concept: "`where` picks the rows to remove; foreign-key rules still apply. The SQL form of simple mode's `delete`."
select:
what: "Query rows with SQL `select` (advanced mode)."
example: "select name, email from Customers where id = 1"
concept: "`select` is read-only: choose columns (or `*`), filter with `where`, sort with `order by`, cap with `limit`. This is the heart of SQL — and the reason advanced mode exists."
with:
what: "Name a sub-query (a CTE) and read from it in a `select` (advanced mode)."
example: "with recent as (select * from Orders where id > 100) select * from recent"
concept: "A `with` clause (Common Table Expression) names a query so the main `select` can use it like a temporary table — handy for breaking a complex query into readable steps."
explain_sql:
what: "Show how the database will run a SQL query, without running it (advanced mode)."
example: "explain select * from Customers where email = 'a@example.io'"
concept: "Like simple mode's `explain`, but wraps a raw SQL statement. It reveals whether an index is used, and never executes."
err:
# Runtime error classes (Phase C batch 5), keyed by
# friendly::error_hint_class. `example` is a fix recipe rather than a
# runnable line; `concept` is the relational idea behind the rule.
foreign_key:
child_side:
what: "The value you gave for the child column doesn't match any parent row, so the foreign key has nothing to point at."
example: "First insert the parent (insert into Customers …), then the child that references it."
concept: "A foreign key is a promise that every child points at a real parent, so the parent must exist first. To allow orphans on delete instead, set the relationship's `on delete` to `set null` or `cascade`."
parent_side:
what: "You're deleting or changing a row that other rows point at, which would orphan those children."
example: "Delete the child rows first, or set the relationship's `on delete` to `cascade` (remove them too) or `set null` (keep them, unlinked)."
concept: "A foreign key guarantees every child has a real parent, so the database won't remove a parent out from under its children unless the relationship says what should happen to them."
unique:
what: "A value you're inserting — or updating to — already exists in a column that must be unique."
example: "Pick a different value, or update the existing row instead of inserting a new one."
concept: "A unique constraint (and every primary key) forbids duplicates, so each value identifies at most one row."
not_null:
what: "You left a column empty that is required to have a value."
example: "Supply a value for the column, or give it a default so new rows fill it automatically."
concept: "A `not null` constraint means every row must have a value there — it's how you mark a fact as mandatory."
check:
what: "A value broke a `check` rule defined on the column."
example: "Use a value the rule allows — for example a positive number, or one of the permitted options."
concept: "A `check` constraint is a condition every row must satisfy, so the database enforces business rules like \"price ≥ 0\" for you."
type_mismatch:
what: "A value doesn't fit the column's type — for instance text where a number is expected."
example: "Give a value of the right type: a number for `int`/`real`, a quoted string for `text`, true/false for `bool`."
concept: "Every column has a type, and the database rejects values that don't fit, so a column's data stays consistent and comparable."
not_found:
what: "You named a table or column that doesn't exist."
example: "Check the spelling, or run `show tables` (or `show table <name>`) to see what's there."
concept: "A command can only refer to tables and columns that already exist — create them first if you need them."
already_exists:
what: "You tried to create a table, column, relationship, or index whose name is already taken."
example: "Pick a different name, or drop the existing one first if you meant to replace it."
concept: "Names must be unique within their kind so a command is never ambiguous about what it refers to."
generic:
what: "The database refused the command for the reason shown above."
example: "Read that message for the specifics, adjust the command, and try again."
invalid_value:
what: "A value or option in the command wasn't valid for where it was used."
example: "Check the value against the column's type and the command's accepted options."
# Invalid identifier in a schema slot (ADR-0022 stage 8e # Invalid identifier in a schema slot (ADR-0022 stage 8e
# + the user's #5). Voice mirrors ADR-0019's "no such # + the user's #5). Voice mirrors ADR-0019's "no such
# {kind}" wording for consistency with engine errors. # {kind}" wording for consistency with engine errors.
@@ -617,6 +872,7 @@ parse:
# description. # description.
quit: "quit" quit: "quit"
help: "help [<command>]" help: "help [<command>]"
hint: "hint"
rebuild: "rebuild" rebuild: "rebuild"
save: "save | save as" save: "save | save as"
new: "new" new: "new"
@@ -883,14 +1139,21 @@ panel:
relationships_title: "Relationships" relationships_title: "Relationships"
relationships_empty: "(none)" relationships_empty: "(none)"
hint_empty: "Type a command — press Tab for options, `help` for a list" 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 # Panel titles for the output and hint panels (rendered inside
# the rounded border, hence the leading/trailing space). # the rounded border, hence the leading/trailing space).
output_title: "Output" output_title: "Output"
hint_title: "Hint" hint_title: "Hint"
# ---- Shortcut hints (paired with key names in the bottom bar) ------- # ---- 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: shortcut:
submit: "submit"
confirm: "confirm" confirm: "confirm"
cancel: "cancel" cancel: "cancel"
yes: "Yes" yes: "Yes"
@@ -899,10 +1162,20 @@ shortcut:
select: "select" select: "select"
browse_path: "browse path" browse_path: "browse path"
back_to_list: "back to list" back_to_list: "back to list"
switch: "switch" # Status-strip labels (ADR-0051, issue #27).
advanced_once: "advanced once" run: "run"
cancel_one_shot: "cancel one-shot" nav: "sidebar"
quit: "quit" 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 / messages banners (app-level commands) -------------------
mode: mode:
+153
View File
@@ -253,6 +253,73 @@ pub fn translate(error: &DbError, ctx: &TranslateContext) -> FriendlyError {
fe 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( fn translate_sqlite(
message: &str, message: &str,
kind: SqliteErrorKind, 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 { fn sqlite(message: &str, kind: SqliteErrorKind) -> DbError {
DbError::Sqlite { DbError::Sqlite {
message: message.to_string(), message: message.to_string(),
+112 -4
View File
@@ -28,7 +28,35 @@ use super::PersistenceError;
pub(super) const STATUS_OK: &str = "ok"; pub(super) const STATUS_OK: &str = "ok";
pub(super) const STATUS_ERR: &str = "err"; 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 { pub(super) fn format_record(command_text: &str, timestamp_iso: String) -> String {
format_record_with_status(command_text, timestamp_iso, STATUS_OK) 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. // characters) is preserved.
let mut parts = line.splitn(3, '|'); let mut parts = line.splitn(3, '|');
let _ts = parts.next()?; let _ts = parts.next()?;
let _status = parts.next()?; let status = parts.next()?;
let source = 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 /// 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) { if !looks_like_iso8601(ts) {
return None; 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 { Some(JournalRecord {
status_is_ok: status == STATUS_OK, status_is_ok,
source: unescape_command(source), source: unescape_command(source),
}) })
} }
@@ -436,4 +478,70 @@ mod tests {
let body = fs::read_to_string(&path).unwrap(); let body = fs::read_to_string(&path).unwrap();
assert_eq!(body, "first|ok|a\nsecond|ok|b\n"); assert_eq!(body, "first|ok|a\nsecond|ok|b\n");
} }
// ---- ADR-0052 (issue #30): mode tag in the status field ----
#[test]
fn status_token_builds_and_parses_the_adv_suffix() {
assert_eq!(status_token(STATUS_OK, false), "ok");
assert_eq!(status_token(STATUS_OK, true), "ok:adv");
assert_eq!(status_token(STATUS_ERR, true), "err:adv");
assert_eq!(parse_status("ok"), (true, false));
assert_eq!(parse_status("ok:adv"), (true, true));
assert_eq!(parse_status("err"), (false, false));
assert_eq!(parse_status("err:adv"), (false, true));
// Unknown base → not ok (replay skips it), simple.
assert_eq!(parse_status("frobnicate"), (false, false));
}
#[test]
fn read_recent_sources_reconstructs_colon_prefix_for_advanced() {
// An advanced record (`ok:adv`) hydrates in its `:`-prefixed
// simple-mode runnable form; a simple record stays bare. This is
// the cross-session half of the issue #30 fix.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("history.log");
let adv = format_record_with_status(
"select * from T",
"2026-06-13T10:00:00Z".to_string(),
&status_token(STATUS_OK, true),
);
let simple = format_record_with_status(
"create table T with pk",
"2026-06-13T10:00:01Z".to_string(),
&status_token(STATUS_OK, false),
);
std::fs::write(&path, format!("{adv}{simple}")).unwrap();
let got = read_recent_sources(&path, 10).unwrap();
assert_eq!(
got,
vec![
": select * from T".to_string(),
"create table T with pk".to_string(),
],
);
}
#[test]
fn parse_journal_record_treats_ok_adv_as_ok() {
// Replay keys off the base token, so `ok:adv` replays like `ok`
// (source stays canonical).
let rec = parse_journal_record("2026-06-13T10:00:00Z|ok:adv|select * from T")
.expect("ok:adv journal record");
assert!(rec.status_is_ok);
assert_eq!(rec.source, "select * from T");
let err = parse_journal_record("2026-06-13T10:00:00Z|err:adv|select bad")
.expect("err:adv journal record");
assert!(!err.status_is_ok);
}
#[test]
fn old_three_field_log_reads_as_simple() {
// Back-compat: a pre-ADR-0052 log (no `:adv`) hydrates bare.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("history.log");
std::fs::write(&path, "2026-01-01T00:00:00Z|ok|select 1\n").unwrap();
let got = read_recent_sources(&path, 10).unwrap();
assert_eq!(got, vec!["select 1".to_string()]);
}
} }
+32 -9
View File
@@ -395,11 +395,26 @@ impl Persistence {
} }
} }
/// Append one successful-command record to `history.log`. /// Append one successful-command record to `history.log`. `advanced`
pub fn append_history(&self, command_text: &str) -> Result<(), PersistenceError> { /// (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 path = self.project_path.join(HISTORY_LOG);
let line = history::format_record(command_text, history::utc_iso8601_now()); let status = history::status_token(history::STATUS_OK, advanced);
debug!(len = command_text.len(), "persist: append ok record to history.log"); 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) history::append(&path, &line)
} }
@@ -410,14 +425,22 @@ impl Persistence {
/// transactional `ok` journal). Best-effort at the call site: /// transactional `ok` journal). Best-effort at the call site:
/// a failure to record a failure must never escalate a user /// a failure to record a failure must never escalate a user
/// error into a fatal (ADR-0034 §4). /// 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 path = self.project_path.join(HISTORY_LOG);
let status = history::status_token(history::STATUS_ERR, advanced);
let line = history::format_record_with_status( let line = history::format_record_with_status(
command_text, command_text,
history::utc_iso8601_now(), 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) history::append(&path, &line)
} }
@@ -577,8 +600,8 @@ mod tests {
fn append_history_creates_and_appends() { fn append_history_creates_and_appends() {
let dir = tempdir(); let dir = tempdir();
let p = Persistence::new(dir.path().to_path_buf()); let p = Persistence::new(dir.path().to_path_buf());
p.append_history("create table Foo with pk id(serial)").unwrap(); p.append_history("create table Foo with pk id(serial)", false).unwrap();
p.append_history("insert into Foo (1)").unwrap(); p.append_history("insert into Foo (1)", false).unwrap();
let body = fs::read_to_string(dir.path().join(HISTORY_LOG)).unwrap(); let body = fs::read_to_string(dir.path().join(HISTORY_LOG)).unwrap();
let lines: Vec<&str> = body.trim_end().lines().collect(); let lines: Vec<&str> = body.trim_end().lines().collect();
assert_eq!(lines.len(), 2); assert_eq!(lines.len(), 2);
+61 -22
View File
@@ -479,17 +479,19 @@ async fn run_loop(
command, command,
source, source,
submission_mode, 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 // ADR-0034 §1/§4: record a failed command as an
// `err` record. Best-effort — a failure to record // `err` record (ADR-0052: `err:adv` when advanced).
// a failure must never escalate a user error into // Best-effort — a failure to record a failure must
// a fatal, so the result is logged and ignored. // never escalate a user error into a fatal, so the
// result is logged and ignored.
if let Err(e) = crate::persistence::Persistence::new( if let Err(e) = crate::persistence::Persistence::new(
session.project().path().to_path_buf(), 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)"); 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 // history.log. The worker's persistence is wired but not
// directly addressable from here, so we use a fresh // directly addressable from here, so we use a fresh
// Persistence handle for this single line. // 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 // Update the resume pointer so the next `--resume` launch
// reopens the project we just switched to — unless it is a // reopens the project we just switched to — unless it is a
@@ -1040,7 +1044,9 @@ fn spawn_export(
source: String, source: String,
event_tx: mpsc::Sender<AppEvent>, 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 { tokio::spawn(async move {
let outcome = tokio::task::spawn_blocking(move || { let outcome = tokio::task::spawn_blocking(move || {
do_export(&project_path, &project_name, &data_root, target.as_deref()) 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 // miss leaves that table's columns unpopulated and the
// walker falls back to the schemaless value-literal list. // walker falls back to the schemaless value-literal list.
for name in cache.tables.clone() { 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). // Per-table indexes for the items panel (S2, ADR-0025).
// Carry uniqueness so the panel can mark a UNIQUE index // Carry uniqueness so the panel can mark a UNIQUE index
// (ADR-0035 §4d). Captured before `desc.columns` is // (ADR-0035 §4d). Captured before `desc.columns` is
@@ -1295,11 +1301,20 @@ fn spawn_rebuild(
source: String, source: String,
) { ) {
tokio::spawn(async move { tokio::spawn(async move {
let source_for_journal = source.clone();
match database match database
.rebuild_from_text(project_path.clone(), Some(source)) .rebuild_from_text(project_path.clone(), Some(source))
.await .await
{ {
Ok(()) => { 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) let summary = summarize_project(&project_path)
.unwrap_or_else(|_| "rebuild complete".to_string()); .unwrap_or_else(|_| "rebuild complete".to_string());
let _ = event_tx let _ = event_tx
@@ -1407,10 +1422,11 @@ fn spawn_dsl_dispatch(
command: Command, command: Command,
source: String, source: String,
submission_mode: crate::app::EffectiveMode, submission_mode: crate::app::EffectiveMode,
project_path: std::path::PathBuf,
) { ) {
tokio::spawn(async move { tokio::spawn(async move {
// Retain the source for `DslFailed` so the App can journal a // Retain the source for journaling (ADR-0034 §1/§2; ADR-0052
// rejected command as `err` (ADR-0034 §1/§2). // moved success journaling here, next to the failure path).
let source_for_journal = source.clone(); let source_for_journal = source.clone();
// ADR-0038: the DSL → SQL teaching echo fires for a DSL-form // ADR-0038: the DSL → SQL teaching echo fires for a DSL-form
// command submitted in an advanced effective mode (ADR-0037). // 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 lookups = collect_echo_lookups(&database, &command, submission_mode).await;
let echo = crate::echo::echo_for(&command, submission_mode); let echo = crate::echo::echo_for(&command, submission_mode);
let outcome = execute_command_typed(&database, command.clone(), source).await; 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 { let event = match outcome {
Ok(CommandOutcome::Schema(description)) => { Ok(CommandOutcome::Schema(description)) => {
let schema_echo = build_schema_echo( let schema_echo = build_schema_echo(
@@ -1569,6 +1598,7 @@ fn spawn_dsl_dispatch(
error, error,
facts, facts,
source: source_for_journal, source: source_for_journal,
advanced: submission_mode.is_advanced(),
} }
} }
}; };
@@ -1620,7 +1650,7 @@ async fn build_show_data_echo(
limit: Some(_), limit: Some(_),
.. ..
} => database } => database
.describe_table(name.clone(), None) .describe_table(name.clone())
.await .await
.map(|desc| { .map(|desc| {
desc.columns desc.columns
@@ -1702,7 +1732,7 @@ async fn collect_echo_lookups(
Command::DropIndex { Command::DropIndex {
selector: IndexSelector::Columns { table, columns }, 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) && let Some(idx) = desc.indexes.iter().find(|i| i.columns == *columns)
{ {
out.drop_index_name = Some(idx.name.clone()); out.drop_index_name = Some(idx.name.clone());
@@ -1717,7 +1747,7 @@ async fn collect_echo_lookups(
child_column, 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| { && let Some(rel) = desc.outbound_relationships.iter().find(|r| {
// The Endpoints drop selector is single-column // The Endpoints drop selector is single-column
// (ADR-0043 keeps DROP by-endpoints 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. // resolver API would be the next step if schemas grow.
if let Ok(tables) = database.list_tables().await { if let Ok(tables) = database.list_tables().await {
for table in tables { 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) && desc.outbound_relationships.iter().any(|r| r.name == *name)
{ {
out.drop_relationship = Some((name.clone(), table.clone())); 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 // *before* execution to know which `ADD COLUMN` lines to
// emit. The parent columns here are the explicit DSL list, // emit. The parent columns here are the explicit DSL list,
// paired positionally with the child list. // paired positionally with the child list.
let parent_desc = database.describe_table(parent_table.clone(), None).await; let parent_desc = database.describe_table(parent_table.clone()).await;
let child_desc = database.describe_table(child_table.clone(), None).await; let child_desc = database.describe_table(child_table.clone()).await;
if let (Ok(parent_desc), Ok(child_desc)) = (parent_desc, child_desc) { if let (Ok(parent_desc), Ok(child_desc)) = (parent_desc, child_desc) {
let mut new_columns: Vec<(String, crate::dsl::types::Type)> = Vec::new(); let mut new_columns: Vec<(String, crate::dsl::types::Type)> = Vec::new();
for (child_col, parent_col) in child_columns.iter().zip(parent_columns) { for (child_col, parent_col) in child_columns.iter().zip(parent_columns) {
@@ -2034,7 +2064,7 @@ async fn enrich_check_violation(
.await .await
.map(|v| v.to_string()); .map(|v| v.to_string());
// The rule itself — the column's compiled CHECK expression. // 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) && let Some(col) = desc.columns.iter().find(|c| c.name == column)
{ {
facts.check_rule.clone_from(&col.check); facts.check_rule.clone_from(&col.check);
@@ -2242,7 +2272,7 @@ async fn user_value_for_column_with_schema(
} = command } = command
{ {
let desc = database let desc = database
.describe_table(table.to_string(), None) .describe_table(table.to_string())
.await .await
.ok()?; .ok()?;
// Build the natural-order column list the same way // 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 && literal_rows.len() == 1
{ {
let desc = database let desc = database
.describe_table(table.to_string(), None) .describe_table(table.to_string())
.await .await
.ok()?; .ok()?;
let idx = desc.columns.iter().position(|c| c.name == column)?; 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; execute_command_typed(database, command, command_text.clone()).await;
match outcome { match outcome {
Ok(_) => { 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; count += 1;
} }
Err(DbError::PersistenceFatal { Err(DbError::PersistenceFatal {
@@ -2891,7 +2930,7 @@ async fn execute_command_typed(
.await .await
.map(|d| CommandOutcome::Schema(Some(d))), .map(|d| CommandOutcome::Schema(Some(d))),
Command::ShowTable { name } => database Command::ShowTable { name } => database
.describe_table(name, src) .describe_table(name)
.await .await
.map(|d| CommandOutcome::Schema(Some(d))), .map(|d| CommandOutcome::Schema(Some(d))),
// ADR-0044: a named relationship renders as a diagram (App-side), // ADR-0044: a named relationship renders as a diagram (App-side),
@@ -2944,14 +2983,14 @@ async fn execute_command_typed(
filter, filter,
limit, limit,
} => database } => database
.query_data(name, filter, limit, src) .query_data(name, filter, limit)
.await .await
.map(CommandOutcome::Query), .map(CommandOutcome::Query),
// A SQL `SELECT` (advanced mode; ADR-0030 §6, ADR-0031). // A SQL `SELECT` (advanced mode; ADR-0030 §6, ADR-0031).
// The grammar walker has already validated `sql` is in // The grammar walker has already validated `sql` is in
// the supported subset; the worker runs it as text. // the supported subset; the worker runs it as text.
Command::Select { sql } => database Command::Select { sql } => database
.run_select(sql, src) .run_select(sql)
.await .await
.map(CommandOutcome::Query), .map(CommandOutcome::Query),
// A SQL `INSERT` (advanced mode; ADR-0033 §1). Grammar-as- // A SQL `INSERT` (advanced mode; ADR-0033 §1). Grammar-as-
@@ -0,0 +1,9 @@
---
source: src/app.rs
assertion_line: 5844
expression: block
---
Hint
What: Add one or more rows to a table.
Example: insert into Customers values ('Ann', 'ann@example.io')
Concept: A row is one record; each value lines up with a column, in order. Columns typed `serial`/`shortid` fill themselves — leave them out.
@@ -1,6 +1,6 @@
--- ---
source: src/ui.rs source: src/ui.rs
assertion_line: 2326 assertion_line: 2839
expression: snapshot expression: snapshot
--- ---
╭ Output ──────────────────────────────────────────────────────────────────────╮ ╭ Output ──────────────────────────────────────────────────────────────────────╮
@@ -26,4 +26,4 @@ expression: snapshot
│ │ │ │
╰──────────────────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner 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 source: src/ui.rs
assertion_line: 2309 assertion_line: 2822
expression: snapshot expression: snapshot
--- ---
╭ Output ──────────────────────────────────────────────────────────────────────╮ ╭ Output ──────────────────────────────────────────────────────────────────────╮
@@ -22,8 +22,8 @@ expression: snapshot
│ │ │ │
╰──────────────────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
╭ Hint ────────────────────────────────────────────────────────────────────────╮ ╭ 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 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 source: src/ui.rs
assertion_line: 2317 assertion_line: 2830
expression: snapshot expression: snapshot
--- ---
╭ Output ──────────────────────────────────────────────────────────────────────╮ ╭ Output ──────────────────────────────────────────────────────────────────────╮
@@ -22,8 +22,8 @@ expression: snapshot
│ │ │ │
╰──────────────────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
╭ Hint ────────────────────────────────────────────────────────────────────────╮ ╭ 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 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 source: src/ui.rs
assertion_line: 3445
expression: snapshot expression: snapshot
--- ---
╭ Output ────────────────────────────────────────────────────────────────────────────────╮ ╭ Output ────────────────────────────────────────────────────────────────────────────────╮
@@ -23,8 +24,8 @@ expression: snapshot
│ │ │ │
╰────────────────────────────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ ╭ 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 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 source: src/ui.rs
assertion_line: 3391
expression: snapshot expression: snapshot
--- ---
╭ Output ────────────────────────────────────────────────────────────────────────────────╮ ╭ Output ────────────────────────────────────────────────────────────────────────────────╮
@@ -23,8 +24,8 @@ expression: snapshot
│ │ │ │
╰────────────────────────────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ ╭ 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 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 source: src/ui.rs
assertion_line: 3381
expression: snapshot expression: snapshot
--- ---
╭ Output ────────────────────────────────────────────────────────────────────────────────╮ ╭ Output ────────────────────────────────────────────────────────────────────────────────╮
@@ -23,8 +24,8 @@ expression: snapshot
│ │ │ │
╰────────────────────────────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ ╭ 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 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 source: src/ui.rs
assertion_line: 3434
expression: snapshot expression: snapshot
--- ---
╭ Output ────────────────────────────────────────────────────────────────────────────────╮ ╭ Output ────────────────────────────────────────────────────────────────────────────────╮
@@ -23,8 +24,8 @@ expression: snapshot
│ │ │ │
╰────────────────────────────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ ╭ 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 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 source: src/ui.rs
assertion_line: 3460
expression: snapshot expression: snapshot
--- ---
╭ Output ────────────────────────────────────────────────────────────────────────────────╮ ╭ Output ────────────────────────────────────────────────────────────────────────────────╮
@@ -23,8 +24,8 @@ expression: snapshot
│ │ │ │
╰────────────────────────────────────────────────────────────────────────────────────────╯ ╰────────────────────────────────────────────────────────────────────────────────────────╯
╭ Hint ──────────────────────────────────────────────────────────────────────────────────╮ ╭ 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 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 source: src/ui.rs
assertion_line: 2369 assertion_line: 2882
expression: snapshot expression: snapshot
--- ---
╭ Output ──────────────────────────────────────────────────────────────────────╮ ╭ Output ──────────────────────────────────────────────────────────────────────╮
@@ -26,4 +26,4 @@ expression: snapshot
│insert into <Table> [(<col>[, ...])] [values] (<value>[, ...]) │ │insert into <Table> [(<col>[, ...])] [values] (<value>[, ...]) │
╰──────────────────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner Project: Term Planner
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit F1 hint · Esc clear · Ctrl-A/E home/end · Ctrl-W del word · Enter run
@@ -1,6 +1,6 @@
--- ---
source: src/ui.rs source: src/ui.rs
assertion_line: 2967 assertion_line: 3347
expression: snapshot expression: snapshot
--- ---
╭ Tables ───────────────────────────────────╮ ─────────────────────────────────╮ ╭ Tables ───────────────────────────────────╮ ─────────────────────────────────╮
@@ -22,8 +22,8 @@ expression: snapshot
╰───────────────────────────────────────────╯ │ ╰───────────────────────────────────────────╯ │
╭ Relationships ────────────────────────────╮ ─────────────────────────────────╯ ╭ Relationships ────────────────────────────╮ ─────────────────────────────────╯
│Customers_Orders │ ─────────────────────────────────╮ │Customers_Orders │ ─────────────────────────────────╮
│ Customers.id -> │ ` for a list │ Customers.id -> │ ` for a list · `mode advanced`
│ Orders.customer_id │ │ │ Orders.customer_id │ │
╰───────────────────────────────────────────╯ ─────────────────────────────────╯ ╰───────────────────────────────────────────╯ ─────────────────────────────────╯
Project: Term Planner 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 source: src/ui.rs
assertion_line: 2385 assertion_line: 2898
expression: snapshot expression: snapshot
--- ---
╭ Output ──────────────────────────────────────────────────────────────────────╮ ╭ Output ──────────────────────────────────────────────────────────────────────╮
@@ -26,4 +26,4 @@ expression: snapshot
│ │ │ │
╰──────────────────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner 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 source: src/ui.rs
assertion_line: 2679 assertion_line: 3102
expression: snapshot expression: snapshot
--- ---
╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮ ╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮
@@ -22,8 +22,8 @@ expression: snapshot
╰──────────────────────────╯│ │ ╰──────────────────────────╯│ │
╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯ ╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯
│(none) │╭ Hint ──────────────────────────────────────────────────────────────────────────╮ │(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 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 source: src/ui.rs
assertion_line: 2399 assertion_line: 2912
expression: snapshot expression: snapshot
--- ---
╭ Output ──────────────────────────────────────────────────────────────────────╮ ╭ Output ──────────────────────────────────────────────────────────────────────╮
@@ -22,8 +22,8 @@ expression: snapshot
│ │ │ │
╰──────────────────────────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────────────────────────╯
╭ Hint ────────────────────────────────────────────────────────────────────────╮ ╭ 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 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 source: src/ui.rs
assertion_line: 2789 assertion_line: 3212
expression: snapshot expression: snapshot
--- ---
╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮ ╭ Tables ──────────────────╮╭ Output ────────────────────────────────────────────────────────────────────────╮
@@ -22,8 +22,8 @@ expression: snapshot
╰──────────────────────────╯│ │ ╰──────────────────────────╯│ │
╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯ ╭ Relationships ───────────╮╰────────────────────────────────────────────────────────────────────────────────╯
│Customers_Orders │╭ Hint ──────────────────────────────────────────────────────────────────────────╮ │Customers_Orders │╭ Hint ──────────────────────────────────────────────────────────────────────────╮
│ Customers.id -> ││Type a command — press Tab for options, `help` for a list │ Customers.id -> ││Type a command — press Tab for options, `help` for a list · `mode advanced`
│ Orders.customer_id ││ │ Orders.customer_id ││for SQL
╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯ ╰──────────────────────────╯╰────────────────────────────────────────────────────────────────────────────────╯
Project: Term Planner 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 source: src/ui.rs
assertion_line: 2265 assertion_line: 2616
expression: snapshot expression: snapshot
--- ---
╭ Output ──────────────────────────────────────────────────╮ ╭ Output ──────────────────────────────────────────────────╮
@@ -46,4 +46,4 @@ expression: snapshot
│with `mode advanced`, or prefix the line with `:` to run… │ │with `mode advanced`, or prefix the line with `:` to run… │
╰──────────────────────────────────────────────────────────╯ ╰──────────────────────────────────────────────────────────╯
Project: Term Planner Project: Term Planner
Enter submit · : advanced once · mode advanced switch · F1 hint · Esc clear · Ctrl-A/E home/end · Ctrl-W del w
+237 -24
View File
@@ -1694,7 +1694,19 @@ fn resolve_hint_lines(
(None, Some(crate::input_render::AmbientHint::Candidates { items, selected })) => { (None, Some(crate::input_render::AmbientHint::Candidates { items, selected })) => {
vec![render_candidate_line(&items, selected, inner, theme)] vec![render_candidate_line(&items, selected, inner, theme)]
} }
(None, None) => prose(&crate::t!("panel.hint_empty")), // Empty input: the base prompt, plus — in simple mode only — a
// pointer to advanced mode (ADR-0051, issue #27), since the
// `mode advanced` switch left the keybinding strip. Advanced
// mode shows no pointer: users know how they reached it, and
// `help` covers the way back. (One-shot never reaches here — its
// `:` makes the input non-empty → ambient path.)
(None, None) => {
let mut text = crate::t!("panel.hint_empty");
if matches!(app.effective_mode(), EffectiveMode::Simple) {
text.push_str(&crate::t!("panel.hint_mode_advanced"));
}
prose(&text)
}
} }
} }
@@ -1845,6 +1857,66 @@ fn render_candidate_line(
Line::from(spans) 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) { fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
let key_style = Style::default() let key_style = Style::default()
.fg(theme.fg) .fg(theme.fg)
@@ -1855,35 +1927,14 @@ fn render_status_bar(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect
let separator = Span::styled(" · ", sep_style); let separator = Span::styled(" · ", sep_style);
let mut spans: Vec<Span<'_>> = Vec::new(); let mut spans: Vec<Span<'_>> = Vec::new();
for (key, label) in status_bar_bindings(app) {
let push_shortcut = |spans: &mut Vec<Span<'_>>, key: &'static str, label: &str| {
if !spans.is_empty() { if !spans.is_empty() {
spans.push(separator.clone()); spans.push(separator.clone());
} }
spans.push(Span::styled(key, key_style)); spans.push(Span::styled(key, key_style));
spans.push(Span::raw(" ")); spans.push(Span::raw(" "));
spans.push(Span::styled(label.to_string(), label_style)); spans.push(Span::styled(label, 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);
}
} }
push_shortcut(&mut spans, "Ctrl-C", &quit);
let paragraph = Paragraph::new(Line::from(spans)).style(bar_style); let paragraph = Paragraph::new(Line::from(spans)).style(bar_style);
frame.render_widget(paragraph, area); frame.render_widget(paragraph, area);
@@ -2582,6 +2633,168 @@ mod tests {
.expect("hint bottom border present") .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] #[test]
fn clamp_wrapped_truncates_with_ellipsis_past_max() { fn clamp_wrapped_truncates_with_ellipsis_past_max() {
// ≤ max rows: untouched. // ≤ max rows: untouched.
+5 -5
View File
@@ -93,7 +93,7 @@ fn rename_column_with_case_variant_table_keeps_metadata_in_step() {
.expect("rename column via a case-variant table name"); .expect("rename column via a case-variant table name");
let desc = r let desc = r
.block_on(db.describe_table("Items".to_string(), None)) .block_on(db.describe_table("Items".to_string()))
.expect("describe Items"); .expect("describe Items");
let amount = desc let amount = desc
.columns .columns
@@ -126,7 +126,7 @@ fn insert_with_case_variant_table_persists_and_survives_rebuild() {
let db = fresh_rebuild(db, &project, &r); let db = fresh_rebuild(db, &project, &r);
let rows = 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") .expect("query")
.rows; .rows;
assert_eq!(rows.len(), 1, "the wrong-case insert survived the rebuild (no data loss)"); 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 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"); 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"); 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). // 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", add 1:n relationship from parent.id to child.parent_id\n",
); );
// The parent's inbound relationship is visible under the stored case. // 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.len(), 1, "relationship recorded under the stored case");
assert_eq!(p.inbound_relationships[0].other_table, "Child"); assert_eq!(p.inbound_relationships[0].other_table, "Child");
let db = fresh_rebuild(db, &project, &r); 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.len(), 1, "relationship survived the rebuild");
assert_eq!(p.inbound_relationships[0].other_table, "Child"); assert_eq!(p.inbound_relationships[0].other_table, "Child");
} }
+5 -5
View File
@@ -276,7 +276,7 @@ fn compound_fk_declares_enforces_and_round_trips() {
); );
// describe shows the compound endpoints symmetrically. // 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]; let outbound = &city.outbound_relationships[0];
assert_eq!( assert_eq!(
outbound.local_columns, outbound.local_columns,
@@ -329,7 +329,7 @@ fn compound_fk_create_fk_makes_both_child_columns() {
) )
.await .await
.expect("add compound relationship with --create-fk"); .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"] { for col in ["c_country", "c_code"] {
assert!( assert!(
city.columns.iter().any(|c| c.name == col), city.columns.iter().any(|c| c.name == col),
@@ -527,7 +527,7 @@ fn compound_fk_survives_rebuild_from_text() {
.await; .await;
assert!(bad.is_err(), "compound FK still enforced after rebuild from text"); assert!(bad.is_err(), "compound FK still enforced after rebuild from text");
// Endpoints survived the round-trip intact. // 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!( assert_eq!(
city.outbound_relationships[0].other_columns, city.outbound_relationships[0].other_columns,
vec!["country".to_string(), "code".to_string()], vec!["country".to_string(), "code".to_string()],
@@ -563,7 +563,7 @@ fn compound_fk_undo_removes_the_relationship() {
.await .await
.expect("add compound relationship"); .expect("add compound relationship");
assert_eq!( assert_eq!(
db.describe_table("City".to_string(), None) db.describe_table("City".to_string())
.await .await
.unwrap() .unwrap()
.outbound_relationships .outbound_relationships
@@ -573,7 +573,7 @@ fn compound_fk_undo_removes_the_relationship() {
// One undo step removes the whole relationship (ADR-0013/0006). // One undo step removes the whole relationship (ADR-0013/0006).
db.undo().await.unwrap().expect("undo applied"); db.undo().await.unwrap().expect("undo applied");
assert!( assert!(
db.describe_table("City".to_string(), None) db.describe_table("City".to_string())
.await .await
.unwrap() .unwrap()
.outbound_relationships .outbound_relationships
+8 -54
View File
@@ -15,7 +15,7 @@ use rdbms_playground::db::Database;
use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, RowFilter, Type, Value}; use rdbms_playground::dsl::{ColumnSpec, ReferentialAction, RowFilter, Type, Value};
use rdbms_playground::persistence::Persistence; use rdbms_playground::persistence::Persistence;
use rdbms_playground::project::{ use rdbms_playground::project::{
self, DATA_DIR, HISTORY_LOG, PROJECT_YAML, self, DATA_DIR, PROJECT_YAML,
}; };
fn tempdir() -> tempfile::TempDir { fn tempdir() -> tempfile::TempDir {
@@ -44,11 +44,6 @@ fn open_project(
(project, db, path) (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 { fn read_yaml(project_path: &Path) -> String {
fs::read_to_string(project_path.join(PROJECT_YAML)).expect("project.yaml") 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: serial"), "yaml: {yaml}");
assert!(yaml.contains("type: text"), "yaml: {yaml}"); assert!(yaml.contains("type: text"), "yaml: {yaml}");
let history = read_history(&path); // ADR-0052: journaling moved to the dispatch layer (the worker no
assert_eq!(history.len(), 1, "expected one history line; got {history:?}"); // longer writes history.log); this test verifies only the yaml state.
assert!(history[0].ends_with("|ok|create table Customers with pk id(serial)")); // Journaling is covered by the history.rs/app.rs/replay tests.
} }
#[test] #[test]
@@ -119,11 +114,8 @@ fn insert_writes_csv_and_history() {
assert_eq!(lines[0], "id,Name"); assert_eq!(lines[0], "id,Name");
assert_eq!(lines[1], "1,Alice"); assert_eq!(lines[1], "1,Alice");
let history = read_history(&path); // ADR-0052: journaling moved off the worker; this test verifies the
assert!( // csv state only (journaling covered elsewhere).
history.iter().any(|l| l.ends_with("|ok|insert into Customers ('Alice')")),
"history missing insert: {history:?}",
);
} }
#[test] #[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] #[test]
fn failed_command_does_not_append_history_or_change_yaml() { fn failed_command_does_not_append_history_or_change_yaml() {
let data = tempdir(); 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"); assert_eq!(yaml_before, yaml_after, "failed cmd must not change yaml");
}); });
let history = read_history(&path); // ADR-0052: journaling moved off the worker; this test now verifies
// Only the first (successful) create_table should have logged. // only that a failed command does not change the yaml state.
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:?}");
} }
#[test] #[test]
+4 -4
View File
@@ -76,7 +76,7 @@ fn rebuild_restores_schema_only_project() {
// Phase 4: confirm Customers exists with the right shape. // Phase 4: confirm Customers exists with the right shape.
let desc = rt() 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"); .expect("describe_table");
assert_eq!(desc.name, "Customers"); assert_eq!(desc.name, "Customers");
let cols: Vec<&str> = desc.columns.iter().map(|c| c.name.as_str()).collect(); 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() 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"); .expect("query_data");
assert_eq!(rows.rows.len(), 2); assert_eq!(rows.rows.len(), 2);
let names: Vec<Option<String>> = rows.rows.iter().map(|r| r[1].clone()).collect(); 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 // Trigger any successful command so project.yaml is
// rewritten from the now-rebuilt db state. // rewritten from the now-rebuilt db state.
rt().block_on(async { rt().block_on(async {
db.describe_table("T".to_string(), Some("show table T".to_string())) db.describe_table("T".to_string())
.await .await
.unwrap(); .unwrap();
// describe is read-only; force a rewrite by adding a column. // describe is read-only; force a rewrite by adding a column.
@@ -451,7 +451,7 @@ fn rebuild_restores_indexes() {
}); });
let desc = rt() 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"); .expect("describe_table");
assert_eq!(desc.indexes.len(), 1, "index should survive rebuild"); assert_eq!(desc.indexes.len(), 1, "index should survive rebuild");
assert_eq!(desc.indexes[0].name, "idx_email"); assert_eq!(desc.indexes[0].name, "idx_email");
+4 -7
View File
@@ -173,15 +173,12 @@ fn rebuild_against_populated_db_wipes_and_reloads() {
.expect("rebuild"); .expect("rebuild");
}); });
let rows = rt() 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(); .unwrap();
assert_eq!(rows.rows.len(), 1); assert_eq!(rows.rows.len(), 1);
assert_eq!(rows.rows[0][1].as_deref(), Some("Edna")); assert_eq!(rows.rows[0][1].as_deref(), Some("Edna"));
// history.log should contain the rebuild entry. // ADR-0052: `rebuild` journaling moved to the dispatch layer
let history = fs::read_to_string(project_path.join("history.log")).unwrap(); // (`spawn_rebuild`), so the direct worker call here no longer writes
assert!( // history.log; this test verifies the wipe/reload behaviour only.
history.lines().any(|l| l.ends_with("|ok|rebuild")),
"history.log missing rebuild entry:\n{history}",
);
} }
+1 -1
View File
@@ -362,7 +362,7 @@ fn end_to_end_export_then_import_real_project() {
// Round-trip: the inserted row is back. // Round-trip: the inserted row is back.
let data_view = rt() 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"); .expect("query data");
assert_eq!(data_view.rows.len(), 1); assert_eq!(data_view.rows.len(), 1);
// Serial id auto-filled to 1; Name was the inserted value. // Serial id auto-filled to 1; Name was the inserted value.
+32 -6
View File
@@ -141,9 +141,9 @@ fn read_recent_history_returns_appended_entries_in_order() {
let tmp = tempdir(); let tmp = tempdir();
let project = Project::create_temp(tmp.path()).unwrap(); let project = Project::create_temp(tmp.path()).unwrap();
let p = Persistence::new(project.path().to_path_buf()); let p = Persistence::new(project.path().to_path_buf());
p.append_history("create table A with pk").unwrap(); p.append_history("create table A with pk", false).unwrap();
p.append_history("create table B with pk").unwrap(); p.append_history("create table B with pk", false).unwrap();
p.append_history("create table C with pk").unwrap(); p.append_history("create table C with pk", false).unwrap();
let entries = p.read_recent_history(10).unwrap(); let entries = p.read_recent_history(10).unwrap();
assert_eq!( assert_eq!(
entries, entries,
@@ -165,9 +165,9 @@ fn hydration_reads_both_ok_and_err_records() {
let tmp = tempdir(); let tmp = tempdir();
let project = Project::create_temp(tmp.path()).unwrap(); let project = Project::create_temp(tmp.path()).unwrap();
let p = Persistence::new(project.path().to_path_buf()); let p = Persistence::new(project.path().to_path_buf());
p.append_history("create table A with pk").unwrap(); p.append_history("create table A with pk", false).unwrap();
p.append_history_failure("insert into A (1, 2, 3)").unwrap(); p.append_history_failure("insert into A (1, 2, 3)", false).unwrap();
p.append_history("show data A").unwrap(); p.append_history("show data A", false).unwrap();
let entries = p.read_recent_history(10).unwrap(); let entries = p.read_recent_history(10).unwrap();
assert_eq!( assert_eq!(
entries, entries,
@@ -194,6 +194,32 @@ fn seed_history_replaces_in_memory_history() {
assert_eq!(app.history, vec!["x".to_string(), "y".to_string()]); 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] #[test]
fn seed_history_preserves_chronological_order_for_navigation() { fn seed_history_preserves_chronological_order_for_navigation() {
let mut app = App::new(); let mut app = App::new();
+6 -6
View File
@@ -107,7 +107,7 @@ fn generates_junction_with_compound_pk_and_two_enforced_fks() {
assert!(tables.contains(&"Students_Courses".to_string()), "tables: {tables:?}"); assert!(tables.contains(&"Students_Courses".to_string()), "tables: {tables:?}");
// Two FK columns, both part of the compound PK. // 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)> = let cols: Vec<(&str, bool)> =
desc.columns.iter().map(|c| (c.name.as_str(), c.primary_key)).collect(); desc.columns.iter().map(|c| (c.name.as_str(), c.primary_key)).collect();
assert_eq!( assert_eq!(
@@ -191,7 +191,7 @@ fn compound_parent_pk_contributes_one_fk_column_each() {
.await .await
.expect("create m:n"); .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(); 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"]); assert_eq!(names, vec!["Students_id", "Sections_course_id", "Sections_term"]);
// All three form the compound PK. // 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). // Deleting the student cascades to the junction (ON DELETE CASCADE).
db.delete("Students".to_string(), RowFilter::AllRows, None).await.unwrap(); 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); 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(); let tables = db.list_tables().await.unwrap();
assert!(!tables.contains(&"Students_Courses".to_string()), "undo should remove the junction: {tables:?}"); assert!(!tables.contains(&"Students_Courses".to_string()), "undo should remove the junction: {tables:?}");
// The parents' relationships are gone too (the junction held them). // 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"); 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(&"Enrollments".to_string()), "tables: {tables:?}");
assert!(!tables.contains(&"Students_Courses".to_string())); assert!(!tables.contains(&"Students_Courses".to_string()));
// Both relationships survive the rename (rebuild-preserving). // 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"); 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"); db.rebuild_from_text(project.path().to_path_buf(), None).await.expect("rebuild");
let tables = db.list_tables().await.unwrap(); let tables = db.list_tables().await.unwrap();
assert!(tables.contains(&"Students_Courses".to_string()), "junction survived: {tables:?}"); 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_eq!(desc.outbound_relationships.len(), 2, "both FKs reconstructed");
assert!(desc.columns.iter().all(|c| c.primary_key), "compound PK reconstructed"); assert!(desc.columns.iter().all(|c| c.primary_key), "compound PK reconstructed");
}); });
+9 -9
View File
@@ -108,13 +108,13 @@ fn replay_runs_advanced_sql_create_table_as_a_write() {
// The SQL DDL line actually created the structural table… // The SQL DDL line actually created the structural table…
let desc = rt() 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"); .expect("describe");
let names: Vec<String> = desc.columns.iter().map(|c| c.name.clone()).collect(); let names: Vec<String> = desc.columns.iter().map(|c| c.name.clone()).collect();
assert_eq!(names, vec!["id".to_string(), "name".to_string()]); assert_eq!(names, vec!["id".to_string(), "name".to_string()]);
// …and the following insert (serial id auto-filled) ran against it. // …and the following insert (serial id auto-filled) ran against it.
let rows = rt() 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") .expect("query")
.rows; .rows;
assert_eq!(rows.len(), 1); assert_eq!(rows.len(), 1);
@@ -139,7 +139,7 @@ fn replay_three_lines_dispatches_three_commands() {
// The dispatched commands actually mutated state. // The dispatched commands actually mutated state.
let data_result = rt() 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"); .expect("query_data");
assert_eq!(data_result.rows.len(), 1, "row inserted"); assert_eq!(data_result.rows.len(), 1, "row inserted");
assert_eq!(data_result.rows[0][1].as_deref(), Some("Alice")); 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); assert_completed(&events, 3);
let data_result = rt() 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"); .expect("query_data");
assert_eq!(data_result.rows.len(), 1, "only the ok INSERT applied"); assert_eq!(data_result.rows.len(), 1, "only the ok INSERT applied");
assert_eq!(data_result.rows[0][1].as_deref(), Some("alpha")); 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:?}"), other => panic!("expected ReplayCompleted, got {other:?}"),
} }
let data_result = rt() 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"); .expect("query_data");
assert!( assert!(
data_result.columns.iter().any(|c| c == "v"), 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 // but earlier commands stayed applied (table T exists with
// the `name` column). // the `name` column).
let desc = rt() 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"); .expect("describe_table");
assert!( assert!(
desc.columns.iter().any(|c| c.name == "name"), desc.columns.iter().any(|c| c.name == "name"),
"earlier add column should have stayed applied" "earlier add column should have stayed applied"
); );
let data_result = rt() 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"); .expect("query_data");
assert!( assert!(
data_result.rows.is_empty(), 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 // The earlier two lines stayed applied; the failing insert
// did not run — state is intact. // did not run — state is intact.
let data_result = rt() 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"); .expect("query_data");
assert!( assert!(
data_result.rows.is_empty(), 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:?}"), other => panic!("expected ReplayCompleted (nested replay skipped), got {other:?}"),
} }
// The nested file's table was NOT created (the replay was skipped). // 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)"); assert!(cols.is_err(), "inner.commands' table T must not exist (nested replay skipped)");
} }
-18
View File
@@ -430,24 +430,6 @@ fn seed_is_reproducible_with_a_fixed_seed() {
assert_eq!(csv1, csv2, "the same --seed must reproduce identical data"); 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) — // — FK sampling, empty-parent error, block guard (ADR-0048 D14 / D1) —
/// `Users(id serial pk, name text)` + `Orders(id serial pk, user_id /// `Users(id serial pk, name text)` + `Orders(id serial pk, user_id
+1 -1
View File
@@ -462,7 +462,7 @@ fn app_show_table_renders_relationships_as_compact_diagrams() {
rt.block_on(seed_schema(&db)); rt.block_on(seed_schema(&db));
// Orders holds the FK to Customers — an outbound relationship. // Orders holds the FK to Customers — an outbound relationship.
let desc = rt let desc = rt
.block_on(db.describe_table("Orders".to_string(), None)) .block_on(db.describe_table("Orders".to_string()))
.expect("describe Orders"); .expect("describe Orders");
let mut app = App::new(); let mut app = App::new();
+17 -17
View File
@@ -111,7 +111,7 @@ fn e2e_alter_drop_compound_primary_key_member_is_refused() {
/// The current user-facing type of column `name` in table `T`. /// The current user-facing type of column `name` in table `T`.
fn col_type(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option<Type> { 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") .expect("describe")
.columns .columns
.into_iter() .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> { 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") .expect("describe")
.columns .columns
.into_iter() .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. // The DEFAULT backfilled the pre-existing row to qty = 0.
let rows = r 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") .expect("query")
.rows; .rows;
assert_eq!(rows.len(), 1); assert_eq!(rows.len(), 1);
@@ -252,7 +252,7 @@ fn e2e_alter_column_type_clean_and_lossy_convert() {
} }
let rows = r 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") .expect("query")
.rows; .rows;
assert_eq!(rows.len(), 1); 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"); assert_eq!(col_type(&db, &r, "n"), Some(Type::Serial), "int→serial converted the column");
let rows = r 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") .expect("query")
.rows; .rows;
assert_eq!(rows[0][1].as_deref(), Some("100"), "the existing value is preserved"); 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"); .expect("write");
r.block_on(run_replay(&db, project.path(), "u.commands")); r.block_on(run_replay(&db, project.path(), "u.commands"));
let has_unique = || { let has_unique = || {
!r.block_on(db.describe_table("T".to_string(), None)) !r.block_on(db.describe_table("T".to_string()))
.expect("describe") .expect("describe")
.unique_constraints .unique_constraints
.is_empty() .is_empty()
@@ -878,7 +878,7 @@ fn e2e_describe_shows_table_level_constraints() {
"events: {events:?}" "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!( assert_eq!(
desc.unique_constraints, desc.unique_constraints,
vec![vec!["a".to_string(), "b".to_string()]], 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"); assert!(!csv_path(&project, "Orders").exists(), "data/Orders.csv removed");
let rows = r 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") .expect("query")
.rows; .rows;
assert_eq!(rows.len(), 2); 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:?}" "Purchases round-tripped through a fresh rebuild: {tables:?}"
); );
let rows = r 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") .expect("query")
.rows; .rows;
assert_eq!(rows.len(), 2); 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. // 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.len(), 1);
assert_eq!(c.outbound_relationships[0].other_table, "Parent"); 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. // 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.len(), 1);
assert_eq!(p.inbound_relationships[0].other_table, "Child"); 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`. // 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.outbound_relationships[0].other_table, "Tree");
assert_eq!(t.inbound_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:?}" "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.len(), 1, "the index followed the rename");
assert_eq!( assert_eq!(
u.indexes[0].name, "T_email_idx", 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). // Survives a fresh rebuild (recreated from IndexSchema on table Users).
let db = fresh_rebuild(db, &project, &r); 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.len(), 1);
assert_eq!(u.indexes[0].name, "T_email_idx"); 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:?}" "undo restored the old table name: {tables:?}"
); );
assert_eq!( 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, 1,
"the row is back under the old name" "the row is back under the old name"
); );
@@ -1427,7 +1427,7 @@ fn e2e_alter_column_set_default_applies() {
)) ))
.expect("insert omitting qty"); .expect("insert omitting qty");
let rows = r 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") .expect("query")
.rows; .rows;
assert_eq!( assert_eq!(
@@ -1473,7 +1473,7 @@ fn e2e_alter_column_drop_default_removes_it() {
)) ))
.expect("insert omitting qty"); .expect("insert omitting qty");
let rows = r 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") .expect("query")
.rows; .rows;
assert_eq!( assert_eq!(
+4 -4
View File
@@ -55,7 +55,7 @@ fn insert_row(db: &Database, r: &tokio::runtime::Runtime, id: i64, email: &str)
} }
fn index(db: &Database, r: &tokio::runtime::Runtime, name: &str) -> Option<(Vec<String>, bool)> { 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") .expect("describe")
.indexes .indexes
.into_iter() .into_iter()
@@ -141,7 +141,7 @@ fn create_unique_index_on_duplicate_data_is_refused() {
#[test] #[test]
fn if_not_exists_on_an_existing_name_is_a_noop_and_journalled() { 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(); let r = rt();
make_t(&db, &r); make_t(&db, &r);
r.block_on(db.sql_create_index( 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::Skipped(name) => assert_eq!(name, "ix"),
CreateIndexOutcome::Created(_) => panic!("expected Skipped, got Created"), CreateIndexOutcome::Created(_) => panic!("expected Skipped, got Created"),
} }
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log"); // ADR-0052: journaling moved to the dispatch layer; this test now
assert!(log.contains(line), "the skipped create should be journalled; log:\n{log}"); // asserts only the no-op `Skipped` outcome.
} }
#[test] #[test]
+21 -21
View File
@@ -64,7 +64,7 @@ fn created_table_appears_with_playground_types() {
assert!(tables.contains(&"Widget".to_string())); assert!(tables.contains(&"Widget".to_string()));
let desc = r let desc = r
.block_on(db.describe_table("Widget".to_string(), None)) .block_on(db.describe_table("Widget".to_string()))
.expect("describe"); .expect("describe");
let types: Vec<(String, Option<Type>)> = desc let types: Vec<(String, Option<Type>)> = desc
.columns .columns
@@ -98,7 +98,7 @@ fn integer_primary_key_is_plain_int() {
)) ))
.expect("create"); .expect("create");
let desc = r let desc = r
.block_on(db.describe_table("T".to_string(), None)) .block_on(db.describe_table("T".to_string()))
.expect("describe"); .expect("describe");
assert_eq!(desc.columns[0].user_type, Some(Type::Int)); assert_eq!(desc.columns[0].user_type, Some(Type::Int));
} }
@@ -137,7 +137,7 @@ fn serial_pk_autoincrements_in_multi_column_table() {
} }
let data = r let data = r
.block_on(db.query_data("T".to_string(), None, None, None)) .block_on(db.query_data("T".to_string(), None, None))
.expect("query"); .expect("query");
let id_idx = data let id_idx = data
.columns .columns
@@ -220,7 +220,7 @@ fn table_without_primary_key_is_allowed() {
)) ))
.expect("insert into PK-less table"); .expect("insert into PK-less table");
let data = r let data = r
.block_on(db.query_data("Notes".to_string(), None, None, None)) .block_on(db.query_data("Notes".to_string(), None, None))
.expect("query"); .expect("query");
assert_eq!(data.rows.len(), 1); assert_eq!(data.rows.len(), 1);
} }
@@ -299,7 +299,7 @@ fn default_is_applied_when_column_omitted() {
)) ))
.expect("insert"); .expect("insert");
let data = r let data = r
.block_on(db.query_data("T".to_string(), None, None, None)) .block_on(db.query_data("T".to_string(), None, None))
.expect("query"); .expect("query");
let n_idx = data.columns.iter().position(|c| c == "n").expect("n column"); let n_idx = data.columns.iter().position(|c| c == "n").expect("n column");
assert_eq!(data.rows[0][n_idx].as_deref(), Some("7"), "DEFAULT 7 applied"); assert_eq!(data.rows[0][n_idx].as_deref(), Some("7"), "DEFAULT 7 applied");
@@ -381,7 +381,7 @@ fn check_default_and_composite_unique_survive_rebuild() {
// A valid row inserts; DEFAULT n=7 survived. // A valid row inserts; DEFAULT n=7 survived.
r.block_on(ins("1", "1", "5")).expect("valid row"); r.block_on(ins("1", "1", "5")).expect("valid row");
let data = r let data = r
.block_on(db.query_data("T".to_string(), None, None, None)) .block_on(db.query_data("T".to_string(), None, None))
.expect("query"); .expect("query");
let n_idx = data.columns.iter().position(|c| c == "n").expect("n column"); let n_idx = data.columns.iter().position(|c| c == "n").expect("n column");
assert_eq!(data.rows[0][n_idx].as_deref(), Some("7"), "DEFAULT survived rebuild"); assert_eq!(data.rows[0][n_idx].as_deref(), Some("7"), "DEFAULT survived rebuild");
@@ -590,7 +590,7 @@ fn if_not_exists_noop_is_journalled() {
// A successful no-op is still a submission and belongs in the // A successful no-op is still a submission and belongs in the
// complete journal (ADR-0034) — like read-only `show table`, and // complete journal (ADR-0034) — like read-only `show table`, and
// unlike a *failed* duplicate-create (journalled `err`). // unlike a *failed* duplicate-create (journalled `err`).
let (p, db, _d) = open(false); let (_p, db, _d) = open(false);
let r = rt(); let r = rt();
r.block_on(db.sql_create_table( r.block_on(db.sql_create_table(
"T".to_string(), "T".to_string(),
@@ -617,8 +617,8 @@ fn if_not_exists_noop_is_journalled() {
)) ))
.expect("no-op"); .expect("no-op");
assert!(matches!(out, CreateOutcome::Skipped(_))); assert!(matches!(out, CreateOutcome::Skipped(_)));
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log"); // ADR-0052: journaling moved to the dispatch layer; this test now
assert!(log.contains(noop), "the no-op skip should be journalled; log:\n{log}"); // asserts only the no-op `Skipped` outcome.
} }
#[test] #[test]
@@ -679,7 +679,7 @@ fn sql_create_table_is_one_undo_step() {
/// Sorted `id` column values of table `T`. /// Sorted `id` column values of table `T`.
fn ids(db: &Database, r: &tokio::runtime::Runtime) -> Vec<Option<String>> { fn ids(db: &Database, r: &tokio::runtime::Runtime) -> Vec<Option<String>> {
let d = r let d = r
.block_on(db.query_data("T".to_string(), None, None, None)) .block_on(db.query_data("T".to_string(), None, None))
.expect("query"); .expect("query");
let idx = d.columns.iter().position(|c| c == "id").expect("id column"); let idx = d.columns.iter().position(|c| c == "id").expect("id column");
let mut v: Vec<Option<String>> = d.rows.iter().map(|row| row[idx].clone()).collect(); let mut v: Vec<Option<String>> = d.rows.iter().map(|row| row[idx].clone()).collect();
@@ -801,7 +801,7 @@ fn dropping_a_column_a_table_check_references_fails_cleanly() {
// The table is intact: both columns survive (rollback) ... // The table is intact: both columns survive (rollback) ...
let desc = r let desc = r
.block_on(db.describe_table("T".to_string(), None)) .block_on(db.describe_table("T".to_string()))
.expect("describe still works"); .expect("describe still works");
assert_eq!( assert_eq!(
desc.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>(), desc.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>(),
@@ -925,14 +925,14 @@ fn foreign_key_creates_named_relationship_visible_in_describe() {
.expect("create child with FK"); .expect("create child with FK");
// The child has an outbound relationship; the parent an inbound one. // The child has an outbound relationship; the parent an inbound one.
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe child"); let child = r.block_on(db.describe_table("child".to_string())).expect("describe child");
assert_eq!(child.outbound_relationships.len(), 1, "child references parent"); assert_eq!(child.outbound_relationships.len(), 1, "child references parent");
let rel = &child.outbound_relationships[0]; let rel = &child.outbound_relationships[0];
assert_eq!(rel.name, "parent_id_to_child_pid", "auto-named per ADR-0013"); assert_eq!(rel.name, "parent_id_to_child_pid", "auto-named per ADR-0013");
assert_eq!(rel.other_table, "parent"); assert_eq!(rel.other_table, "parent");
assert_eq!(rel.local_columns, vec!["pid".to_string()]); assert_eq!(rel.local_columns, vec!["pid".to_string()]);
let parent = r.block_on(db.describe_table("parent".to_string(), None)).expect("describe parent"); let parent = r.block_on(db.describe_table("parent".to_string())).expect("describe parent");
assert_eq!(parent.inbound_relationships.len(), 1, "parent is referenced by child"); assert_eq!(parent.inbound_relationships.len(), 1, "parent is referenced by child");
} }
@@ -954,7 +954,7 @@ fn explicit_constraint_name_is_used() {
Some("create table child (id serial primary key, pid int, constraint child_to_parent foreign key (pid) references parent(id))".to_string()), Some("create table child (id serial primary key, pid int, constraint child_to_parent foreign key (pid) references parent(id))".to_string()),
)) ))
.expect("create child with named FK"); .expect("create child with named FK");
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe"); let child = r.block_on(db.describe_table("child".to_string())).expect("describe");
assert_eq!(child.outbound_relationships[0].name, "child_to_parent"); assert_eq!(child.outbound_relationships[0].name, "child_to_parent");
} }
@@ -974,7 +974,7 @@ fn bare_references_resolves_to_parent_single_column_pk() {
Some("create table child (id serial primary key, pid int references parent)".to_string()), Some("create table child (id serial primary key, pid int references parent)".to_string()),
)) ))
.expect("create child with bare REFERENCES"); .expect("create child with bare REFERENCES");
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe"); let child = r.block_on(db.describe_table("child".to_string())).expect("describe");
assert_eq!(child.outbound_relationships[0].other_columns, vec!["id".to_string()], "resolved to parent PK"); assert_eq!(child.outbound_relationships[0].other_columns, vec!["id".to_string()], "resolved to parent PK");
} }
@@ -1108,7 +1108,7 @@ fn create_table_with_fk_is_one_undo_step() {
// parent (now un-referenced) can be described without a dangling rel. // parent (now un-referenced) can be described without a dangling rel.
r.block_on(db.undo()).expect("undo").expect("a step was undone"); r.block_on(db.undo()).expect("undo").expect("a step was undone");
assert!(!r.block_on(db.list_tables()).unwrap().contains(&"child".to_string())); assert!(!r.block_on(db.list_tables()).unwrap().contains(&"child".to_string()));
let parent = r.block_on(db.describe_table("parent".to_string(), None)).expect("describe parent"); let parent = r.block_on(db.describe_table("parent".to_string())).expect("describe parent");
assert!(parent.inbound_relationships.is_empty(), "the relationship was undone with the table"); assert!(parent.inbound_relationships.is_empty(), "the relationship was undone with the table");
} }
@@ -1152,7 +1152,7 @@ fn foreign_key_on_delete_cascade_takes_effect() {
)) ))
.expect("delete parent"); .expect("delete parent");
let child_rows = r let child_rows = r
.block_on(db.query_data("child".to_string(), None, None, None)) .block_on(db.query_data("child".to_string(), None, None))
.expect("query child"); .expect("query child");
assert!(child_rows.rows.is_empty(), "ON DELETE CASCADE removed the child row"); assert!(child_rows.rows.is_empty(), "ON DELETE CASCADE removed the child row");
} }
@@ -1232,7 +1232,7 @@ fn fk_survives_a_rebuild_triggering_column_add() {
.expect("add column via rebuild"); .expect("add column via rebuild");
// The relationship still exists after the rebuild. // The relationship still exists after the rebuild.
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe"); let child = r.block_on(db.describe_table("child".to_string())).expect("describe");
assert_eq!(child.outbound_relationships.len(), 1, "FK survived the column-add rebuild"); assert_eq!(child.outbound_relationships.len(), 1, "FK survived the column-add rebuild");
// And the engine still enforces it (now and after a fresh rebuild). // And the engine still enforces it (now and after a fresh rebuild).
insert_parent_row(&db, &r); insert_parent_row(&db, &r);
@@ -1275,7 +1275,7 @@ fn fk_referential_actions_survive_rebuild() {
)) ))
.expect("create"); .expect("create");
r.block_on(db.rebuild_from_text(p.path().to_path_buf(), None)).expect("rebuild"); r.block_on(db.rebuild_from_text(p.path().to_path_buf(), None)).expect("rebuild");
let child = r.block_on(db.describe_table("child".to_string(), None)).expect("describe"); let child = r.block_on(db.describe_table("child".to_string())).expect("describe");
let rel = &child.outbound_relationships[0]; let rel = &child.outbound_relationships[0];
assert_eq!(rel.on_delete, ReferentialAction::Cascade, "ON DELETE survived rebuild"); assert_eq!(rel.on_delete, ReferentialAction::Cascade, "ON DELETE survived rebuild");
assert_eq!(rel.on_update, ReferentialAction::SetNull, "ON UPDATE survived rebuild"); assert_eq!(rel.on_update, ReferentialAction::SetNull, "ON UPDATE survived rebuild");
@@ -1299,7 +1299,7 @@ fn dropping_the_child_clears_the_fk_relationship() {
.expect("create"); .expect("create");
r.block_on(db.drop_table("child".to_string(), Some("drop table child".to_string()))) r.block_on(db.drop_table("child".to_string(), Some("drop table child".to_string())))
.expect("drop child"); .expect("drop child");
let parent = r.block_on(db.describe_table("parent".to_string(), None)).expect("describe parent"); let parent = r.block_on(db.describe_table("parent".to_string())).expect("describe parent");
assert!(parent.inbound_relationships.is_empty(), "dropping the child cleared the relationship"); assert!(parent.inbound_relationships.is_empty(), "dropping the child cleared the relationship");
} }
@@ -1341,7 +1341,7 @@ fn bare_self_reference_resolves_to_own_pk() {
Some("create table emp (id int primary key, mgr int references emp)".to_string()), Some("create table emp (id int primary key, mgr int references emp)".to_string()),
)) ))
.expect("create self-referential emp with a bare reference"); .expect("create self-referential emp with a bare reference");
let emp = r.block_on(db.describe_table("emp".to_string(), None)).expect("describe"); let emp = r.block_on(db.describe_table("emp".to_string())).expect("describe");
assert_eq!(emp.outbound_relationships[0].other_columns, vec!["id".to_string()], "bare self-ref resolved to own PK"); assert_eq!(emp.outbound_relationships[0].other_columns, vec!["id".to_string()], "bare self-ref resolved to own PK");
// Enforced: a non-existent manager is rejected. // Enforced: a non-existent manager is rejected.
r.block_on(db.insert( r.block_on(db.insert(
+4 -17
View File
@@ -154,7 +154,7 @@ fn delete_without_where_runs_across_all_rows() {
let csv = read_csv(&project, "t").unwrap_or_default(); let csv = read_csv(&project, "t").unwrap_or_default();
assert!(!csv.contains('a') && !csv.contains('b') && !csv.contains('c'), "no rows left: {csv:?}"); assert!(!csv.contains('a') && !csv.contains('b') && !csv.contains('c'), "no rows left: {csv:?}");
let remaining = rt let remaining = rt
.block_on(db.query_data("t".to_string(), None, None, None)) .block_on(db.query_data("t".to_string(), None, None))
.expect("query t"); .expect("query t");
assert!(remaining.rows.is_empty(), "table empty after unfiltered delete"); assert!(remaining.rows.is_empty(), "table empty after unfiltered delete");
} }
@@ -261,19 +261,6 @@ fn r2_cascade_with_subquery_where() {
"only Bob's order remains: {orders_csv:?}"); "only Bob's order remains: {orders_csv:?}");
} }
#[test]
fn delete_appends_literal_line_to_history() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
seed(&db, &rt, "insert into t (id, v) values (1, 'x')", "t");
let input = "delete from t where id = 1";
run_delete(&db, &rt, input).expect("delete runs");
let body = std::fs::read_to_string(project.path().join("history.log"))
.expect("history.log present");
assert!(body.contains(input), "history records the literal line: {body:?}");
}
#[test] #[test]
fn cascade_to_two_children_reports_both() { fn cascade_to_two_children_reports_both() {
// DA gate (untested branch): a parent with TWO cascade children // DA gate (untested branch): a parent with TWO cascade children
@@ -315,8 +302,8 @@ fn cascade_to_two_children_reports_both() {
assert_eq!(by_child.get("Orders"), Some(&2), "two orders cascaded"); assert_eq!(by_child.get("Orders"), Some(&2), "two orders cascaded");
assert_eq!(by_child.get("Reviews"), Some(&1), "one review cascaded"); assert_eq!(by_child.get("Reviews"), Some(&1), "one review cascaded");
// Both child CSVs re-persisted to the post-cascade (empty) state. // Both child CSVs re-persisted to the post-cascade (empty) state.
let orders = rt.block_on(db.query_data("Orders".to_string(), None, None, None)).unwrap(); let orders = rt.block_on(db.query_data("Orders".to_string(), None, None)).unwrap();
let reviews = rt.block_on(db.query_data("Reviews".to_string(), None, None, None)).unwrap(); let reviews = rt.block_on(db.query_data("Reviews".to_string(), None, None)).unwrap();
assert!(orders.rows.is_empty() && reviews.rows.is_empty(), "both children emptied"); assert!(orders.rows.is_empty() && reviews.rows.is_empty(), "both children emptied");
let _ = &project; let _ = &project;
} }
@@ -374,7 +361,7 @@ fn delete_violating_fk_fails_and_persists_nothing() {
let result = run_delete(&db, &rt, input); let result = run_delete(&db, &rt, input);
assert!(result.is_err(), "delete of a referenced parent must be rejected"); assert!(result.is_err(), "delete of a referenced parent must be rejected");
// Rolled back: Alice survives. // Rolled back: Alice survives.
let customers = rt.block_on(db.query_data("Customers".to_string(), None, None, None)).unwrap(); let customers = rt.block_on(db.query_data("Customers".to_string(), None, None)).unwrap();
assert_eq!(customers.rows.len(), 1, "parent row preserved after rejected delete"); assert_eq!(customers.rows.len(), 1, "parent row preserved after rejected delete");
// No history line for the failed statement (written only on success). // No history line for the failed statement (written only on success).
let history = std::fs::read_to_string(project.path().join("history.log")).unwrap_or_default(); let history = std::fs::read_to_string(project.path().join("history.log")).unwrap_or_default();
+1 -1
View File
@@ -149,7 +149,7 @@ fn seed(db: &Database, rt: &tokio::runtime::Runtime, sql: &str) {
} }
fn query(db: &Database, rt: &tokio::runtime::Runtime, table: &str) -> Vec<Vec<Option<String>>> { fn query(db: &Database, rt: &tokio::runtime::Runtime, table: &str) -> Vec<Vec<Option<String>>> {
rt.block_on(db.query_data(table.to_string(), None, None, None)) rt.block_on(db.query_data(table.to_string(), None, None))
.unwrap_or_else(|e| panic!("query_data {table}: {e:?}")) .unwrap_or_else(|e| panic!("query_data {table}: {e:?}"))
.rows .rows
} }
+4 -4
View File
@@ -55,7 +55,7 @@ fn make_t_with_index(db: &Database, r: &tokio::runtime::Runtime) -> String {
} }
fn index_names(db: &Database, r: &tokio::runtime::Runtime) -> Vec<String> { fn index_names(db: &Database, r: &tokio::runtime::Runtime) -> Vec<String> {
r.block_on(db.describe_table("T".to_string(), None)) r.block_on(db.describe_table("T".to_string()))
.expect("describe") .expect("describe")
.indexes .indexes
.into_iter() .into_iter()
@@ -84,7 +84,7 @@ fn drop_index_removes_an_existing_index_and_shows_the_table() {
#[test] #[test]
fn if_exists_on_an_absent_index_is_a_noop_and_journalled() { fn if_exists_on_an_absent_index_is_a_noop_and_journalled() {
let (p, db, _d) = open(false); let (_p, db, _d) = open(false);
let r = rt(); let r = rt();
let line = "drop index if exists ghost_idx"; let line = "drop index if exists ghost_idx";
let out = r let out = r
@@ -92,8 +92,8 @@ fn if_exists_on_an_absent_index_is_a_noop_and_journalled() {
.expect("IF EXISTS on an absent index succeeds as a no-op"); .expect("IF EXISTS on an absent index succeeds as a no-op");
assert!(matches!(out, DropIndexOutcome::Skipped)); assert!(matches!(out, DropIndexOutcome::Skipped));
// The no-op is still journalled (ADR-0034), like the create-skip. // The no-op is still journalled (ADR-0034), like the create-skip.
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log"); // ADR-0052: journaling moved to the dispatch layer; this test now
assert!(log.contains(line), "the skipped drop should be journalled; log:\n{log}"); // asserts only the no-op `Skipped` outcome.
} }
#[test] #[test]
+4 -4
View File
@@ -59,7 +59,7 @@ fn drop_table_removes_an_existing_table() {
#[test] #[test]
fn if_exists_on_an_absent_table_is_a_noop_and_journalled() { fn if_exists_on_an_absent_table_is_a_noop_and_journalled() {
let (p, db, _d) = open(false); let (_p, db, _d) = open(false);
let r = rt(); let r = rt();
let line = "drop table if exists Ghost"; let line = "drop table if exists Ghost";
let out = r let out = r
@@ -67,8 +67,8 @@ fn if_exists_on_an_absent_table_is_a_noop_and_journalled() {
.expect("IF EXISTS on an absent table succeeds as a no-op"); .expect("IF EXISTS on an absent table succeeds as a no-op");
assert!(matches!(out, DropOutcome::Skipped)); assert!(matches!(out, DropOutcome::Skipped));
// The no-op is still journalled (ADR-0034), like the create-skip. // The no-op is still journalled (ADR-0034), like the create-skip.
let log = std::fs::read_to_string(p.path().join("history.log")).expect("read history.log"); // ADR-0052: journaling moved to the dispatch layer; this test now
assert!(log.contains(line), "the skipped drop should be journalled; log:\n{log}"); // asserts only the no-op `Skipped` outcome.
} }
#[test] #[test]
@@ -150,7 +150,7 @@ fn drop_table_is_one_undo_step_and_restores_data() {
assert!(r.block_on(db.undo()).expect("undo").is_some(), "the drop was one undo step"); assert!(r.block_on(db.undo()).expect("undo").is_some(), "the drop was one undo step");
assert!(r.block_on(db.list_tables()).unwrap().contains(&"T".to_string())); assert!(r.block_on(db.list_tables()).unwrap().contains(&"T".to_string()));
let data = r let data = r
.block_on(db.query_data("T".to_string(), None, None, None)) .block_on(db.query_data("T".to_string(), None, None))
.expect("query"); .expect("query");
assert_eq!(data.rows.len(), 1, "the dropped row was restored by undo"); assert_eq!(data.rows.len(), 1, "the dropped row was restored by undo");
} }
-44
View File
@@ -132,30 +132,6 @@ fn no_column_list_full_arity_insert_persists() {
assert!(csv.contains("full-arity"), "CSV reflects the row: {csv:?}"); assert!(csv.contains("full-arity"), "CSV reflects the row: {csv:?}");
} }
#[test]
fn insert_appends_literal_line_to_history() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_t(&db, &rt);
// ADR-0030 §11: the literal submitted line lands in history.log.
let source = "insert into T (a, b) values (1, 'logged')";
rt.block_on(db.run_sql_insert(
"insert into T (a, b) values (1, 'logged')".to_string(),
Some(source.to_string()),
"T".to_string(),
Vec::new(),
String::new(),
false,
))
.expect("insert runs");
let body = std::fs::read_to_string(project.path().join("history.log"))
.expect("history.log present after an INSERT");
assert!(
body.contains(source),
"history.log records the literal INSERT line: {body:?}",
);
}
#[test] #[test]
fn failed_insert_rolls_back_and_does_not_repersist() { fn failed_insert_rolls_back_and_does_not_repersist() {
let (project, db, _dir) = open_project_db(); let (project, db, _dir) = open_project_db();
@@ -583,26 +559,6 @@ fn combined_serial_and_shortid_autofill() {
assert_eq!(rows[0][2], "x", "name preserved: {rows:?}"); assert_eq!(rows[0][2], "x", "name preserved: {rows:?}");
} }
#[test]
fn autofill_logs_original_source_not_rewritten_sql() {
// ADR-0030 §11: even though the worker rewrites the executed
// statement to bind synthesised shortids, history.log records
// the user's original line verbatim.
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::ShortId), ("label", Type::Text)], &["id"]);
let input = "insert into t (label) values ('x')";
run_sqlinsert(&db, &rt, input).expect("auto-fill insert runs");
let body = std::fs::read_to_string(project.path().join("history.log"))
.expect("history.log present");
assert!(body.contains(input), "original line logged: {body:?}");
// The rewritten parameterised INSERT must not leak into history.
assert!(
!body.contains("INSERT INTO") && !body.contains("?1"),
"rewritten SQL must not be logged: {body:?}",
);
}
#[test] #[test]
fn shortid_autofill_respects_mixed_case_column_name() { fn shortid_autofill_respects_mixed_case_column_name() {
// ADR-0009 / 3d DA gate: identifiers are case-preserving. The // ADR-0009 / 3d DA gate: identifiers are case-preserving. The
+10 -36
View File
@@ -215,7 +215,7 @@ fn decimal_aggregation_display_trims_ieee754_noise() {
// The reported case: the aggregate no longer leaks float noise. // The reported case: the aggregate no longer leaks float noise.
let agg = rt let agg = rt
.block_on(db.run_select("select sum(price * qty) from Products".to_string(), None)) .block_on(db.run_select("select sum(price * qty) from Products".to_string()))
.expect("aggregate select"); .expect("aggregate select");
assert_eq!( assert_eq!(
agg.rows[0][0].as_deref(), agg.rows[0][0].as_deref(),
@@ -226,7 +226,7 @@ fn decimal_aggregation_display_trims_ieee754_noise() {
// Raw decimal column is still exact — TEXT storage preserves // Raw decimal column is still exact — TEXT storage preserves
// the input string verbatim, including the trailing zero. // the input string verbatim, including the trailing zero.
let raw = rt let raw = rt
.block_on(db.run_select("select price from Products".to_string(), None)) .block_on(db.run_select("select price from Products".to_string()))
.expect("raw decimal select"); .expect("raw decimal select");
let prices: Vec<&str> = raw.rows.iter().map(|r| r[0].as_deref().unwrap()).collect(); let prices: Vec<&str> = raw.rows.iter().map(|r| r[0].as_deref().unwrap()).collect();
assert_eq!( assert_eq!(
@@ -240,10 +240,7 @@ fn decimal_aggregation_display_trims_ieee754_noise() {
fn database_run_select_constant_returns_a_single_row() { fn database_run_select_constant_returns_a_single_row() {
let (_p, db, _dir) = open_project_db(); let (_p, db, _dir) = open_project_db();
let data = rt() let data = rt()
.block_on(db.run_select( .block_on(db.run_select("select 1".to_string()))
"select 1".to_string(),
Some("select 1".to_string()),
))
.expect("`select 1` runs clean"); .expect("`select 1` runs clean");
assert_eq!(data.rows.len(), 1, "one result row"); assert_eq!(data.rows.len(), 1, "one result row");
assert_eq!(data.rows[0].len(), 1, "one column"); assert_eq!(data.rows[0].len(), 1, "one column");
@@ -288,7 +285,7 @@ fn database_run_select_from_user_table_returns_inserted_rows() {
.expect("insert row"); .expect("insert row");
}); });
let data = rt let data = rt
.block_on(db.run_select("select Name from T".to_string(), None)) .block_on(db.run_select("select Name from T".to_string()))
.expect("SELECT runs"); .expect("SELECT runs");
assert_eq!(data.rows.len(), 1); assert_eq!(data.rows.len(), 1);
assert_eq!(data.rows[0][0].as_deref(), Some("Ada")); assert_eq!(data.rows[0][0].as_deref(), Some("Ada"));
@@ -336,7 +333,7 @@ fn database_run_select_recovers_bool_column_type() {
.expect("insert row"); .expect("insert row");
}); });
let data = rt let data = rt
.block_on(db.run_select("select Active from Products".to_string(), None)) .block_on(db.run_select("select Active from Products".to_string()))
.expect("SELECT runs"); .expect("SELECT runs");
assert_eq!(data.rows.len(), 2); assert_eq!(data.rows.len(), 2);
assert_eq!(data.column_types, vec![Some(Type::Bool)]); assert_eq!(data.column_types, vec![Some(Type::Bool)]);
@@ -374,7 +371,7 @@ fn database_run_select_recovers_text_type_through_alias() {
// playground type is recovered. // playground type is recovered.
let data = rt let data = rt
.block_on( .block_on(
db.run_select("select Name as n from Users".to_string(), None), db.run_select("select Name as n from Users".to_string()),
) )
.expect("SELECT runs"); .expect("SELECT runs");
assert_eq!(data.columns, vec!["n".to_string()]); assert_eq!(data.columns, vec!["n".to_string()]);
@@ -402,7 +399,7 @@ fn database_run_select_computed_expression_stays_typeless() {
.expect("insert"); .expect("insert");
}); });
let data = rt let data = rt
.block_on(db.run_select("select Score + 1 from T".to_string(), None)) .block_on(db.run_select("select Score + 1 from T".to_string()))
.expect("SELECT runs"); .expect("SELECT runs");
assert_eq!(data.column_types, vec![None]); assert_eq!(data.column_types, vec![None]);
} }
@@ -439,7 +436,6 @@ fn engine_aggregate_in_where_routes_through_catalog() {
let err = rt let err = rt
.block_on(db.run_select( .block_on(db.run_select(
"select id from T where count(score) > 0".to_string(), "select id from T where count(score) > 0".to_string(),
None,
)) ))
.expect_err("engine should reject aggregate in WHERE"); .expect_err("engine should reject aggregate in WHERE");
let DbError::Sqlite { .. } = &err else { let DbError::Sqlite { .. } = &err else {
@@ -512,7 +508,6 @@ fn engine_group_by_missing_routes_through_catalog() {
let _ = rt let _ = rt
.block_on(db.run_select( .block_on(db.run_select(
"select category, count(*) from T group by category".to_string(), "select category, count(*) from T group by category".to_string(),
None,
)) ))
.expect("benign GROUP BY query runs"); .expect("benign GROUP BY query runs");
// Direct unit test on the matcher: ensure a message that // Direct unit test on the matcher: ensure a message that
@@ -574,7 +569,6 @@ fn engine_scalar_subquery_too_many_rows_routes_through_catalog() {
let _ = rt let _ = rt
.block_on(db.run_select( .block_on(db.run_select(
"select (select v from T) from T".to_string(), "select (select v from T) from T".to_string(),
None,
)) ))
.expect("benign scalar subquery query runs"); .expect("benign scalar subquery query runs");
let synthetic = DbError::Sqlite { let synthetic = DbError::Sqlite {
@@ -624,13 +618,13 @@ fn database_run_select_type_recovery_works_on_empty_table() {
}); });
// No INSERT — the table is empty. // No INSERT — the table is empty.
let data_text = rt let data_text = rt
.block_on(db.run_select("select col_text from Empty".to_string(), None)) .block_on(db.run_select("select col_text from Empty".to_string()))
.expect("SELECT runs even on empty table"); .expect("SELECT runs even on empty table");
assert!(data_text.rows.is_empty()); assert!(data_text.rows.is_empty());
assert_eq!(data_text.column_types, vec![Some(Type::Text)]); assert_eq!(data_text.column_types, vec![Some(Type::Text)]);
let data_blob = rt let data_blob = rt
.block_on(db.run_select("select col_blob from Empty".to_string(), None)) .block_on(db.run_select("select col_blob from Empty".to_string()))
.expect("SELECT runs even on empty table"); .expect("SELECT runs even on empty table");
assert!(data_blob.rows.is_empty()); assert!(data_blob.rows.is_empty());
assert_eq!( assert_eq!(
@@ -723,7 +717,7 @@ fn database_run_select_recovers_all_ten_playground_types() {
for (col, expected_type) in cases { for (col, expected_type) in cases {
let sql = format!("select {col} from AllTypes"); let sql = format!("select {col} from AllTypes");
let data = rt let data = rt
.block_on(db.run_select(sql.clone(), None)) .block_on(db.run_select(sql.clone()))
.expect("SELECT runs"); .expect("SELECT runs");
assert_eq!( assert_eq!(
data.column_types, data.column_types,
@@ -732,23 +726,3 @@ fn database_run_select_recovers_all_ten_playground_types() {
); );
} }
} }
#[test]
fn database_run_select_appends_to_history_when_source_present() {
let (project, db, _dir) = open_project_db();
let history_path = project.path().join("history.log");
// ADR-0030 §11: the literal submitted line lands in
// history.log so replay re-runs it.
let _ = rt()
.block_on(db.run_select(
"select 1".to_string(),
Some("select 1".to_string()),
))
.expect("SELECT runs");
let body = std::fs::read_to_string(&history_path)
.expect("history.log present after a SELECT");
assert!(
body.contains("select 1"),
"history.log records the literal SELECT line: {body:?}",
);
}
+1 -14
View File
@@ -205,19 +205,6 @@ fn update_matching_no_rows_is_ok() {
assert!(csv.contains("keep") && !csv.contains('x'), "unchanged: {csv:?}"); assert!(csv.contains("keep") && !csv.contains('x'), "unchanged: {csv:?}");
} }
#[test]
fn update_appends_literal_line_to_history() {
let (project, db, _dir) = open_project_db();
let rt = rt();
create_cols(&db, &rt, "t", &[("id", Type::Int), ("v", Type::Text)], &["id"]);
seed(&db, &rt, "insert into t (id, v) values (1, 'old')", "t");
let input = "update t set v = 'new' where id = 1";
run_update(&db, &rt, input).expect("update runs");
let body = std::fs::read_to_string(project.path().join("history.log"))
.expect("history.log present");
assert!(body.contains(input), "history records the literal line: {body:?}");
}
// ================================================================= // =================================================================
// ADR-0036 Phase 2 — `SET` literal value validation // ADR-0036 Phase 2 — `SET` literal value validation
// ================================================================= // =================================================================
@@ -514,7 +501,7 @@ fn update_all_rows_flag_in_advanced_updates_every_row() {
"the --all-rows update replays through the DSL fall-back; events: {events:?}" "the --all-rows update replays through the DSL fall-back; events: {events:?}"
); );
let rows = rt let rows = rt
.block_on(db.query_data("t".to_string(), None, None, None)) .block_on(db.query_data("t".to_string(), None, None))
.expect("query") .expect("query")
.rows; .rows;
assert_eq!(rows.len(), 2, "both rows present"); assert_eq!(rows.len(), 2, "both rows present");
+3 -3
View File
@@ -63,7 +63,7 @@ async fn insert_named(db: &Database, name: &str) {
} }
async fn row_count(db: &Database) -> usize { async fn row_count(db: &Database) -> usize {
db.query_data("Customers".to_string(), None, None, None) db.query_data("Customers".to_string(), None, None)
.await .await
.unwrap() .unwrap()
.rows .rows
@@ -306,7 +306,7 @@ async fn sql_delete(db: &Database, input: &str) {
} }
async fn count_t(db: &Database) -> usize { async fn count_t(db: &Database) -> usize {
db.query_data("T".to_string(), None, None, None) db.query_data("T".to_string(), None, None)
.await .await
.unwrap() .unwrap()
.rows .rows
@@ -378,7 +378,7 @@ fn undo_restores_db_and_csv_consistently() {
// Both the database read model and the on-disk CSV are // Both the database read model and the on-disk CSV are
// restored — the (db, csv) pair stays consistent. // restored — the (db, csv) pair stays consistent.
assert_eq!( assert_eq!(
db.query_data("T".to_string(), None, None, None) db.query_data("T".to_string(), None, None)
.await .await
.unwrap() .unwrap()
.rows .rows
+21 -11
View File
@@ -223,21 +223,30 @@ fn typing_colon_in_simple_mode_flips_prompt_to_advanced() {
} }
#[test] #[test]
fn status_bar_lists_quit_and_submit_in_all_modes() { fn status_bar_is_keystroke_only_and_state_aware() {
// ADR-0051 (issue #27): the bottom strip is keystrokes-only and
// tracks the interaction state. Typed-command words (`:` advanced
// once, `mode advanced`/`mode simple` switch) and `Ctrl-C quit`
// leave the strip; mode discovery moves to the hint (locked by the
// ui.rs unit tests). This test exercises the real render path.
let mut app = App::new(); let mut app = App::new();
let theme = Theme::dark(); let theme = Theme::dark();
let simple = rendered_text(&mut app, &theme, 80, 24); // Default (empty input): nav / complete / history / run keystrokes.
assert!(simple.contains("Enter"), "status bar lists Enter"); let default_view = rendered_text(&mut app, &theme, 80, 24);
assert!(simple.contains("Ctrl-C"), "status bar lists Ctrl-C"); assert!(default_view.contains("Ctrl-O sidebar"), "strip lists sidebar:\n{default_view}");
assert!(simple.contains("mode advanced")); assert!(default_view.contains("Enter run"), "strip lists run:\n{default_view}");
assert!(!default_view.contains("Ctrl-C"), "quit dropped from the strip:\n{default_view}");
assert!(
!default_view.contains("advanced once"),
"`:` command word dropped from the strip:\n{default_view}",
);
type_str(&mut app, "mode advanced"); // Editing (input has text): the #29 readline edit keys appear.
submit(&mut app); type_str(&mut app, "create");
let advanced = rendered_text(&mut app, &theme, 80, 24); let editing = rendered_text(&mut app, &theme, 80, 24);
assert!(advanced.contains("Enter")); assert!(editing.contains("Esc clear"), "editing strip lists clear:\n{editing}");
assert!(advanced.contains("Ctrl-C")); assert!(editing.contains("Ctrl-W del word"), "editing strip lists del word:\n{editing}");
assert!(advanced.contains("mode simple"));
} }
// --------------------------------------------------------------- // ---------------------------------------------------------------
@@ -652,6 +661,7 @@ fn dsl_failure_shows_friendly_error_in_output() {
}, },
facts: rdbms_playground::friendly::FailureContext::default(), facts: rdbms_playground::friendly::FailureContext::default(),
source: String::new(), source: String::new(),
advanced: false,
}); });
let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24); let rendered = rendered_text(&mut app, &Theme::dark(), 80, 24);
assert!( assert!(
+1
View File
@@ -250,6 +250,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
App(app) => match app { App(app) => match app {
AppCommand::Quit => "App(Quit)".into(), AppCommand::Quit => "App(Quit)".into(),
AppCommand::Help { .. } => "App(Help)".into(), AppCommand::Help { .. } => "App(Help)".into(),
AppCommand::Hint => "App(Hint)".into(),
AppCommand::Rebuild => "App(Rebuild)".into(), AppCommand::Rebuild => "App(Rebuild)".into(),
AppCommand::Save => "App(Save)".into(), AppCommand::Save => "App(Save)".into(),
AppCommand::SaveAs => "App(SaveAs)".into(), AppCommand::SaveAs => "App(SaveAs)".into(),